mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[ML] Anomaly Detection alert type (#89286)
* [ML] init ML alerts
* [ML] job selector
* [ML] move schema server-side
* [ML] fix type 🤦
* [ML] severity selector
* [ML] add alerting capabilities
* [ML] add alerting capabilities
* [ML] result type selector
* [ML] time range selector
* [ML] init alert preview endpoint
* [ML] update SeveritySelector component
* [ML] adjust the form
* [ML] adjust the form
* [ML] server-side, preview component
* [ML] update defaultMessage
* [ML] Anomaly explorer URL
* [ML] validate preview interval
* [ML] rename alert type
* [ML] fix i18n
* [ML] fix TS and mocks
* [ML] update licence headers
* [ML] add ts config references
* [ML] init functional tests
* [ML] functional test for creating anomaly detection alert
* [ML] adjust bucket results query
* [ML] fix messages
* [ML] resolve functional tests related issues
* [ML] fix result check
* [ML] change preview layout
* [ML] extend ml client types
* [ML] add missing types, remove unused client variable
* [ML] change to import type
* [ML] handle preview error
* [ML] move error callout
* [ML] better error handling
* [ML] add client-side validation
* [ML] move fake request to the executor
* [ML] revert ml client type changes, set response type manually
* [ML] documentationUrl
* [ML] add extra sentence for interim results
* [ML] use publicBaseUrl
* [ML] adjust the query
* [ML] fix anomaly explorer url
* [ML] adjust the alert params schema
* [ML] remove default severity threshold for records and influencers
* [ML] fix query with filter block
* [ML] fix functional tests
* [ML] remove isInterim check
* [ML] remove redundant fragment
* [ML] fix selected cells hook
* [ML] set query string
* [ML] support sample size by the preview endpoint
* [ML] update counter
* [ML] add check for the bucket span
* [ML] fix effects
* [ML] disable mlExplorerSwimlane
* [ML] refactor functional tests to use setSliderValue
* [ML] add assertTestIntervalValue
* [ML] floor scores
This commit is contained in:
parent
40570a633f
commit
341e9cf2eb
48 changed files with 2305 additions and 110 deletions
|
@ -105,6 +105,7 @@ export interface AlertExecutorOptions<
|
|||
export interface ActionVariable {
|
||||
name: string;
|
||||
description: string;
|
||||
useWithTripleBracesInTemplates?: boolean;
|
||||
}
|
||||
|
||||
export type ExecutorType<
|
||||
|
|
49
x-pack/plugins/ml/common/constants/alerts.ts
Normal file
49
x-pack/plugins/ml/common/constants/alerts.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ActionGroup } from '../../../alerts/common';
|
||||
import { MINIMUM_FULL_LICENSE } from '../license';
|
||||
import { PLUGIN_ID } from './app';
|
||||
|
||||
export const ML_ALERT_TYPES = {
|
||||
ANOMALY_DETECTION: 'xpack.ml.anomaly_detection_alert',
|
||||
} as const;
|
||||
|
||||
export type MlAlertType = typeof ML_ALERT_TYPES[keyof typeof ML_ALERT_TYPES];
|
||||
|
||||
export const ANOMALY_SCORE_MATCH_GROUP_ID = 'anomaly_score_match';
|
||||
export type AnomalyScoreMatchGroupId = typeof ANOMALY_SCORE_MATCH_GROUP_ID;
|
||||
export const THRESHOLD_MET_GROUP: ActionGroup<AnomalyScoreMatchGroupId> = {
|
||||
id: ANOMALY_SCORE_MATCH_GROUP_ID,
|
||||
name: i18n.translate('xpack.ml.anomalyDetectionAlert.actionGroupName', {
|
||||
defaultMessage: 'Anomaly score matched the condition',
|
||||
}),
|
||||
};
|
||||
|
||||
export const ML_ALERT_TYPES_CONFIG: Record<
|
||||
MlAlertType,
|
||||
{
|
||||
name: string;
|
||||
actionGroups: Array<ActionGroup<AnomalyScoreMatchGroupId>>;
|
||||
defaultActionGroupId: AnomalyScoreMatchGroupId;
|
||||
minimumLicenseRequired: string;
|
||||
producer: string;
|
||||
}
|
||||
> = {
|
||||
[ML_ALERT_TYPES.ANOMALY_DETECTION]: {
|
||||
name: i18n.translate('xpack.ml.anomalyDetectionAlert.name', {
|
||||
defaultMessage: 'Anomaly detection alert',
|
||||
}),
|
||||
actionGroups: [THRESHOLD_MET_GROUP],
|
||||
defaultActionGroupId: ANOMALY_SCORE_MATCH_GROUP_ID,
|
||||
minimumLicenseRequired: MINIMUM_FULL_LICENSE,
|
||||
producer: PLUGIN_ID,
|
||||
},
|
||||
};
|
||||
|
||||
export const ALERT_PREVIEW_SAMPLE_SIZE = 5;
|
|
@ -31,6 +31,12 @@ export const SEVERITY_COLORS = {
|
|||
BLANK: '#ffffff',
|
||||
};
|
||||
|
||||
export const ANOMALY_RESULT_TYPE = {
|
||||
BUCKET: 'bucket',
|
||||
RECORD: 'record',
|
||||
INFLUENCER: 'influencer',
|
||||
} as const;
|
||||
|
||||
export const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const;
|
||||
export const JOB_ID = 'job_id';
|
||||
export const PARTITION_FIELD_VALUE = 'partition_field_value';
|
||||
|
|
|
@ -13,3 +13,4 @@ export const PLUGIN_ICON_SOLUTION = 'logoKibana';
|
|||
export const ML_APP_NAME = i18n.translate('xpack.ml.navMenu.mlAppNameText', {
|
||||
defaultMessage: 'Machine Learning',
|
||||
});
|
||||
export const ML_BASE_PATH = '/api/ml';
|
||||
|
|
92
x-pack/plugins/ml/common/types/alerts.ts
Normal file
92
x-pack/plugins/ml/common/types/alerts.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { AnomalyResultType } from './anomalies';
|
||||
import { ANOMALY_RESULT_TYPE } from '../constants/anomalies';
|
||||
import { AlertTypeParams } from '../../../alerts/common';
|
||||
|
||||
export type PreviewResultsKeys = 'record_results' | 'bucket_results' | 'influencer_results';
|
||||
export type TopHitsResultsKeys = 'top_record_hits' | 'top_bucket_hits' | 'top_influencer_hits';
|
||||
|
||||
export interface AlertExecutionResult {
|
||||
count: number;
|
||||
key: number;
|
||||
key_as_string: string;
|
||||
isInterim: boolean;
|
||||
jobIds: string[];
|
||||
timestamp: number;
|
||||
timestampEpoch: number;
|
||||
timestampIso8601: string;
|
||||
score: number;
|
||||
bucketRange: { start: string; end: string };
|
||||
topRecords: RecordAnomalyAlertDoc[];
|
||||
topInfluencers?: InfluencerAnomalyAlertDoc[];
|
||||
}
|
||||
|
||||
export interface PreviewResponse {
|
||||
count: number;
|
||||
results: AlertExecutionResult[];
|
||||
}
|
||||
|
||||
interface BaseAnomalyAlertDoc {
|
||||
result_type: AnomalyResultType;
|
||||
job_id: string;
|
||||
/**
|
||||
* Rounded score
|
||||
*/
|
||||
score: number;
|
||||
timestamp: number;
|
||||
is_interim: boolean;
|
||||
unique_key: string;
|
||||
}
|
||||
|
||||
export interface RecordAnomalyAlertDoc extends BaseAnomalyAlertDoc {
|
||||
result_type: typeof ANOMALY_RESULT_TYPE.RECORD;
|
||||
function: string;
|
||||
field_name: string;
|
||||
by_field_value: string | number;
|
||||
over_field_value: string | number;
|
||||
partition_field_value: string | number;
|
||||
}
|
||||
|
||||
export interface BucketAnomalyAlertDoc extends BaseAnomalyAlertDoc {
|
||||
result_type: typeof ANOMALY_RESULT_TYPE.BUCKET;
|
||||
start: number;
|
||||
end: number;
|
||||
timestamp_epoch: number;
|
||||
timestamp_iso8601: number;
|
||||
}
|
||||
|
||||
export interface InfluencerAnomalyAlertDoc extends BaseAnomalyAlertDoc {
|
||||
result_type: typeof ANOMALY_RESULT_TYPE.INFLUENCER;
|
||||
influencer_field_name: string;
|
||||
influencer_field_value: string | number;
|
||||
influencer_score: number;
|
||||
}
|
||||
|
||||
export type AlertHitDoc = RecordAnomalyAlertDoc | BucketAnomalyAlertDoc | InfluencerAnomalyAlertDoc;
|
||||
|
||||
export function isRecordAnomalyAlertDoc(arg: any): arg is RecordAnomalyAlertDoc {
|
||||
return arg.hasOwnProperty('result_type') && arg.result_type === ANOMALY_RESULT_TYPE.RECORD;
|
||||
}
|
||||
|
||||
export function isBucketAnomalyAlertDoc(arg: any): arg is BucketAnomalyAlertDoc {
|
||||
return arg.hasOwnProperty('result_type') && arg.result_type === ANOMALY_RESULT_TYPE.BUCKET;
|
||||
}
|
||||
|
||||
export function isInfluencerAnomalyAlertDoc(arg: any): arg is InfluencerAnomalyAlertDoc {
|
||||
return arg.hasOwnProperty('result_type') && arg.result_type === ANOMALY_RESULT_TYPE.INFLUENCER;
|
||||
}
|
||||
|
||||
export type MlAnomalyDetectionAlertParams = {
|
||||
jobSelection: {
|
||||
jobIds?: string[];
|
||||
groupIds?: string[];
|
||||
};
|
||||
severity: number;
|
||||
resultType: AnomalyResultType;
|
||||
} & AlertTypeParams;
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { PARTITION_FIELDS } from '../constants/anomalies';
|
||||
import { PARTITION_FIELDS, ANOMALY_RESULT_TYPE } from '../constants/anomalies';
|
||||
|
||||
export interface Influencer {
|
||||
influencer_field_name: string;
|
||||
|
@ -77,3 +77,5 @@ export interface AnomalyCategorizerStatsDoc {
|
|||
}
|
||||
|
||||
export type EntityFieldType = 'partition_field' | 'over_field' | 'by_field';
|
||||
|
||||
export type AnomalyResultType = typeof ANOMALY_RESULT_TYPE[keyof typeof ANOMALY_RESULT_TYPE];
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import { KibanaRequest } from 'kibana/server';
|
||||
import { PLUGIN_ID } from '../constants/app';
|
||||
import { ML_SAVED_OBJECT_TYPE } from './saved_objects';
|
||||
import { ML_ALERT_TYPES } from '../constants/alerts';
|
||||
|
||||
export const apmUserMlCapabilities = {
|
||||
canGetJobs: false,
|
||||
|
@ -106,6 +107,10 @@ export function getPluginPrivileges() {
|
|||
all: savedObjects,
|
||||
read: savedObjects,
|
||||
},
|
||||
alerting: {
|
||||
all: Object.values(ML_ALERT_TYPES),
|
||||
read: [],
|
||||
},
|
||||
},
|
||||
user: {
|
||||
...privilege,
|
||||
|
@ -117,6 +122,10 @@ export function getPluginPrivileges() {
|
|||
all: [],
|
||||
read: savedObjects,
|
||||
},
|
||||
alerting: {
|
||||
all: [],
|
||||
read: Object.values(ML_ALERT_TYPES),
|
||||
},
|
||||
},
|
||||
apmUser: {
|
||||
excludeFromBasePrivileges: true,
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { ALLOWED_DATA_UNITS } from '../constants/validation';
|
||||
import { parseInterval } from './parse_interval';
|
||||
|
||||
/**
|
||||
* Provides a validator function for maximum allowed input length.
|
||||
|
@ -61,17 +62,17 @@ export function composeValidators(
|
|||
}
|
||||
|
||||
export function requiredValidator() {
|
||||
return (value: any) => {
|
||||
return <T extends string>(value: T) => {
|
||||
return value === '' || value === undefined || value === null ? { required: true } : null;
|
||||
};
|
||||
}
|
||||
|
||||
export type ValidationResult = object | null;
|
||||
export type ValidationResult = Record<string, any> | null;
|
||||
|
||||
export type MemoryInputValidatorResult = { invalidUnits: { allowedUnits: string } } | null;
|
||||
|
||||
export function memoryInputValidator(allowedUnits = ALLOWED_DATA_UNITS) {
|
||||
return (value: any) => {
|
||||
return <T>(value: T) => {
|
||||
if (typeof value !== 'string' || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
@ -81,3 +82,16 @@ export function memoryInputValidator(allowedUnits = ALLOWED_DATA_UNITS) {
|
|||
: { invalidUnits: { allowedUnits: allowedUnits.join(', ') } };
|
||||
};
|
||||
}
|
||||
|
||||
export function timeIntervalInputValidator() {
|
||||
return (value: string) => {
|
||||
const r = parseInterval(value);
|
||||
if (r === null) {
|
||||
return {
|
||||
invalidTimeInterval: true,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -17,9 +17,11 @@
|
|||
"uiActions",
|
||||
"kibanaLegacy",
|
||||
"indexPatternManagement",
|
||||
"discover"
|
||||
"discover",
|
||||
"triggersActionsUi"
|
||||
],
|
||||
"optionalPlugins": [
|
||||
"alerts",
|
||||
"home",
|
||||
"security",
|
||||
"spaces",
|
||||
|
|
124
x-pack/plugins/ml/public/alerting/job_selector.tsx
Normal file
124
x-pack/plugins/ml/public/alerting/job_selector.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption, EuiComboBoxProps, EuiFormRow } from '@elastic/eui';
|
||||
import { JobId } from '../../common/types/anomaly_detection_jobs';
|
||||
import { MlApiServices } from '../application/services/ml_api_service';
|
||||
|
||||
interface JobSelection {
|
||||
jobIds?: JobId[];
|
||||
groupIds?: string[];
|
||||
}
|
||||
|
||||
export interface JobSelectorControlProps {
|
||||
jobSelection?: JobSelection;
|
||||
onSelectionChange: (jobSelection: JobSelection) => void;
|
||||
adJobsApiService: MlApiServices['jobs'];
|
||||
/**
|
||||
* Validation is handled by alerting framework
|
||||
*/
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const JobSelectorControl: FC<JobSelectorControlProps> = ({
|
||||
jobSelection,
|
||||
onSelectionChange,
|
||||
adJobsApiService,
|
||||
errors,
|
||||
}) => {
|
||||
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
|
||||
const jobIds = useMemo(() => new Set(), []);
|
||||
const groupIds = useMemo(() => new Set(), []);
|
||||
|
||||
const fetchOptions = useCallback(async () => {
|
||||
try {
|
||||
const {
|
||||
jobIds: jobIdOptions,
|
||||
groupIds: groupIdOptions,
|
||||
} = await adJobsApiService.getAllJobAndGroupIds();
|
||||
|
||||
jobIdOptions.forEach((v) => {
|
||||
jobIds.add(v);
|
||||
});
|
||||
groupIdOptions.forEach((v) => {
|
||||
groupIds.add(v);
|
||||
});
|
||||
|
||||
setOptions([
|
||||
{
|
||||
label: i18n.translate('xpack.ml.jobSelector.jobOptionsLabel', {
|
||||
defaultMessage: 'Jobs',
|
||||
}),
|
||||
options: jobIdOptions.map((v) => ({ label: v })),
|
||||
},
|
||||
{
|
||||
label: i18n.translate('xpack.ml.jobSelector.groupOptionsLabel', {
|
||||
defaultMessage: 'Groups',
|
||||
}),
|
||||
options: groupIdOptions.map((v) => ({ label: v })),
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
// TODO add error handling
|
||||
}
|
||||
}, [adJobsApiService]);
|
||||
|
||||
const onChange: EuiComboBoxProps<string>['onChange'] = useCallback(
|
||||
(selectedOptions) => {
|
||||
const selectedJobIds: JobId[] = [];
|
||||
const selectedGroupIds: string[] = [];
|
||||
selectedOptions.forEach(({ label }: { label: string }) => {
|
||||
if (jobIds.has(label)) {
|
||||
selectedJobIds.push(label);
|
||||
} else if (groupIds.has(label)) {
|
||||
selectedGroupIds.push(label);
|
||||
}
|
||||
});
|
||||
onSelectionChange({
|
||||
...(selectedJobIds.length > 0 ? { jobIds: selectedJobIds } : {}),
|
||||
...(selectedGroupIds.length > 0 ? { groupIds: selectedGroupIds } : {}),
|
||||
});
|
||||
},
|
||||
[jobIds, groupIds]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, []);
|
||||
|
||||
const selectedOptions = Object.values(jobSelection ?? {})
|
||||
.flat()
|
||||
.map((v) => ({
|
||||
label: v,
|
||||
}));
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.jobSelector.formControlLabel"
|
||||
defaultMessage="Select jobs or groups"
|
||||
/>
|
||||
}
|
||||
isInvalid={!!errors?.length}
|
||||
error={errors}
|
||||
>
|
||||
<EuiComboBox<string>
|
||||
selectedOptions={selectedOptions}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
data-test-subj={'mlAnomalyAlertJobSelection'}
|
||||
isInvalid={!!errors?.length}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { FC, useCallback, useEffect, useMemo } from 'react';
|
||||
import { EuiSpacer, EuiForm } from '@elastic/eui';
|
||||
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 { SeverityControl } from './severity_control';
|
||||
import { ResultTypeSelector } from './result_type_selector';
|
||||
import { alertingApiProvider } from '../application/services/ml_api_service/alerting';
|
||||
import { PreviewAlertCondition } from './preview_alert_condition';
|
||||
import { ANOMALY_THRESHOLD } from '../../common';
|
||||
import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts';
|
||||
import { ANOMALY_RESULT_TYPE } from '../../common/constants/anomalies';
|
||||
|
||||
interface MlAnomalyAlertTriggerProps {
|
||||
alertParams: MlAnomalyDetectionAlertParams;
|
||||
setAlertParams: <T extends keyof MlAnomalyDetectionAlertParams>(
|
||||
key: T,
|
||||
value: MlAnomalyDetectionAlertParams[T]
|
||||
) => void;
|
||||
errors: Record<keyof MlAnomalyDetectionAlertParams, string[]>;
|
||||
}
|
||||
|
||||
const MlAnomalyAlertTrigger: FC<MlAnomalyAlertTriggerProps> = ({
|
||||
alertParams,
|
||||
setAlertParams,
|
||||
errors,
|
||||
}) => {
|
||||
const {
|
||||
services: { http },
|
||||
} = useMlKibana();
|
||||
const mlHttpService = useMemo(() => new HttpService(http), [http]);
|
||||
const adJobsApiService = useMemo(() => jobsApiProvider(mlHttpService), [mlHttpService]);
|
||||
const alertingApiService = useMemo(() => alertingApiProvider(mlHttpService), [mlHttpService]);
|
||||
|
||||
const onAlertParamChange = useCallback(
|
||||
<T extends keyof MlAnomalyDetectionAlertParams>(param: T) => (
|
||||
update: MlAnomalyDetectionAlertParams[T]
|
||||
) => {
|
||||
setAlertParams(param, update);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(function setDefaults() {
|
||||
if (alertParams.severity === undefined) {
|
||||
onAlertParamChange('severity')(ANOMALY_THRESHOLD.CRITICAL);
|
||||
}
|
||||
if (alertParams.resultType === undefined) {
|
||||
onAlertParamChange('resultType')(ANOMALY_RESULT_TYPE.BUCKET);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiForm data-test-subj={'mlAnomalyAlertForm'}>
|
||||
<JobSelectorControl
|
||||
jobSelection={alertParams.jobSelection}
|
||||
adJobsApiService={adJobsApiService}
|
||||
onSelectionChange={useCallback(onAlertParamChange('jobSelection'), [])}
|
||||
errors={errors.jobSelection}
|
||||
/>
|
||||
<ResultTypeSelector
|
||||
value={alertParams.resultType}
|
||||
onChange={useCallback(onAlertParamChange('resultType'), [])}
|
||||
/>
|
||||
<SeverityControl
|
||||
value={alertParams.severity}
|
||||
onChange={useCallback(onAlertParamChange('severity'), [])}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
<PreviewAlertCondition alertingApiService={alertingApiService} alertParams={alertParams} />
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
||||
|
||||
// Default export is required for React.lazy loading
|
||||
//
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default MlAnomalyAlertTrigger;
|
294
x-pack/plugins/ml/public/alerting/preview_alert_condition.tsx
Normal file
294
x-pack/plugins/ml/public/alerting/preview_alert_condition.tsx
Normal file
|
@ -0,0 +1,294 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiCallOut,
|
||||
EuiCode,
|
||||
EuiDescriptionList,
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiHorizontalRule,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import type { AlertingApiService } from '../application/services/ml_api_service/alerting';
|
||||
import { MlAnomalyDetectionAlertParams, PreviewResponse } from '../../common/types/alerts';
|
||||
import { composeValidators } from '../../common';
|
||||
import { requiredValidator, timeIntervalInputValidator } from '../../common/util/validators';
|
||||
import { invalidTimeIntervalMessage } from '../application/jobs/new_job/common/job_validator/util';
|
||||
import { ALERT_PREVIEW_SAMPLE_SIZE } from '../../common/constants/alerts';
|
||||
|
||||
export interface PreviewAlertConditionProps {
|
||||
alertingApiService: AlertingApiService;
|
||||
alertParams: MlAnomalyDetectionAlertParams;
|
||||
}
|
||||
|
||||
const AlertInstancePreview: FC<PreviewResponse['results'][number]> = React.memo(
|
||||
({ jobIds, timestampIso8601, score, topInfluencers, topRecords }) => {
|
||||
const listItems = [
|
||||
{
|
||||
title: i18n.translate('xpack.ml.previewAlert.jobsLabel', {
|
||||
defaultMessage: 'Job IDs:',
|
||||
}),
|
||||
description: jobIds.join(', '),
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.ml.previewAlert.timeLabel', {
|
||||
defaultMessage: 'Time: ',
|
||||
}),
|
||||
description: timestampIso8601,
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.ml.previewAlert.scoreLabel', {
|
||||
defaultMessage: 'Anomaly score:',
|
||||
}),
|
||||
description: score,
|
||||
},
|
||||
...(topInfluencers && topInfluencers.length > 0
|
||||
? [
|
||||
{
|
||||
title: i18n.translate('xpack.ml.previewAlert.topInfluencersLabel', {
|
||||
defaultMessage: 'Top influencers:',
|
||||
}),
|
||||
description: (
|
||||
<ul>
|
||||
{topInfluencers.map((i) => (
|
||||
<li key={i.unique_key}>
|
||||
<EuiCode transparentBackground>{i.influencer_field_name}</EuiCode> ={' '}
|
||||
{i.influencer_field_value} [{i.score}]
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(topRecords && topRecords.length > 0
|
||||
? [
|
||||
{
|
||||
title: i18n.translate('xpack.ml.previewAlert.topRecordsLabel', {
|
||||
defaultMessage: 'Top records:',
|
||||
}),
|
||||
description: (
|
||||
<ul>
|
||||
{topRecords.map((i) => (
|
||||
<li key={i.unique_key}>
|
||||
<EuiCode transparentBackground>
|
||||
{i.function}({i.field_name})
|
||||
</EuiCode>{' '}
|
||||
{i.by_field_value} {i.over_field_value} {i.partition_field_value} [{i.score}]
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return <EuiDescriptionList type={'row'} compressed={true} listItems={listItems} />;
|
||||
}
|
||||
);
|
||||
|
||||
export const PreviewAlertCondition: FC<PreviewAlertConditionProps> = ({
|
||||
alertingApiService,
|
||||
alertParams,
|
||||
}) => {
|
||||
const sampleSize = ALERT_PREVIEW_SAMPLE_SIZE;
|
||||
|
||||
const [lookBehindInterval, setLookBehindInterval] = useState<string>();
|
||||
const [areResultsVisible, setAreResultVisible] = useState<boolean>(true);
|
||||
const [previewError, setPreviewError] = useState<Error | undefined>();
|
||||
const [previewResponse, setPreviewResponse] = useState<PreviewResponse | undefined>();
|
||||
|
||||
const validators = useMemo(
|
||||
() => composeValidators(requiredValidator(), timeIntervalInputValidator()),
|
||||
[]
|
||||
);
|
||||
|
||||
const validationErrors = useMemo(() => validators(lookBehindInterval), [lookBehindInterval]);
|
||||
|
||||
useEffect(
|
||||
function resetPreview() {
|
||||
setPreviewResponse(undefined);
|
||||
},
|
||||
[alertParams]
|
||||
);
|
||||
|
||||
const testCondition = useCallback(async () => {
|
||||
try {
|
||||
const response = await alertingApiService.preview({
|
||||
alertParams,
|
||||
timeRange: lookBehindInterval!,
|
||||
sampleSize,
|
||||
});
|
||||
setPreviewResponse(response);
|
||||
setPreviewError(undefined);
|
||||
} catch (e) {
|
||||
setPreviewResponse(undefined);
|
||||
setPreviewError(e.body ?? e);
|
||||
}
|
||||
}, [alertParams, lookBehindInterval]);
|
||||
|
||||
const sampleHits = useMemo(() => {
|
||||
if (!previewResponse) return;
|
||||
|
||||
return previewResponse.results;
|
||||
}, [previewResponse]);
|
||||
|
||||
const isReady =
|
||||
(alertParams.jobSelection?.jobIds?.length! > 0 ||
|
||||
alertParams.jobSelection?.groupIds?.length! > 0) &&
|
||||
!!alertParams.resultType &&
|
||||
!!alertParams.severity &&
|
||||
validationErrors === null;
|
||||
|
||||
const isInvalid = lookBehindInterval !== undefined && !!validationErrors;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup gutterSize="s" alignItems={'flexEnd'}>
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.previewAlert.intervalLabel"
|
||||
defaultMessage="Check the alert condition with an interval"
|
||||
/>
|
||||
}
|
||||
isInvalid={isInvalid}
|
||||
error={invalidTimeIntervalMessage(lookBehindInterval)}
|
||||
>
|
||||
<EuiFieldText
|
||||
placeholder="15d, 6m"
|
||||
value={lookBehindInterval}
|
||||
onChange={(e) => {
|
||||
setLookBehindInterval(e.target.value);
|
||||
}}
|
||||
isInvalid={isInvalid}
|
||||
data-test-subj={'mlAnomalyAlertPreviewInterval'}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={testCondition}
|
||||
disabled={!isReady}
|
||||
data-test-subj={'mlAnomalyAlertPreviewButton'}
|
||||
>
|
||||
<FormattedMessage id="xpack.ml.previewAlert.testButtonLabel" defaultMessage="Test" />
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
{previewError !== undefined && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiCallOut
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.previewAlert.previewErrorTitle"
|
||||
defaultMessage="Unable to load the preview"
|
||||
/>
|
||||
}
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
>
|
||||
<p>{previewError.message}</p>
|
||||
</EuiCallOut>
|
||||
</>
|
||||
)}
|
||||
|
||||
{previewResponse && sampleHits && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFlexGroup gutterSize={'xs'} alignItems={'center'}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size={'xs'} data-test-subj={'mlAnomalyAlertPreviewMessage'}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.previewAlert.previewMessage"
|
||||
defaultMessage="Triggers {alertsCount, plural, one {# time} other {# times}} in the last {interval}"
|
||||
values={{
|
||||
alertsCount: previewResponse.count,
|
||||
interval: lookBehindInterval,
|
||||
}}
|
||||
/>
|
||||
</strong>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
{sampleHits.length > 0 && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
color={'primary'}
|
||||
size="xs"
|
||||
onClick={setAreResultVisible.bind(null, !areResultsVisible)}
|
||||
>
|
||||
{areResultsVisible ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.previewAlert.hideResultsButtonLabel"
|
||||
defaultMessage="Hide results"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.previewAlert.showResultsButtonLabel"
|
||||
defaultMessage="Show results"
|
||||
/>
|
||||
)}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
|
||||
{areResultsVisible && sampleHits.length > 0 ? (
|
||||
<EuiPanel
|
||||
color="subdued"
|
||||
borderRadius="none"
|
||||
hasShadow={false}
|
||||
data-test-subj={'mlAnomalyAlertPreviewCallout'}
|
||||
>
|
||||
<ul>
|
||||
{sampleHits.map((v, i) => {
|
||||
return (
|
||||
<li key={v.key}>
|
||||
<AlertInstancePreview {...v} />
|
||||
{i !== sampleHits.length - 1 ? <EuiHorizontalRule margin="xs" /> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{previewResponse.count > sampleSize ? (
|
||||
<>
|
||||
<EuiSpacer size={'m'} />
|
||||
<EuiText size={'xs'}>
|
||||
<b>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.previewAlert.otherValuesLabel"
|
||||
defaultMessage="and {count, plural, one {# other} other {# others}}"
|
||||
values={{
|
||||
count: previewResponse.count - sampleSize,
|
||||
}}
|
||||
/>
|
||||
</b>
|
||||
</EuiText>
|
||||
</>
|
||||
) : null}
|
||||
</EuiPanel>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
93
x-pack/plugins/ml/public/alerting/register_ml_alerts.ts
Normal file
93
x-pack/plugins/ml/public/alerting/register_ml_alerts.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { lazy } from 'react';
|
||||
import { MlStartDependencies } from '../plugin';
|
||||
import { ML_ALERT_TYPES } from '../../common/constants/alerts';
|
||||
import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts';
|
||||
|
||||
export function registerMlAlerts(
|
||||
alertTypeRegistry: MlStartDependencies['triggersActionsUi']['alertTypeRegistry']
|
||||
) {
|
||||
alertTypeRegistry.register({
|
||||
id: ML_ALERT_TYPES.ANOMALY_DETECTION,
|
||||
description: i18n.translate('xpack.ml.alertTypes.anomalyDetection.description', {
|
||||
defaultMessage: 'Alert when anomaly detection jobs results match the condition.',
|
||||
}),
|
||||
iconClass: 'bell',
|
||||
documentationUrl(docLinks) {
|
||||
return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/machine-learning/${docLinks.DOC_LINK_VERSION}/ml-configuring-alerts.html`;
|
||||
},
|
||||
alertParamsExpression: lazy(() => import('./ml_anomaly_alert_trigger')),
|
||||
validate: (alertParams: MlAnomalyDetectionAlertParams) => {
|
||||
const validationResult = {
|
||||
errors: {
|
||||
jobSelection: new Array<string>(),
|
||||
severity: new Array<string>(),
|
||||
resultType: new Array<string>(),
|
||||
},
|
||||
};
|
||||
|
||||
if (
|
||||
!alertParams.jobSelection?.jobIds?.length &&
|
||||
!alertParams.jobSelection?.groupIds?.length
|
||||
) {
|
||||
validationResult.errors.jobSelection.push(
|
||||
i18n.translate('xpack.ml.alertTypes.anomalyDetection.jobSelection.errorMessage', {
|
||||
defaultMessage: 'Job selection is required',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (alertParams.severity === undefined) {
|
||||
validationResult.errors.severity.push(
|
||||
i18n.translate('xpack.ml.alertTypes.anomalyDetection.severity.errorMessage', {
|
||||
defaultMessage: 'Anomaly severity is required',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (alertParams.resultType === undefined) {
|
||||
validationResult.errors.resultType.push(
|
||||
i18n.translate('xpack.ml.alertTypes.anomalyDetection.resultType.errorMessage', {
|
||||
defaultMessage: 'Result type is required',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
},
|
||||
requiresAppContext: false,
|
||||
defaultActionMessage: i18n.translate(
|
||||
'xpack.ml.alertTypes.anomalyDetection.defaultActionMessage',
|
||||
{
|
||||
defaultMessage: `Elastic Stack Machine Learning Alert:
|
||||
- Job IDs: \\{\\{#context.jobIds\\}\\}\\{\\{context.jobIds\\}\\} - \\{\\{/context.jobIds\\}\\}
|
||||
- Time: \\{\\{context.timestampIso8601\\}\\}
|
||||
- Anomaly score: \\{\\{context.score\\}\\}
|
||||
|
||||
Alerts are raised based on real-time scores. Remember that scores may be adjusted over time as data continues to be analyzed.
|
||||
|
||||
\\{\\{! Section might be not relevant if selected jobs don't contain influencer configuration \\}\\}
|
||||
Top influencers:
|
||||
\\{\\{#context.topInfluencers\\}\\}
|
||||
\\{\\{influencer_field_name\\}\\} = \\{\\{influencer_field_value\\}\\} [\\{\\{score\\}\\}]
|
||||
\\{\\{/context.topInfluencers\\}\\}
|
||||
|
||||
Top records:
|
||||
\\{\\{#context.topRecords\\}\\}
|
||||
\\{\\{function\\}\\}(\\{\\{field_name\\}\\}) \\{\\{by_field_value\\}\\} \\{\\{over_field_value\\}\\} \\{\\{partition_field_value\\}\\} [\\{\\{score\\}\\}]
|
||||
\\{\\{/context.topRecords\\}\\}
|
||||
|
||||
\\{\\{! Replace kibanaBaseUrl if not configured in Kibana \\}\\}
|
||||
[Open in Anomaly Explorer](\\{\\{\\{context.kibanaBaseUrl\\}\\}\\}\\{\\{\\{context.anomalyExplorerUrl\\}\\}\\})
|
||||
`,
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
97
x-pack/plugins/ml/public/alerting/result_type_selector.tsx
Normal file
97
x-pack/plugins/ml/public/alerting/result_type_selector.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import React, { FC } from 'react';
|
||||
import { ANOMALY_RESULT_TYPE } from '../../common/constants/anomalies';
|
||||
import { AnomalyResultType } from '../../common/types/anomalies';
|
||||
|
||||
export interface ResultTypeSelectorProps {
|
||||
value: AnomalyResultType | undefined;
|
||||
onChange: (value: AnomalyResultType) => void;
|
||||
}
|
||||
|
||||
export const ResultTypeSelector: FC<ResultTypeSelectorProps> = ({
|
||||
value: selectedResultType = [],
|
||||
onChange,
|
||||
}) => {
|
||||
const resultTypeOptions = [
|
||||
{
|
||||
value: ANOMALY_RESULT_TYPE.BUCKET,
|
||||
title: <FormattedMessage id="xpack.ml.bucketResultType.title" defaultMessage="Bucket" />,
|
||||
description: (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.bucketResultType.description"
|
||||
defaultMessage="How unusual was the job within the bucket of time?"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: ANOMALY_RESULT_TYPE.RECORD,
|
||||
title: <FormattedMessage id="xpack.ml.recordResultType.title" defaultMessage="Record" />,
|
||||
description: (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.recordResultType.description"
|
||||
defaultMessage="What individual anomalies are present in a time range?"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: ANOMALY_RESULT_TYPE.INFLUENCER,
|
||||
title: (
|
||||
<FormattedMessage id="xpack.ml.influencerResultType.title" defaultMessage="Influencer" />
|
||||
),
|
||||
description: (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.influencerResultType.description"
|
||||
defaultMessage="What are the most unusual entities in a time range?"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.resultTypeSelector.formControlLabel"
|
||||
defaultMessage="Result type"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
{resultTypeOptions.map(({ value, title, description }) => {
|
||||
return (
|
||||
<EuiFlexItem key={value}>
|
||||
<EuiCard
|
||||
title={title}
|
||||
titleSize={'xs'}
|
||||
paddingSize={'s'}
|
||||
description={<small>{description}</small>}
|
||||
selectable={{
|
||||
onClick: () => {
|
||||
if (selectedResultType === value) {
|
||||
// don't allow de-select
|
||||
return;
|
||||
}
|
||||
onChange(value);
|
||||
},
|
||||
isSelected: value === selectedResultType,
|
||||
}}
|
||||
data-test-subj={`mlAnomalyAlertResult_${value}${
|
||||
value === selectedResultType ? '_selected' : ''
|
||||
}`}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
})}
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
|
@ -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 { SeverityControl } from './severity_control';
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiFormRow, EuiRange, EuiRangeProps } from '@elastic/eui';
|
||||
import { SEVERITY_OPTIONS } from '../../application/components/controls/select_severity/select_severity';
|
||||
import { ANOMALY_THRESHOLD } from '../../../common';
|
||||
import './styles.scss';
|
||||
|
||||
export interface SeveritySelectorProps {
|
||||
value: number | undefined;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const MAX_ANOMALY_SCORE = 100;
|
||||
|
||||
export const SeverityControl: FC<SeveritySelectorProps> = React.memo(({ value, onChange }) => {
|
||||
const levels: EuiRangeProps['levels'] = [
|
||||
{
|
||||
min: ANOMALY_THRESHOLD.LOW,
|
||||
max: ANOMALY_THRESHOLD.MINOR - 1,
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
min: ANOMALY_THRESHOLD.MINOR,
|
||||
max: ANOMALY_THRESHOLD.MAJOR - 1,
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
min: ANOMALY_THRESHOLD.MAJOR,
|
||||
max: ANOMALY_THRESHOLD.CRITICAL,
|
||||
color: 'warning',
|
||||
},
|
||||
{
|
||||
min: ANOMALY_THRESHOLD.CRITICAL,
|
||||
max: MAX_ANOMALY_SCORE,
|
||||
color: 'danger',
|
||||
},
|
||||
];
|
||||
|
||||
const toggleButtons = SEVERITY_OPTIONS.map((v) => ({
|
||||
value: v.val,
|
||||
label: v.display,
|
||||
}));
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.severitySelector.formControlLabel"
|
||||
defaultMessage="Select severity threshold"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiRange
|
||||
className={'mlSeverityControl'}
|
||||
fullWidth
|
||||
min={ANOMALY_THRESHOLD.LOW}
|
||||
max={MAX_ANOMALY_SCORE}
|
||||
value={value ?? ANOMALY_THRESHOLD.LOW}
|
||||
onChange={(e) => {
|
||||
// @ts-ignore Property 'value' does not exist on type 'EventTarget' | (EventTarget & HTMLInputElement)
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
showLabels
|
||||
showValue
|
||||
aria-label={i18n.translate('xpack.ml.severitySelector.formControlLabel', {
|
||||
defaultMessage: 'Select severity threshold',
|
||||
})}
|
||||
showTicks
|
||||
ticks={toggleButtons}
|
||||
levels={levels}
|
||||
data-test-subj={'mlAnomalyAlertScoreSelection'}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,18 @@
|
|||
// Color overrides are required (https://github.com/elastic/eui/issues/4467)
|
||||
|
||||
.mlSeverityControl {
|
||||
.euiRangeLevel-- {
|
||||
&success {
|
||||
background-color: #8BC8FB;
|
||||
}
|
||||
&primary {
|
||||
background-color: #FDEC25;
|
||||
}
|
||||
&warning {
|
||||
background-color: #FBA740;
|
||||
}
|
||||
&danger {
|
||||
background-color: #FE5050;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@ import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui';
|
|||
|
||||
import { getSeverityColor } from '../../../../../common/util/anomaly_utils';
|
||||
import { usePageUrlState } from '../../../util/url_state';
|
||||
import { ANOMALY_THRESHOLD } from '../../../../../common';
|
||||
|
||||
const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', {
|
||||
defaultMessage: 'warning',
|
||||
|
@ -31,10 +32,10 @@ const criticalLabel = i18n.translate('xpack.ml.controls.selectSeverity.criticalL
|
|||
});
|
||||
|
||||
const optionsMap = {
|
||||
[warningLabel]: 0,
|
||||
[minorLabel]: 25,
|
||||
[majorLabel]: 50,
|
||||
[criticalLabel]: 75,
|
||||
[warningLabel]: ANOMALY_THRESHOLD.LOW,
|
||||
[minorLabel]: ANOMALY_THRESHOLD.MINOR,
|
||||
[majorLabel]: ANOMALY_THRESHOLD.MAJOR,
|
||||
[criticalLabel]: ANOMALY_THRESHOLD.CRITICAL,
|
||||
};
|
||||
|
||||
interface TableSeverity {
|
||||
|
@ -45,24 +46,24 @@ interface TableSeverity {
|
|||
|
||||
export const SEVERITY_OPTIONS: TableSeverity[] = [
|
||||
{
|
||||
val: 0,
|
||||
val: ANOMALY_THRESHOLD.LOW,
|
||||
display: warningLabel,
|
||||
color: getSeverityColor(0),
|
||||
color: getSeverityColor(ANOMALY_THRESHOLD.LOW),
|
||||
},
|
||||
{
|
||||
val: 25,
|
||||
val: ANOMALY_THRESHOLD.MINOR,
|
||||
display: minorLabel,
|
||||
color: getSeverityColor(25),
|
||||
color: getSeverityColor(ANOMALY_THRESHOLD.MINOR),
|
||||
},
|
||||
{
|
||||
val: 50,
|
||||
val: ANOMALY_THRESHOLD.MAJOR,
|
||||
display: majorLabel,
|
||||
color: getSeverityColor(50),
|
||||
color: getSeverityColor(ANOMALY_THRESHOLD.MAJOR),
|
||||
},
|
||||
{
|
||||
val: 75,
|
||||
val: ANOMALY_THRESHOLD.CRITICAL,
|
||||
display: criticalLabel,
|
||||
color: getSeverityColor(75),
|
||||
color: getSeverityColor(ANOMALY_THRESHOLD.CRITICAL),
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -84,7 +85,7 @@ export const useTableSeverity = (): [TableSeverity, (v: TableSeverity) => void]
|
|||
return usePageUrlState('mlSelectSeverity', TABLE_SEVERITY_DEFAULT);
|
||||
};
|
||||
|
||||
const getSeverityOptions = () =>
|
||||
export const getSeverityOptions = () =>
|
||||
SEVERITY_OPTIONS.map(({ color, display, val }) => ({
|
||||
value: display,
|
||||
inputDisplay: (
|
||||
|
|
|
@ -27,8 +27,8 @@ export const useSelectedCells = (
|
|||
|
||||
let times =
|
||||
appState.mlExplorerSwimlane.selectedTimes ?? appState.mlExplorerSwimlane.selectedTime!;
|
||||
if (typeof times === 'number' && bucketIntervalInSeconds) {
|
||||
times = [times, times + bucketIntervalInSeconds];
|
||||
if (typeof times === 'number') {
|
||||
times = [times, times + bucketIntervalInSeconds!];
|
||||
}
|
||||
|
||||
let lanes =
|
||||
|
|
|
@ -203,7 +203,7 @@ export function populateValidationMessages(
|
|||
}
|
||||
}
|
||||
|
||||
function invalidTimeIntervalMessage(value: string | undefined) {
|
||||
export function invalidTimeIntervalMessage(value: string | undefined) {
|
||||
return i18n.translate(
|
||||
'xpack.ml.newJob.wizard.validateJob.frequencyInvalidTimeIntervalFormatErrorMessage',
|
||||
{
|
||||
|
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { HttpService } from '../http_service';
|
||||
import { ML_BASE_PATH } from '../../../../common/constants/app';
|
||||
import { MlAnomalyDetectionAlertParams, PreviewResponse } from '../../../../common/types/alerts';
|
||||
|
||||
export type AlertingApiService = ReturnType<typeof alertingApiProvider>;
|
||||
|
||||
export const alertingApiProvider = (httpService: HttpService) => {
|
||||
return {
|
||||
preview(params: {
|
||||
alertParams: MlAnomalyDetectionAlertParams;
|
||||
timeRange: string;
|
||||
sampleSize?: number;
|
||||
}): Promise<PreviewResponse> {
|
||||
const body = JSON.stringify(params);
|
||||
return httpService.http<PreviewResponse>({
|
||||
path: `${ML_BASE_PATH}/alerting/preview`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
|
@ -8,32 +8,32 @@
|
|||
import { Observable } from 'rxjs';
|
||||
import { HttpService } from '../http_service';
|
||||
|
||||
import { basePath } from './index';
|
||||
import { Dictionary } from '../../../../common/types/common';
|
||||
import {
|
||||
import type { Dictionary } from '../../../../common/types/common';
|
||||
import type {
|
||||
MlJobWithTimeRange,
|
||||
MlSummaryJobs,
|
||||
CombinedJobWithStats,
|
||||
Job,
|
||||
Datafeed,
|
||||
} from '../../../../common/types/anomaly_detection_jobs';
|
||||
import { JobMessage } from '../../../../common/types/audit_message';
|
||||
import { AggFieldNamePair } from '../../../../common/types/fields';
|
||||
import { ExistingJobsAndGroups } from '../job_service';
|
||||
import {
|
||||
import type { JobMessage } from '../../../../common/types/audit_message';
|
||||
import type { AggFieldNamePair } from '../../../../common/types/fields';
|
||||
import type { ExistingJobsAndGroups } from '../job_service';
|
||||
import type {
|
||||
CategorizationAnalyzer,
|
||||
CategoryFieldExample,
|
||||
FieldExampleCheck,
|
||||
} from '../../../../common/types/categories';
|
||||
import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/categorization_job';
|
||||
import { Category } from '../../../../common/types/categories';
|
||||
import { JobsExistResponse } from '../../../../common/types/job_service';
|
||||
import type { Category } from '../../../../common/types/categories';
|
||||
import type { JobsExistResponse } from '../../../../common/types/job_service';
|
||||
import { ML_BASE_PATH } from '../../../../common/constants/app';
|
||||
|
||||
export const jobsApiProvider = (httpService: HttpService) => ({
|
||||
jobsSummary(jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobIds });
|
||||
return httpService.http<MlSummaryJobs>({
|
||||
path: `${basePath()}/jobs/jobs_summary`,
|
||||
path: `${ML_BASE_PATH}/jobs/jobs_summary`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -45,7 +45,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
jobs: MlJobWithTimeRange[];
|
||||
jobsMap: Dictionary<MlJobWithTimeRange>;
|
||||
}>({
|
||||
path: `${basePath()}/jobs/jobs_with_time_range`,
|
||||
path: `${ML_BASE_PATH}/jobs/jobs_with_time_range`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -54,7 +54,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
jobForCloning(jobId: string) {
|
||||
const body = JSON.stringify({ jobId });
|
||||
return httpService.http<{ job?: Job; datafeed?: Datafeed } | undefined>({
|
||||
path: `${basePath()}/jobs/job_for_cloning`,
|
||||
path: `${ML_BASE_PATH}/jobs/job_for_cloning`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -63,7 +63,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
jobs(jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobIds });
|
||||
return httpService.http<CombinedJobWithStats[]>({
|
||||
path: `${basePath()}/jobs/jobs`,
|
||||
path: `${ML_BASE_PATH}/jobs/jobs`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -71,7 +71,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
|
||||
groups() {
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/groups`,
|
||||
path: `${ML_BASE_PATH}/jobs/groups`,
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
|
@ -79,7 +79,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
updateGroups(updatedJobs: string[]) {
|
||||
const body = JSON.stringify({ jobs: updatedJobs });
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/update_groups`,
|
||||
path: `${ML_BASE_PATH}/jobs/update_groups`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -93,7 +93,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
});
|
||||
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/force_start_datafeeds`,
|
||||
path: `${ML_BASE_PATH}/jobs/force_start_datafeeds`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -102,7 +102,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
stopDatafeeds(datafeedIds: string[]) {
|
||||
const body = JSON.stringify({ datafeedIds });
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/stop_datafeeds`,
|
||||
path: `${ML_BASE_PATH}/jobs/stop_datafeeds`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -111,7 +111,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
deleteJobs(jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobIds });
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/delete_jobs`,
|
||||
path: `${ML_BASE_PATH}/jobs/delete_jobs`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -120,7 +120,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
closeJobs(jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobIds });
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/close_jobs`,
|
||||
path: `${ML_BASE_PATH}/jobs/close_jobs`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -129,7 +129,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
forceStopAndCloseJob(jobId: string) {
|
||||
const body = JSON.stringify({ jobId });
|
||||
return httpService.http<{ success: boolean }>({
|
||||
path: `${basePath()}/jobs/force_stop_and_close_job`,
|
||||
path: `${ML_BASE_PATH}/jobs/force_stop_and_close_job`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -139,7 +139,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
const jobIdString = jobId !== undefined ? `/${jobId}` : '';
|
||||
const query = from !== undefined ? { from } : {};
|
||||
return httpService.http<JobMessage[]>({
|
||||
path: `${basePath()}/job_audit_messages/messages${jobIdString}`,
|
||||
path: `${ML_BASE_PATH}/job_audit_messages/messages${jobIdString}`,
|
||||
method: 'GET',
|
||||
query,
|
||||
});
|
||||
|
@ -147,7 +147,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
|
||||
deletingJobTasks() {
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/deleting_jobs_tasks`,
|
||||
path: `${ML_BASE_PATH}/jobs/deleting_jobs_tasks`,
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
|
@ -155,7 +155,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
jobsExist(jobIds: string[], allSpaces: boolean = false) {
|
||||
const body = JSON.stringify({ jobIds, allSpaces });
|
||||
return httpService.http<JobsExistResponse>({
|
||||
path: `${basePath()}/jobs/jobs_exist`,
|
||||
path: `${ML_BASE_PATH}/jobs/jobs_exist`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -164,7 +164,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
jobsExist$(jobIds: string[], allSpaces: boolean = false): Observable<JobsExistResponse> {
|
||||
const body = JSON.stringify({ jobIds, allSpaces });
|
||||
return httpService.http$({
|
||||
path: `${basePath()}/jobs/jobs_exist`,
|
||||
path: `${ML_BASE_PATH}/jobs/jobs_exist`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -173,7 +173,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
newJobCaps(indexPatternTitle: string, isRollup: boolean = false) {
|
||||
const query = isRollup === true ? { rollup: true } : {};
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/new_job_caps/${indexPatternTitle}`,
|
||||
path: `${ML_BASE_PATH}/jobs/new_job_caps/${indexPatternTitle}`,
|
||||
method: 'GET',
|
||||
query,
|
||||
});
|
||||
|
@ -202,7 +202,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
splitFieldValue,
|
||||
});
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/new_job_line_chart`,
|
||||
path: `${ML_BASE_PATH}/jobs/new_job_line_chart`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -229,7 +229,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
splitFieldName,
|
||||
});
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/new_job_population_chart`,
|
||||
path: `${ML_BASE_PATH}/jobs/new_job_population_chart`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -237,7 +237,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
|
||||
getAllJobAndGroupIds() {
|
||||
return httpService.http<ExistingJobsAndGroups>({
|
||||
path: `${basePath()}/jobs/all_jobs_and_group_ids`,
|
||||
path: `${ML_BASE_PATH}/jobs/all_jobs_and_group_ids`,
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
|
@ -249,7 +249,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
end,
|
||||
});
|
||||
return httpService.http<{ progress: number; isRunning: boolean; isJobClosed: boolean }>({
|
||||
path: `${basePath()}/jobs/look_back_progress`,
|
||||
path: `${ML_BASE_PATH}/jobs/look_back_progress`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -281,7 +281,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
overallValidStatus: CATEGORY_EXAMPLES_VALIDATION_STATUS;
|
||||
validationChecks: FieldExampleCheck[];
|
||||
}>({
|
||||
path: `${basePath()}/jobs/categorization_field_examples`,
|
||||
path: `${ML_BASE_PATH}/jobs/categorization_field_examples`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -293,7 +293,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
total: number;
|
||||
categories: Array<{ count?: number; category: Category }>;
|
||||
}>({
|
||||
path: `${basePath()}/jobs/top_categories`,
|
||||
path: `${ML_BASE_PATH}/jobs/top_categories`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
@ -311,7 +311,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({
|
|||
total: number;
|
||||
categories: Array<{ count?: number; category: Category }>;
|
||||
}>({
|
||||
path: `${basePath()}/jobs/revert_model_snapshot`,
|
||||
path: `${ML_BASE_PATH}/jobs/revert_model_snapshot`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
|
|
@ -47,6 +47,11 @@ import { registerFeature } from './register_feature';
|
|||
import { registerUrlGenerator } from './ml_url_generator/ml_url_generator';
|
||||
import type { MapsStartApi } from '../../maps/public';
|
||||
import { LensPublicStart } from '../../lens/public';
|
||||
import {
|
||||
TriggersAndActionsUIPublicPluginSetup,
|
||||
TriggersAndActionsUIPublicPluginStart,
|
||||
} from '../../triggers_actions_ui/public';
|
||||
import { registerMlAlerts } from './alerting/register_ml_alerts';
|
||||
|
||||
export interface MlStartDependencies {
|
||||
data: DataPublicPluginStart;
|
||||
|
@ -57,7 +62,9 @@ export interface MlStartDependencies {
|
|||
embeddable: EmbeddableStart;
|
||||
maps?: MapsStartApi;
|
||||
lens?: LensPublicStart;
|
||||
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
|
||||
}
|
||||
|
||||
export interface MlSetupDependencies {
|
||||
security?: SecurityPluginSetup;
|
||||
licensing: LicensingPluginSetup;
|
||||
|
@ -69,6 +76,7 @@ export interface MlSetupDependencies {
|
|||
kibanaVersion: string;
|
||||
share: SharePluginSetup;
|
||||
indexPatternManagement: IndexPatternManagementSetup;
|
||||
triggersActionsUi: TriggersAndActionsUIPublicPluginSetup;
|
||||
}
|
||||
|
||||
export type MlCoreSetup = CoreSetup<MlStartDependencies, MlPluginStart>;
|
||||
|
@ -110,6 +118,7 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
|||
uiActions: pluginsStart.uiActions,
|
||||
lens: pluginsStart.lens,
|
||||
kibanaVersion,
|
||||
triggersActionsUi: pluginsStart.triggersActionsUi,
|
||||
},
|
||||
params
|
||||
);
|
||||
|
@ -174,13 +183,14 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
|||
};
|
||||
}
|
||||
|
||||
start(core: CoreStart, deps: any) {
|
||||
start(core: CoreStart, deps: MlStartDependencies) {
|
||||
setDependencyCache({
|
||||
docLinks: core.docLinks!,
|
||||
basePath: core.http.basePath,
|
||||
http: core.http,
|
||||
i18n: core.i18n,
|
||||
});
|
||||
registerMlAlerts(deps.triggersActionsUi.alertTypeRegistry);
|
||||
return {
|
||||
urlGenerator: this.urlGenerator,
|
||||
};
|
||||
|
|
14
x-pack/plugins/ml/server/lib/alerts/alerting_service.test.ts
Normal file
14
x-pack/plugins/ml/server/lib/alerts/alerting_service.test.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { resolveTimeInterval } from './alerting_service';
|
||||
|
||||
describe('Alerting Service', () => {
|
||||
test('should resolve maximum bucket interval', () => {
|
||||
expect(resolveTimeInterval(['15m', '1h', '6h', '90s'])).toBe('43200s');
|
||||
});
|
||||
});
|
525
x-pack/plugins/ml/server/lib/alerts/alerting_service.ts
Normal file
525
x-pack/plugins/ml/server/lib/alerts/alerting_service.ts
Normal file
|
@ -0,0 +1,525 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import Boom from '@hapi/boom';
|
||||
import rison from 'rison-node';
|
||||
import { MlClient } from '../ml_client';
|
||||
import {
|
||||
MlAnomalyDetectionAlertParams,
|
||||
MlAnomalyDetectionAlertPreviewRequest,
|
||||
} from '../../routes/schemas/alerting_schema';
|
||||
import { ANOMALY_RESULT_TYPE } from '../../../common/constants/anomalies';
|
||||
import { AnomalyResultType } from '../../../common/types/anomalies';
|
||||
import {
|
||||
AlertExecutionResult,
|
||||
InfluencerAnomalyAlertDoc,
|
||||
PreviewResponse,
|
||||
PreviewResultsKeys,
|
||||
RecordAnomalyAlertDoc,
|
||||
TopHitsResultsKeys,
|
||||
} from '../../../common/types/alerts';
|
||||
import { parseInterval } from '../../../common/util/parse_interval';
|
||||
import { AnomalyDetectionAlertContext } from './register_anomaly_detection_alert_type';
|
||||
import { MlJobsResponse } from '../../../common/types/job_service';
|
||||
|
||||
function isDefined<T>(argument: T | undefined | null): argument is T {
|
||||
return argument !== undefined && argument !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the longest bucket span from the list and multiply it by 2.
|
||||
* @param bucketSpans Collection of bucket spans
|
||||
*/
|
||||
export function resolveTimeInterval(bucketSpans: string[]): string {
|
||||
return `${
|
||||
Math.max(
|
||||
...bucketSpans
|
||||
.map((b) => parseInterval(b))
|
||||
.filter(isDefined)
|
||||
.map((v) => v.asSeconds())
|
||||
) * 2
|
||||
}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerting related server-side methods
|
||||
* @param mlClient
|
||||
*/
|
||||
export function alertingServiceProvider(mlClient: MlClient) {
|
||||
const getAggResultsLabel = (resultType: AnomalyResultType) => {
|
||||
return {
|
||||
aggGroupLabel: `${resultType}_results` as PreviewResultsKeys,
|
||||
topHitsLabel: `top_${resultType}_hits` as TopHitsResultsKeys,
|
||||
};
|
||||
};
|
||||
|
||||
const getCommonScriptedFields = () => {
|
||||
return {
|
||||
start: {
|
||||
script: {
|
||||
lang: 'painless',
|
||||
source: `LocalDateTime.ofEpochSecond((doc["timestamp"].value.getMillis()-((doc["bucket_span"].value * 1000)
|
||||
* params.padding)) / 1000, 0, ZoneOffset.UTC).toString()+\":00.000Z\"`,
|
||||
params: {
|
||||
padding: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
end: {
|
||||
script: {
|
||||
lang: 'painless',
|
||||
source: `LocalDateTime.ofEpochSecond((doc["timestamp"].value.getMillis()+((doc["bucket_span"].value * 1000)
|
||||
* params.padding)) / 1000, 0, ZoneOffset.UTC).toString()+\":00.000Z\"`,
|
||||
params: {
|
||||
padding: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
timestamp_epoch: {
|
||||
script: {
|
||||
lang: 'painless',
|
||||
source: 'doc["timestamp"].value.getMillis()/1000',
|
||||
},
|
||||
},
|
||||
timestamp_iso8601: {
|
||||
script: {
|
||||
lang: 'painless',
|
||||
source: 'doc["timestamp"].value',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an agg query based on the requested result type.
|
||||
* @param resultType
|
||||
* @param severity
|
||||
*/
|
||||
const getResultTypeAggRequest = (resultType: AnomalyResultType, severity: number) => {
|
||||
return {
|
||||
influencer_results: {
|
||||
filter: {
|
||||
range: {
|
||||
influencer_score: {
|
||||
gte: resultType === ANOMALY_RESULT_TYPE.INFLUENCER ? severity : 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
top_influencer_hits: {
|
||||
top_hits: {
|
||||
sort: [
|
||||
{
|
||||
influencer_score: {
|
||||
order: 'desc',
|
||||
},
|
||||
},
|
||||
],
|
||||
_source: {
|
||||
includes: [
|
||||
'result_type',
|
||||
'timestamp',
|
||||
'influencer_field_name',
|
||||
'influencer_field_value',
|
||||
'influencer_score',
|
||||
'is_interim',
|
||||
'job_id',
|
||||
],
|
||||
},
|
||||
size: 3,
|
||||
script_fields: {
|
||||
...getCommonScriptedFields(),
|
||||
score: {
|
||||
script: {
|
||||
lang: 'painless',
|
||||
source: 'Math.floor(doc["influencer_score"].value)',
|
||||
},
|
||||
},
|
||||
unique_key: {
|
||||
script: {
|
||||
lang: 'painless',
|
||||
source:
|
||||
'doc["timestamp"].value + "_" + doc["influencer_field_name"].value + "_" + doc["influencer_field_value"].value',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
record_results: {
|
||||
filter: {
|
||||
range: {
|
||||
record_score: {
|
||||
gte: resultType === ANOMALY_RESULT_TYPE.RECORD ? severity : 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
top_record_hits: {
|
||||
top_hits: {
|
||||
sort: [
|
||||
{
|
||||
record_score: {
|
||||
order: 'desc',
|
||||
},
|
||||
},
|
||||
],
|
||||
_source: {
|
||||
includes: [
|
||||
'result_type',
|
||||
'timestamp',
|
||||
'record_score',
|
||||
'is_interim',
|
||||
'function',
|
||||
'field_name',
|
||||
'by_field_value',
|
||||
'over_field_value',
|
||||
'partition_field_value',
|
||||
'job_id',
|
||||
],
|
||||
},
|
||||
size: 3,
|
||||
script_fields: {
|
||||
...getCommonScriptedFields(),
|
||||
score: {
|
||||
script: {
|
||||
lang: 'painless',
|
||||
source: 'Math.floor(doc["record_score"].value)',
|
||||
},
|
||||
},
|
||||
unique_key: {
|
||||
script: {
|
||||
lang: 'painless',
|
||||
source: 'doc["timestamp"].value + "_" + doc["function"].value',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...(resultType === ANOMALY_RESULT_TYPE.BUCKET
|
||||
? {
|
||||
bucket_results: {
|
||||
filter: {
|
||||
range: {
|
||||
anomaly_score: {
|
||||
gt: severity,
|
||||
},
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
top_bucket_hits: {
|
||||
top_hits: {
|
||||
sort: [
|
||||
{
|
||||
anomaly_score: {
|
||||
order: 'desc',
|
||||
},
|
||||
},
|
||||
],
|
||||
_source: {
|
||||
includes: [
|
||||
'job_id',
|
||||
'result_type',
|
||||
'timestamp',
|
||||
'anomaly_score',
|
||||
'is_interim',
|
||||
],
|
||||
},
|
||||
size: 1,
|
||||
script_fields: {
|
||||
...getCommonScriptedFields(),
|
||||
score: {
|
||||
script: {
|
||||
lang: 'painless',
|
||||
source: 'Math.floor(doc["anomaly_score"].value)',
|
||||
},
|
||||
},
|
||||
unique_key: {
|
||||
script: {
|
||||
lang: 'painless',
|
||||
source: 'doc["timestamp"].value',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a request body
|
||||
* @param params
|
||||
* @param previewTimeInterval
|
||||
*/
|
||||
const fetchAnomalies = async (
|
||||
params: MlAnomalyDetectionAlertParams,
|
||||
previewTimeInterval?: string
|
||||
): Promise<AlertExecutionResult[] | undefined> => {
|
||||
const jobAndGroupIds = [
|
||||
...(params.jobSelection.jobIds ?? []),
|
||||
...(params.jobSelection.groupIds ?? []),
|
||||
];
|
||||
|
||||
// Extract jobs from group ids and make sure provided jobs assigned to a current space
|
||||
const jobsResponse = (
|
||||
await mlClient.getJobs<MlJobsResponse>({ job_id: jobAndGroupIds.join(',') })
|
||||
).body.jobs;
|
||||
|
||||
if (jobsResponse.length === 0) {
|
||||
// Probably assigned groups don't contain any jobs anymore.
|
||||
return;
|
||||
}
|
||||
|
||||
const lookBackTimeInterval = resolveTimeInterval(
|
||||
jobsResponse.map((v) => v.analysis_config.bucket_span)
|
||||
);
|
||||
|
||||
const jobIds = jobsResponse.map((v) => v.job_id);
|
||||
|
||||
const requestBody = {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
terms: { job_id: jobIds },
|
||||
},
|
||||
{
|
||||
range: {
|
||||
timestamp: {
|
||||
gte: `now-${previewTimeInterval ?? lookBackTimeInterval}`,
|
||||
// Restricts data points to the current moment for preview
|
||||
...(previewTimeInterval ? { lte: 'now' } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
terms: {
|
||||
result_type: Object.values(ANOMALY_RESULT_TYPE),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
alerts_over_time: {
|
||||
date_histogram: {
|
||||
field: 'timestamp',
|
||||
fixed_interval: lookBackTimeInterval,
|
||||
// Ignore empty buckets
|
||||
min_doc_count: 1,
|
||||
},
|
||||
aggs: getResultTypeAggRequest(params.resultType as AnomalyResultType, params.severity),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await mlClient.anomalySearch(
|
||||
{
|
||||
body: requestBody,
|
||||
},
|
||||
jobIds
|
||||
);
|
||||
|
||||
const result = response.body.aggregations as {
|
||||
alerts_over_time: {
|
||||
buckets: Array<
|
||||
{
|
||||
doc_count: number;
|
||||
key: number;
|
||||
key_as_string: string;
|
||||
} & {
|
||||
[key in PreviewResultsKeys]: {
|
||||
doc_count: number;
|
||||
} & {
|
||||
[hitsKey in TopHitsResultsKeys]: {
|
||||
hits: { hits: any[] };
|
||||
};
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
const resultsLabel = getAggResultsLabel(params.resultType as AnomalyResultType);
|
||||
|
||||
return (
|
||||
result.alerts_over_time.buckets
|
||||
// Filter out empty buckets
|
||||
.filter((v) => v.doc_count > 0 && v[resultsLabel.aggGroupLabel].doc_count > 0)
|
||||
// Map response
|
||||
.map((v) => {
|
||||
const aggTypeResults = v[resultsLabel.aggGroupLabel];
|
||||
const requestedAnomalies = aggTypeResults[resultsLabel.topHitsLabel].hits.hits;
|
||||
|
||||
return {
|
||||
count: aggTypeResults.doc_count,
|
||||
key: v.key,
|
||||
key_as_string: v.key_as_string,
|
||||
jobIds: [...new Set(requestedAnomalies.map((h) => h._source.job_id))],
|
||||
isInterim: requestedAnomalies.some((h) => h._source.is_interim),
|
||||
timestamp: requestedAnomalies[0]._source.timestamp,
|
||||
timestampIso8601: requestedAnomalies[0].fields.timestamp_iso8601[0],
|
||||
timestampEpoch: requestedAnomalies[0].fields.timestamp_epoch[0],
|
||||
score: requestedAnomalies[0].fields.score[0],
|
||||
bucketRange: {
|
||||
start: requestedAnomalies[0].fields.start[0],
|
||||
end: requestedAnomalies[0].fields.end[0],
|
||||
},
|
||||
topRecords: v.record_results.top_record_hits.hits.hits.map((h) => ({
|
||||
...h._source,
|
||||
score: h.fields.score[0],
|
||||
unique_key: h.fields.unique_key[0],
|
||||
})) as RecordAnomalyAlertDoc[],
|
||||
topInfluencers: v.influencer_results.top_influencer_hits.hits.hits.map((h) => ({
|
||||
...h._source,
|
||||
score: h.fields.score[0],
|
||||
unique_key: h.fields.unique_key[0],
|
||||
})) as InfluencerAnomalyAlertDoc[],
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO Replace with URL generator when https://github.com/elastic/kibana/issues/59453 is resolved
|
||||
* @param r
|
||||
* @param type
|
||||
*/
|
||||
const buildExplorerUrl = (r: AlertExecutionResult, type: AnomalyResultType): string => {
|
||||
const isInfluencerResult = type === ANOMALY_RESULT_TYPE.INFLUENCER;
|
||||
|
||||
/**
|
||||
* Disabled until Anomaly Explorer page is fixed and properly
|
||||
* support single point time selection
|
||||
*/
|
||||
const highlightSwimLaneSelection = false;
|
||||
|
||||
const globalState = {
|
||||
ml: {
|
||||
jobIds: r.jobIds,
|
||||
},
|
||||
time: {
|
||||
from: r.bucketRange.start,
|
||||
to: r.bucketRange.end,
|
||||
mode: 'absolute',
|
||||
},
|
||||
};
|
||||
|
||||
const appState = {
|
||||
explorer: {
|
||||
mlExplorerFilter: {
|
||||
...(isInfluencerResult
|
||||
? {
|
||||
filterActive: true,
|
||||
filteredFields: [
|
||||
r.topInfluencers![0].influencer_field_name,
|
||||
r.topInfluencers![0].influencer_field_value,
|
||||
],
|
||||
influencersFilterQuery: {
|
||||
bool: {
|
||||
minimum_should_match: 1,
|
||||
should: [
|
||||
{
|
||||
match_phrase: {
|
||||
[r.topInfluencers![0].influencer_field_name]: r.topInfluencers![0]
|
||||
.influencer_field_value,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
queryString: `${r.topInfluencers![0].influencer_field_name}:"${
|
||||
r.topInfluencers![0].influencer_field_value
|
||||
}"`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
mlExplorerSwimlane: {
|
||||
...(highlightSwimLaneSelection
|
||||
? {
|
||||
selectedLanes: [
|
||||
isInfluencerResult ? r.topInfluencers![0].influencer_field_value : 'Overall',
|
||||
],
|
||||
selectedTimes: r.timestampEpoch,
|
||||
selectedType: isInfluencerResult ? 'viewBy' : 'overall',
|
||||
...(isInfluencerResult
|
||||
? { viewByFieldName: r.topInfluencers![0].influencer_field_name }
|
||||
: {}),
|
||||
...(isInfluencerResult ? {} : { showTopFieldValues: true }),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
return `/app/ml/explorer/?_g=${encodeURIComponent(
|
||||
rison.encode(globalState)
|
||||
)}&_a=${encodeURIComponent(rison.encode(appState))}`;
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Return the result of an alert condition execution.
|
||||
*
|
||||
* @param params
|
||||
*/
|
||||
execute: async (
|
||||
params: MlAnomalyDetectionAlertParams,
|
||||
publicBaseUrl: string | undefined
|
||||
): Promise<AnomalyDetectionAlertContext | undefined> => {
|
||||
const res = await fetchAnomalies(params);
|
||||
|
||||
if (!res) {
|
||||
throw new Error('No results found');
|
||||
}
|
||||
|
||||
const result = res[0];
|
||||
if (!result) return;
|
||||
|
||||
const anomalyExplorerUrl = buildExplorerUrl(result, params.resultType as AnomalyResultType);
|
||||
|
||||
return {
|
||||
...result,
|
||||
name: result.key_as_string,
|
||||
anomalyExplorerUrl,
|
||||
kibanaBaseUrl: publicBaseUrl!,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Checks how often the alert condition will fire an alert instance
|
||||
* based on the provided relative time window.
|
||||
*
|
||||
* @param previewParams
|
||||
*/
|
||||
preview: async ({
|
||||
alertParams,
|
||||
timeRange,
|
||||
sampleSize,
|
||||
}: MlAnomalyDetectionAlertPreviewRequest): Promise<PreviewResponse> => {
|
||||
const res = await fetchAnomalies(alertParams, timeRange);
|
||||
|
||||
if (!res) {
|
||||
throw Boom.notFound(`No results found`);
|
||||
}
|
||||
|
||||
return {
|
||||
// sum of all alert responses within the time range
|
||||
count: res.length,
|
||||
results: res.slice(0, sampleSize),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MlAlertingService = ReturnType<typeof alertingServiceProvider>;
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { KibanaRequest } from 'kibana/server';
|
||||
import {
|
||||
ML_ALERT_TYPES,
|
||||
ML_ALERT_TYPES_CONFIG,
|
||||
AnomalyScoreMatchGroupId,
|
||||
} from '../../../common/constants/alerts';
|
||||
import { PLUGIN_ID } from '../../../common/constants/app';
|
||||
import { MINIMUM_FULL_LICENSE } from '../../../common/license';
|
||||
import {
|
||||
MlAnomalyDetectionAlertParams,
|
||||
mlAnomalyDetectionAlertParams,
|
||||
} from '../../routes/schemas/alerting_schema';
|
||||
import { RegisterAlertParams } from './register_ml_alerts';
|
||||
import { InfluencerAnomalyAlertDoc, RecordAnomalyAlertDoc } from '../../../common/types/alerts';
|
||||
import {
|
||||
AlertInstanceContext,
|
||||
AlertInstanceState,
|
||||
AlertTypeState,
|
||||
} from '../../../../alerts/common';
|
||||
|
||||
const alertTypeConfig = ML_ALERT_TYPES_CONFIG[ML_ALERT_TYPES.ANOMALY_DETECTION];
|
||||
|
||||
export type AnomalyDetectionAlertContext = {
|
||||
name: string;
|
||||
jobIds: string[];
|
||||
timestampIso8601: string;
|
||||
timestamp: number;
|
||||
score: number;
|
||||
isInterim: boolean;
|
||||
topRecords: RecordAnomalyAlertDoc[];
|
||||
topInfluencers?: InfluencerAnomalyAlertDoc[];
|
||||
anomalyExplorerUrl: string;
|
||||
kibanaBaseUrl: string;
|
||||
} & AlertInstanceContext;
|
||||
|
||||
export function registerAnomalyDetectionAlertType({
|
||||
alerts,
|
||||
mlSharedServices,
|
||||
publicBaseUrl,
|
||||
}: RegisterAlertParams) {
|
||||
alerts.registerType<
|
||||
MlAnomalyDetectionAlertParams,
|
||||
AlertTypeState,
|
||||
AlertInstanceState,
|
||||
AnomalyDetectionAlertContext,
|
||||
AnomalyScoreMatchGroupId
|
||||
>({
|
||||
id: ML_ALERT_TYPES.ANOMALY_DETECTION,
|
||||
name: alertTypeConfig.name,
|
||||
actionGroups: alertTypeConfig.actionGroups,
|
||||
defaultActionGroupId: alertTypeConfig.defaultActionGroupId,
|
||||
validate: {
|
||||
params: mlAnomalyDetectionAlertParams,
|
||||
},
|
||||
actionVariables: {
|
||||
context: [
|
||||
{
|
||||
name: 'timestamp',
|
||||
description: i18n.translate('xpack.ml.alertContext.timestampDescription', {
|
||||
defaultMessage: 'Timestamp of the anomaly',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'timestampIso8601',
|
||||
description: i18n.translate('xpack.ml.alertContext.timestampIso8601Description', {
|
||||
defaultMessage: 'Time in ISO8601 format',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'jobIds',
|
||||
description: i18n.translate('xpack.ml.alertContext.jobIdsDescription', {
|
||||
defaultMessage: 'List of job IDs triggered the alert instance',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'isInterim',
|
||||
description: i18n.translate('xpack.ml.alertContext.isInterimDescription', {
|
||||
defaultMessage: 'Indicate if top hits contain interim results',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'score',
|
||||
description: i18n.translate('xpack.ml.alertContext.scoreDescription', {
|
||||
defaultMessage: 'Anomaly score',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'topRecords',
|
||||
description: i18n.translate('xpack.ml.alertContext.topRecordsDescription', {
|
||||
defaultMessage: 'Top records',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'topInfluencers',
|
||||
description: i18n.translate('xpack.ml.alertContext.topInfluencersDescription', {
|
||||
defaultMessage: 'Top influencers',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'anomalyExplorerUrl',
|
||||
description: i18n.translate('xpack.ml.alertContext.anomalyExplorerUrlDescription', {
|
||||
defaultMessage: 'URL to open in the Anomaly Explorer',
|
||||
}),
|
||||
useWithTripleBracesInTemplates: true,
|
||||
},
|
||||
// TODO remove when https://github.com/elastic/kibana/pull/90525 is merged
|
||||
{
|
||||
name: 'kibanaBaseUrl',
|
||||
description: i18n.translate('xpack.ml.alertContext.kibanaBasePathUrlDescription', {
|
||||
defaultMessage: 'Kibana base path',
|
||||
}),
|
||||
useWithTripleBracesInTemplates: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
producer: PLUGIN_ID,
|
||||
minimumLicenseRequired: MINIMUM_FULL_LICENSE,
|
||||
async executor({ services, params }) {
|
||||
const fakeRequest = {} as KibanaRequest;
|
||||
const { execute } = mlSharedServices.alertingServiceProvider(
|
||||
services.savedObjectsClient,
|
||||
fakeRequest
|
||||
);
|
||||
const executionResult = await execute(params, publicBaseUrl);
|
||||
|
||||
if (executionResult) {
|
||||
const alertInstanceName = executionResult.name;
|
||||
const alertInstance = services.alertInstanceFactory(alertInstanceName);
|
||||
alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, executionResult);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
20
x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts
Normal file
20
x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { AlertingPlugin } from '../../../../alerts/server';
|
||||
import { registerAnomalyDetectionAlertType } from './register_anomaly_detection_alert_type';
|
||||
import { SharedServices } from '../../shared_services';
|
||||
|
||||
export interface RegisterAlertParams {
|
||||
alerts: AlertingPlugin['setup'];
|
||||
mlSharedServices: SharedServices;
|
||||
publicBaseUrl: string | undefined;
|
||||
}
|
||||
|
||||
export function registerMlAlerts(params: RegisterAlertParams) {
|
||||
registerAnomalyDetectionAlertType(params);
|
||||
}
|
|
@ -57,6 +57,9 @@ import {
|
|||
savedObjectClientsFactory,
|
||||
} from './saved_objects';
|
||||
import { RouteGuard } from './lib/route_guard';
|
||||
import { registerMlAlerts } from './lib/alerts/register_ml_alerts';
|
||||
import { ML_ALERT_TYPES } from '../common/constants/alerts';
|
||||
import { alertingRoutes } from './routes/alerting';
|
||||
|
||||
export type MlPluginSetup = SharedServices;
|
||||
export type MlPluginStart = void;
|
||||
|
@ -98,6 +101,7 @@ export class MlServerPlugin
|
|||
management: {
|
||||
insightsAndAlerting: ['jobsListLink'],
|
||||
},
|
||||
alerting: Object.values(ML_ALERT_TYPES),
|
||||
privileges: {
|
||||
all: admin,
|
||||
read: user,
|
||||
|
@ -123,6 +127,7 @@ export class MlServerPlugin
|
|||
],
|
||||
},
|
||||
});
|
||||
|
||||
registerKibanaSettings(coreSetup);
|
||||
|
||||
this.mlLicense.setup(plugins.licensing.license$, [
|
||||
|
@ -188,21 +193,30 @@ export class MlServerPlugin
|
|||
resolveMlCapabilities,
|
||||
});
|
||||
trainedModelsRoutes(routeInit);
|
||||
alertingRoutes(routeInit);
|
||||
|
||||
initMlServerLog({ log: this.log });
|
||||
|
||||
return {
|
||||
...createSharedServices(
|
||||
this.mlLicense,
|
||||
getSpaces,
|
||||
plugins.cloud,
|
||||
plugins.security?.authz,
|
||||
resolveMlCapabilities,
|
||||
() => this.clusterClient,
|
||||
() => getInternalSavedObjectsClient(),
|
||||
() => this.isMlReady
|
||||
),
|
||||
};
|
||||
const sharedServices = createSharedServices(
|
||||
this.mlLicense,
|
||||
getSpaces,
|
||||
plugins.cloud,
|
||||
plugins.security?.authz,
|
||||
resolveMlCapabilities,
|
||||
() => this.clusterClient,
|
||||
() => getInternalSavedObjectsClient(),
|
||||
() => this.isMlReady
|
||||
);
|
||||
|
||||
if (plugins.alerts) {
|
||||
registerMlAlerts({
|
||||
alerts: plugins.alerts,
|
||||
mlSharedServices: sharedServices,
|
||||
publicBaseUrl: coreSetup.http.basePath.publicBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return { ...sharedServices };
|
||||
}
|
||||
|
||||
public start(coreStart: CoreStart): MlPluginStart {
|
||||
|
|
45
x-pack/plugins/ml/server/routes/alerting.ts
Normal file
45
x-pack/plugins/ml/server/routes/alerting.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { RouteInitialization } from '../types';
|
||||
import { wrapError } from '../client/error_wrapper';
|
||||
import { alertingServiceProvider } from '../lib/alerts/alerting_service';
|
||||
import { mlAnomalyDetectionAlertPreviewRequest } from './schemas/alerting_schema';
|
||||
|
||||
export function alertingRoutes({ router, routeGuard }: RouteInitialization) {
|
||||
/**
|
||||
* @apiGroup Alerting
|
||||
*
|
||||
* @api {post} /api/ml/alerting/preview Preview alerting condition
|
||||
* @apiName PreviewAlert
|
||||
* @apiDescription Returns a preview of the alerting condition
|
||||
*/
|
||||
router.post(
|
||||
{
|
||||
path: '/api/ml/alerting/preview',
|
||||
validate: {
|
||||
body: mlAnomalyDetectionAlertPreviewRequest,
|
||||
},
|
||||
options: {
|
||||
tags: ['access:ml:canGetJobs'],
|
||||
},
|
||||
},
|
||||
routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => {
|
||||
try {
|
||||
const alertingService = alertingServiceProvider(mlClient);
|
||||
|
||||
const result = await alertingService.preview(request.body);
|
||||
|
||||
return response.ok({
|
||||
body: result,
|
||||
});
|
||||
} catch (e) {
|
||||
return response.customError(wrapError(e));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
48
x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts
Normal file
48
x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ALERT_PREVIEW_SAMPLE_SIZE } from '../../../common/constants/alerts';
|
||||
|
||||
export const mlAnomalyDetectionAlertParams = schema.object({
|
||||
jobSelection: schema.object(
|
||||
{
|
||||
jobIds: schema.arrayOf(schema.string(), { defaultValue: [] }),
|
||||
groupIds: schema.arrayOf(schema.string(), { defaultValue: [] }),
|
||||
},
|
||||
{
|
||||
validate: (v) => {
|
||||
if (!v.jobIds?.length && !v.groupIds?.length) {
|
||||
return i18n.translate('xpack.ml.alertTypes.anomalyDetection.jobSelection.errorMessage', {
|
||||
defaultMessage: 'Job selection is required',
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
severity: schema.number(),
|
||||
resultType: schema.string(),
|
||||
});
|
||||
|
||||
export const mlAnomalyDetectionAlertPreviewRequest = schema.object({
|
||||
alertParams: mlAnomalyDetectionAlertParams,
|
||||
/**
|
||||
* Relative time range to look back from now, e.g. 1y, 8m, 15d
|
||||
*/
|
||||
timeRange: schema.string(),
|
||||
/**
|
||||
* Number of top hits to return
|
||||
*/
|
||||
sampleSize: schema.number({ defaultValue: ALERT_PREVIEW_SAMPLE_SIZE, min: 0 }),
|
||||
});
|
||||
|
||||
export type MlAnomalyDetectionAlertParams = TypeOf<typeof mlAnomalyDetectionAlertParams>;
|
||||
|
||||
export type MlAnomalyDetectionAlertPreviewRequest = TypeOf<
|
||||
typeof mlAnomalyDetectionAlertPreviewRequest
|
||||
>;
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server';
|
||||
import { GetGuards } from '../shared_services';
|
||||
import { alertingServiceProvider, MlAlertingService } from '../../lib/alerts/alerting_service';
|
||||
|
||||
export function getAlertingServiceProvider(getGuards: GetGuards) {
|
||||
return {
|
||||
alertingServiceProvider(
|
||||
savedObjectsClient: SavedObjectsClientContract,
|
||||
request: KibanaRequest
|
||||
) {
|
||||
return {
|
||||
preview: async (...args: Parameters<MlAlertingService['preview']>) => {
|
||||
return await getGuards(request, savedObjectsClient)
|
||||
.isFullLicense()
|
||||
.hasMlCapabilities(['canGetJobs'])
|
||||
.ok(({ mlClient }) => alertingServiceProvider(mlClient).preview(...args));
|
||||
},
|
||||
execute: async (
|
||||
...args: Parameters<MlAlertingService['execute']>
|
||||
): ReturnType<MlAlertingService['execute']> => {
|
||||
return await getGuards(request, savedObjectsClient)
|
||||
.isFullLicense()
|
||||
.hasMlCapabilities(['canGetJobs'])
|
||||
.ok(({ mlClient }) => alertingServiceProvider(mlClient).execute(...args));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MlAlertingServiceProvider = ReturnType<typeof getAlertingServiceProvider>;
|
|
@ -28,7 +28,7 @@ export function getJobServiceProvider(getGuards: GetGuards): JobServiceProvider
|
|||
return await getGuards(request, savedObjectsClient)
|
||||
.isFullLicense()
|
||||
.hasMlCapabilities(['canGetJobs'])
|
||||
.ok(async ({ scopedClient, mlClient }) => {
|
||||
.ok(({ scopedClient, mlClient }) => {
|
||||
const { jobsSummary } = jobServiceProvider(scopedClient, mlClient);
|
||||
return jobsSummary(...args);
|
||||
});
|
||||
|
|
|
@ -26,12 +26,17 @@ import { hasMlCapabilitiesProvider, HasMlCapabilities } from '../lib/capabilitie
|
|||
import { MLClusterClientUninitialized } from './errors';
|
||||
import { MlClient, getMlClient } from '../lib/ml_client';
|
||||
import { jobSavedObjectServiceFactory, JobSavedObjectService } from '../saved_objects';
|
||||
import {
|
||||
getAlertingServiceProvider,
|
||||
MlAlertingServiceProvider,
|
||||
} from './providers/alerting_service';
|
||||
|
||||
export type SharedServices = JobServiceProvider &
|
||||
AnomalyDetectorsProvider &
|
||||
MlSystemProvider &
|
||||
ModulesProvider &
|
||||
ResultsServiceProvider;
|
||||
ResultsServiceProvider &
|
||||
MlAlertingServiceProvider;
|
||||
|
||||
interface Guards {
|
||||
isMinimumLicense(): Guards;
|
||||
|
@ -118,6 +123,7 @@ export function createSharedServices(
|
|||
...getModulesProvider(getGuards),
|
||||
...getResultsServiceProvider(getGuards),
|
||||
...getMlSystemProvider(getGuards, mlLicense, getSpaces, cloud, resolveMlCapabilities),
|
||||
...getAlertingServiceProvider(getGuards),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,8 @@ import type { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server';
|
|||
import type { MlLicense } from '../common/license';
|
||||
import type { ResolveMlCapabilities } from '../common/types/capabilities';
|
||||
import type { RouteGuard } from './lib/route_guard';
|
||||
import type { AlertingPlugin } from '../../alerts/server';
|
||||
import type { ActionsPlugin } from '../../actions/server';
|
||||
|
||||
export interface LicenseCheckResult {
|
||||
isAvailable: boolean;
|
||||
|
@ -43,6 +45,8 @@ export interface PluginsSetup {
|
|||
licensing: LicensingPluginSetup;
|
||||
security?: SecurityPluginSetup;
|
||||
spaces?: SpacesPluginSetup;
|
||||
alerts?: AlertingPlugin['setup'];
|
||||
actions?: ActionsPlugin['setup'];
|
||||
}
|
||||
|
||||
export interface PluginsStart {
|
||||
|
|
|
@ -31,5 +31,7 @@
|
|||
{ "path": "../lens/tsconfig.json" },
|
||||
{ "path": "../security/tsconfig.json" },
|
||||
{ "path": "../spaces/tsconfig.json" },
|
||||
{ "path": "../alerts/tsconfig.json" },
|
||||
{ "path": "../triggers_actions_ui/tsconfig.json" }
|
||||
]
|
||||
}
|
||||
|
|
|
@ -94,6 +94,7 @@ describe('rules_notification_alert_type', () => {
|
|||
mlSystemProvider: jest.fn(),
|
||||
modulesProvider: jest.fn(),
|
||||
resultsServiceProvider: jest.fn(),
|
||||
alertingServiceProvider: jest.fn(),
|
||||
};
|
||||
let payload: jest.Mocked<RuleExecutorOptions>;
|
||||
let alert: ReturnType<typeof signalRulesAlertType>;
|
||||
|
|
|
@ -768,6 +768,7 @@ export const AlertForm = ({
|
|||
setAlertIntervalUnit(e.target.value);
|
||||
setScheduleProperty('interval', `${alertInterval}${e.target.value}`);
|
||||
}}
|
||||
data-test-subj="intervalInputUnit"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -37,7 +37,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
return `user-${this.jobId}`;
|
||||
},
|
||||
dependentVariable: 'y',
|
||||
trainingPercent: '20',
|
||||
trainingPercent: 20,
|
||||
modelMemory: '60mb',
|
||||
createIndexPattern: true,
|
||||
expected: {
|
||||
|
|
|
@ -36,7 +36,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
return `user-${this.jobId}`;
|
||||
},
|
||||
dependentVariable: 'stab',
|
||||
trainingPercent: '20',
|
||||
trainingPercent: 20,
|
||||
modelMemory: '20mb',
|
||||
createIndexPattern: true,
|
||||
expected: {
|
||||
|
|
104
x-pack/test/functional/services/ml/alerting.ts
Normal file
104
x-pack/test/functional/services/ml/alerting.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
import { MlCommonUI } from './common_ui';
|
||||
|
||||
export function MachineLearningAlertingProvider(
|
||||
{ getService }: FtrProviderContext,
|
||||
mlCommonUI: MlCommonUI
|
||||
) {
|
||||
const retry = getService('retry');
|
||||
const comboBox = getService('comboBox');
|
||||
const testSubjects = getService('testSubjects');
|
||||
|
||||
return {
|
||||
async selectAnomalyDetectionAlertType() {
|
||||
await testSubjects.click('xpack.ml.anomaly_detection_alert-SelectOption');
|
||||
await retry.tryForTime(5000, async () => {
|
||||
await testSubjects.existOrFail(`mlAnomalyAlertForm`);
|
||||
});
|
||||
},
|
||||
|
||||
async selectJobs(jobIds: string[]) {
|
||||
for (const jobId of jobIds) {
|
||||
await comboBox.set('mlAnomalyAlertJobSelection > comboBoxInput', jobId);
|
||||
}
|
||||
await this.assertJobSelection(jobIds);
|
||||
},
|
||||
|
||||
async assertJobSelection(expectedJobIds: string[]) {
|
||||
const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions(
|
||||
'mlAnomalyAlertJobSelection > comboBoxInput'
|
||||
);
|
||||
expect(comboBoxSelectedOptions).to.eql(
|
||||
expectedJobIds,
|
||||
`Expected job selection to be '${expectedJobIds}' (got '${comboBoxSelectedOptions}')`
|
||||
);
|
||||
},
|
||||
|
||||
async selectResultType(resultType: string) {
|
||||
await testSubjects.click(`mlAnomalyAlertResult_${resultType}`);
|
||||
await this.assertResultTypeSelection(resultType);
|
||||
},
|
||||
|
||||
async assertResultTypeSelection(resultType: string) {
|
||||
await retry.tryForTime(5000, async () => {
|
||||
await testSubjects.existOrFail(`mlAnomalyAlertResult_${resultType}_selected`);
|
||||
});
|
||||
},
|
||||
|
||||
async setSeverity(severity: number) {
|
||||
await mlCommonUI.setSliderValue('mlAnomalyAlertScoreSelection', severity);
|
||||
},
|
||||
|
||||
async assertSeverity(expectedValue: number) {
|
||||
await mlCommonUI.assertSliderValue('mlAnomalyAlertScoreSelection', expectedValue);
|
||||
},
|
||||
|
||||
async setTestInterval(interval: string) {
|
||||
await testSubjects.setValue('mlAnomalyAlertPreviewInterval', interval);
|
||||
await this.assertTestIntervalValue(interval);
|
||||
},
|
||||
|
||||
async assertTestIntervalValue(expectedInterval: string) {
|
||||
const actualValue = await testSubjects.getAttribute('mlAnomalyAlertPreviewInterval', 'value');
|
||||
expect(actualValue).to.eql(
|
||||
expectedInterval,
|
||||
`Expected test interval to equal ${expectedInterval}, got ${actualValue}`
|
||||
);
|
||||
},
|
||||
|
||||
async assertPreviewButtonState(expectedEnabled: boolean) {
|
||||
const isEnabled = await testSubjects.isEnabled('mlAnomalyAlertPreviewButton');
|
||||
expect(isEnabled).to.eql(
|
||||
expectedEnabled,
|
||||
`Expected data frame analytics "create" button to be '${
|
||||
expectedEnabled ? 'enabled' : 'disabled'
|
||||
}' (got '${isEnabled ? 'enabled' : 'disabled'}')`
|
||||
);
|
||||
},
|
||||
|
||||
async clickPreviewButton() {
|
||||
await testSubjects.click('mlAnomalyAlertPreviewButton');
|
||||
await this.assertPreviewCalloutVisible();
|
||||
},
|
||||
|
||||
async checkPreview(expectedMessage: string) {
|
||||
await this.clickPreviewButton();
|
||||
const previewMessage = await testSubjects.getVisibleText('mlAnomalyAlertPreviewMessage');
|
||||
expect(previewMessage).to.eql(expectedMessage);
|
||||
},
|
||||
|
||||
async assertPreviewCalloutVisible() {
|
||||
await retry.tryForTime(5000, async () => {
|
||||
await testSubjects.existOrFail(`mlAnomalyAlertPreviewCallout`);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
|
@ -163,5 +163,54 @@ export function MachineLearningCommonUIProvider({ getService }: FtrProviderConte
|
|||
// escape popover
|
||||
await browser.pressKeys(browser.keys.ESCAPE);
|
||||
},
|
||||
|
||||
async setSliderValue(testDataSubj: string, value: number) {
|
||||
const slider = await testSubjects.find(testDataSubj);
|
||||
|
||||
let currentValue = await slider.getAttribute('value');
|
||||
let currentDiff = +currentValue - +value;
|
||||
|
||||
await retry.tryForTime(60 * 1000, async () => {
|
||||
if (currentDiff === 0) {
|
||||
return true;
|
||||
} else {
|
||||
if (currentDiff > 0) {
|
||||
if (Math.abs(currentDiff) >= 10) {
|
||||
slider.type(browser.keys.PAGE_DOWN);
|
||||
} else {
|
||||
slider.type(browser.keys.ARROW_LEFT);
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(currentDiff) >= 10) {
|
||||
slider.type(browser.keys.PAGE_UP);
|
||||
} else {
|
||||
slider.type(browser.keys.ARROW_RIGHT);
|
||||
}
|
||||
}
|
||||
await retry.tryForTime(1000, async () => {
|
||||
const newValue = await slider.getAttribute('value');
|
||||
if (newValue !== currentValue) {
|
||||
currentValue = newValue;
|
||||
currentDiff = +currentValue - +value;
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(`slider value should have changed, but is still ${currentValue}`);
|
||||
}
|
||||
});
|
||||
|
||||
throw new Error(`slider value should be '${value}' (got '${currentValue}')`);
|
||||
}
|
||||
});
|
||||
|
||||
await this.assertSliderValue(testDataSubj, value);
|
||||
},
|
||||
|
||||
async assertSliderValue(testDataSubj: string, expectedValue: number) {
|
||||
const actualValue = await testSubjects.getAttribute(testDataSubj, 'value');
|
||||
expect(actualValue).to.eql(
|
||||
expectedValue,
|
||||
`${testDataSubj} slider value should be '${expectedValue}' (got '${actualValue}')`
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ export function MachineLearningDataFrameAnalyticsCreationProvider(
|
|||
const testSubjects = getService('testSubjects');
|
||||
const comboBox = getService('comboBox');
|
||||
const retry = getService('retry');
|
||||
const browser = getService('browser');
|
||||
|
||||
return {
|
||||
async assertJobTypeSelectExists() {
|
||||
|
@ -273,45 +272,11 @@ export function MachineLearningDataFrameAnalyticsCreationProvider(
|
|||
);
|
||||
},
|
||||
|
||||
async setTrainingPercent(trainingPercent: string) {
|
||||
const slider = await testSubjects.find('mlAnalyticsCreateJobWizardTrainingPercentSlider');
|
||||
|
||||
let currentValue = await slider.getAttribute('value');
|
||||
let currentDiff = +currentValue - +trainingPercent;
|
||||
|
||||
await retry.tryForTime(60 * 1000, async () => {
|
||||
if (currentDiff === 0) {
|
||||
return true;
|
||||
} else {
|
||||
if (currentDiff > 0) {
|
||||
if (Math.abs(currentDiff) >= 10) {
|
||||
slider.type(browser.keys.PAGE_DOWN);
|
||||
} else {
|
||||
slider.type(browser.keys.ARROW_LEFT);
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(currentDiff) >= 10) {
|
||||
slider.type(browser.keys.PAGE_UP);
|
||||
} else {
|
||||
slider.type(browser.keys.ARROW_RIGHT);
|
||||
}
|
||||
}
|
||||
await retry.tryForTime(1000, async () => {
|
||||
const newValue = await slider.getAttribute('value');
|
||||
if (newValue !== currentValue) {
|
||||
currentValue = newValue;
|
||||
currentDiff = +currentValue - +trainingPercent;
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(`slider value should have changed, but is still ${currentValue}`);
|
||||
}
|
||||
});
|
||||
|
||||
throw new Error(`slider value should be '${trainingPercent}' (got '${currentValue}')`);
|
||||
}
|
||||
});
|
||||
|
||||
await this.assertTrainingPercentValue(trainingPercent);
|
||||
async setTrainingPercent(trainingPercent: number) {
|
||||
await mlCommonUI.setSliderValue(
|
||||
'mlAnalyticsCreateJobWizardTrainingPercentSlider',
|
||||
trainingPercent
|
||||
);
|
||||
},
|
||||
|
||||
async assertConfigurationStepActive() {
|
||||
|
|
|
@ -45,6 +45,7 @@ import { MachineLearningSingleMetricViewerProvider } from './single_metric_viewe
|
|||
import { MachineLearningTestExecutionProvider } from './test_execution';
|
||||
import { MachineLearningTestResourcesProvider } from './test_resources';
|
||||
import { MachineLearningDataVisualizerTableProvider } from './data_visualizer_table';
|
||||
import { MachineLearningAlertingProvider } from './alerting';
|
||||
|
||||
export function MachineLearningProvider(context: FtrProviderContext) {
|
||||
const commonAPI = MachineLearningCommonAPIProvider(context);
|
||||
|
@ -95,10 +96,12 @@ export function MachineLearningProvider(context: FtrProviderContext) {
|
|||
const singleMetricViewer = MachineLearningSingleMetricViewerProvider(context, commonUI);
|
||||
const testExecution = MachineLearningTestExecutionProvider(context);
|
||||
const testResources = MachineLearningTestResourcesProvider(context);
|
||||
const alerting = MachineLearningAlertingProvider(context, commonUI);
|
||||
|
||||
return {
|
||||
anomaliesTable,
|
||||
anomalyExplorer,
|
||||
alerting,
|
||||
api,
|
||||
commonAPI,
|
||||
commonConfig,
|
||||
|
|
|
@ -36,6 +36,12 @@ export function MachineLearningNavigationProvider({
|
|||
});
|
||||
},
|
||||
|
||||
async navigateToAlertsAndAction() {
|
||||
await PageObjects.common.navigateToApp('triggersActions');
|
||||
await testSubjects.click('alertsTab');
|
||||
await testSubjects.existOrFail('alertsList');
|
||||
},
|
||||
|
||||
async assertTabsExist(tabTypeSubject: string, areaSubjects: string[]) {
|
||||
await retry.tryForTime(10000, async () => {
|
||||
const allTabs = await testSubjects.findAll(`~${tabTypeSubject}`, 3);
|
||||
|
|
124
x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts
Normal file
124
x-pack/test/functional_with_es_ssl/apps/ml/alert_flyout.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
import { DATAFEED_STATE } from '../../../../plugins/ml/common/constants/states';
|
||||
|
||||
function createTestJobAndDatafeed() {
|
||||
const timestamp = Date.now();
|
||||
const jobId = `ec-high_sum_total_sales_${timestamp}`;
|
||||
|
||||
return {
|
||||
job: {
|
||||
job_id: jobId,
|
||||
description: 'test_job_annotation',
|
||||
groups: ['ecommerce'],
|
||||
analysis_config: {
|
||||
bucket_span: '1h',
|
||||
detectors: [
|
||||
{
|
||||
detector_description: 'High total sales',
|
||||
function: 'high_sum',
|
||||
field_name: 'taxful_total_price',
|
||||
over_field_name: 'customer_full_name.keyword',
|
||||
detector_index: 0,
|
||||
},
|
||||
],
|
||||
influencers: ['customer_full_name.keyword', 'category.keyword'],
|
||||
},
|
||||
data_description: {
|
||||
time_field: 'order_date',
|
||||
time_format: 'epoch_ms',
|
||||
},
|
||||
analysis_limits: {
|
||||
model_memory_limit: '13mb',
|
||||
categorization_examples_limit: 4,
|
||||
},
|
||||
},
|
||||
datafeed: {
|
||||
datafeed_id: `datafeed-${jobId}`,
|
||||
job_id: jobId,
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
match_all: {},
|
||||
},
|
||||
],
|
||||
filter: [],
|
||||
must_not: [],
|
||||
},
|
||||
},
|
||||
indices: ['ft_ecommerce'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
||||
const esArchiver = getService('esArchiver');
|
||||
const ml = getService('ml');
|
||||
const pageObjects = getPageObjects(['triggersActionsUI']);
|
||||
|
||||
let testJobId = '';
|
||||
|
||||
describe('anomaly detection alert', function () {
|
||||
this.tags('ciGroup13');
|
||||
|
||||
before(async () => {
|
||||
await esArchiver.loadIfNeeded('ml/ecommerce');
|
||||
await ml.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date');
|
||||
await ml.testResources.setKibanaTimeZoneToUTC();
|
||||
|
||||
await ml.securityUI.loginAsMlPowerUser();
|
||||
|
||||
const { job, datafeed } = createTestJobAndDatafeed();
|
||||
|
||||
testJobId = job.job_id;
|
||||
|
||||
// Set up jobs
|
||||
await ml.api.createAnomalyDetectionJob(job);
|
||||
await ml.api.openAnomalyDetectionJob(job.job_id);
|
||||
await ml.api.createDatafeed(datafeed);
|
||||
await ml.api.startDatafeed(datafeed.datafeed_id);
|
||||
await ml.api.waitForDatafeedState(datafeed.datafeed_id, DATAFEED_STATE.STARTED);
|
||||
await ml.api.assertJobResultsExist(job.job_id);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await ml.api.cleanMlIndices();
|
||||
});
|
||||
|
||||
describe('overview page alert flyout controls', () => {
|
||||
it('can create an anomaly detection alert', async () => {
|
||||
await ml.navigation.navigateToAlertsAndAction();
|
||||
await pageObjects.triggersActionsUI.clickCreateAlertButton();
|
||||
await ml.alerting.selectAnomalyDetectionAlertType();
|
||||
|
||||
await ml.testExecution.logTestStep('should have correct default values');
|
||||
await ml.alerting.assertSeverity(75);
|
||||
await ml.alerting.assertPreviewButtonState(false);
|
||||
|
||||
await ml.testExecution.logTestStep('should complete the alert params');
|
||||
await ml.alerting.selectJobs([testJobId]);
|
||||
await ml.alerting.selectResultType('record');
|
||||
await ml.alerting.setSeverity(10);
|
||||
|
||||
await ml.testExecution.logTestStep('should preview the alert condition');
|
||||
await ml.alerting.assertPreviewButtonState(false);
|
||||
await ml.alerting.setTestInterval('2y');
|
||||
await ml.alerting.assertPreviewButtonState(true);
|
||||
await ml.alerting.checkPreview('Triggers 2 times in the last 2y');
|
||||
|
||||
await ml.testExecution.logTestStep('should create an alert');
|
||||
await pageObjects.triggersActionsUI.setAlertName('ml-test-alert');
|
||||
await pageObjects.triggersActionsUI.setAlertInterval(10, 's');
|
||||
await pageObjects.triggersActionsUI.saveAlert();
|
||||
await pageObjects.triggersActionsUI.clickOnAlertInAlertsList('ml-test-alert');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
33
x-pack/test/functional_with_es_ssl/apps/ml/index.ts
Normal file
33
x-pack/test/functional_with_es_ssl/apps/ml/index.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default ({ loadTestFile, getService }: FtrProviderContext) => {
|
||||
const ml = getService('ml');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
describe('ML app', function () {
|
||||
this.tags(['mlqa', 'skipFirefox']);
|
||||
|
||||
before(async () => {
|
||||
await ml.securityCommon.createMlRoles();
|
||||
await ml.securityCommon.createMlUsers();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await ml.testResources.deleteIndexPatternByTitle('ft_ecommerce');
|
||||
await esArchiver.unload('ml/ecommerce');
|
||||
await ml.securityCommon.cleanMlUsers();
|
||||
await ml.securityCommon.cleanMlRoles();
|
||||
await ml.testResources.resetKibanaTimeZone();
|
||||
await ml.securityUI.logout();
|
||||
});
|
||||
|
||||
loadTestFile(require.resolve('./alert_flyout'));
|
||||
});
|
||||
};
|
|
@ -46,6 +46,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
testFiles: [
|
||||
resolve(__dirname, './apps/triggers_actions_ui'),
|
||||
resolve(__dirname, './apps/uptime'),
|
||||
resolve(__dirname, './apps/ml'),
|
||||
],
|
||||
apps: {
|
||||
...xpackFunctionalConfig.get('apps'),
|
||||
|
|
|
@ -157,5 +157,34 @@ export function TriggersActionsPageProvider({ getService }: FtrProviderContext)
|
|||
);
|
||||
await createBtn.click();
|
||||
},
|
||||
async setAlertName(value: string) {
|
||||
await testSubjects.setValue('alertNameInput', value);
|
||||
await this.assertAlertName(value);
|
||||
},
|
||||
async assertAlertName(expectedValue: string) {
|
||||
const actualValue = await testSubjects.getAttribute('alertNameInput', 'value');
|
||||
expect(actualValue).to.eql(expectedValue);
|
||||
},
|
||||
async setAlertInterval(value: number, unit?: 's' | 'm' | 'h' | 'd') {
|
||||
await testSubjects.setValue('intervalInput', value.toString());
|
||||
if (unit) {
|
||||
await testSubjects.selectValue('intervalInputUnit', unit);
|
||||
}
|
||||
await this.assertAlertInterval(value, unit);
|
||||
},
|
||||
async assertAlertInterval(expectedValue: number, expectedUnit?: 's' | 'm' | 'h' | 'd') {
|
||||
const actualValue = await testSubjects.getAttribute('intervalInput', 'value');
|
||||
expect(actualValue).to.eql(expectedValue);
|
||||
if (expectedUnit) {
|
||||
const actualUnitValue = await testSubjects.getAttribute('intervalInputUnit', 'value');
|
||||
expect(actualUnitValue).to.eql(expectedUnit);
|
||||
}
|
||||
},
|
||||
async saveAlert() {
|
||||
await testSubjects.click('saveAlertButton');
|
||||
const isConfirmationModalVisible = await testSubjects.isDisplayed('confirmAlertSaveModal');
|
||||
expect(isConfirmationModalVisible).to.eql(true, 'Expect confirmation modal to be visible');
|
||||
await testSubjects.click('confirmModalConfirmButton');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue