[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:
Dima Arnautov 2021-02-11 18:14:14 +01:00 committed by GitHub
parent 40570a633f
commit 341e9cf2eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2305 additions and 110 deletions

View file

@ -105,6 +105,7 @@ export interface AlertExecutorOptions<
export interface ActionVariable {
name: string;
description: string;
useWithTripleBracesInTemplates?: boolean;
}
export type ExecutorType<

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -17,9 +17,11 @@
"uiActions",
"kibanaLegacy",
"indexPatternManagement",
"discover"
"discover",
"triggersActionsUi"
],
"optionalPlugins": [
"alerts",
"home",
"security",
"spaces",

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

View file

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

View 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}
</>
)}
</>
);
};

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

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

View file

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

View file

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

View file

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

View file

@ -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: (

View file

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

View file

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

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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,
});
},
};
};

View file

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

View file

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

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

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

View file

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

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

View file

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

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

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

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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>;

View file

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

View file

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

View file

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

View file

@ -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" }
]
}

View file

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

View file

@ -768,6 +768,7 @@ export const AlertForm = ({
setAlertIntervalUnit(e.target.value);
setScheduleProperty('interval', `${alertInterval}${e.target.value}`);
}}
data-test-subj="intervalInputUnit"
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -37,7 +37,7 @@ export default function ({ getService }: FtrProviderContext) {
return `user-${this.jobId}`;
},
dependentVariable: 'y',
trainingPercent: '20',
trainingPercent: 20,
modelMemory: '60mb',
createIndexPattern: true,
expected: {

View file

@ -36,7 +36,7 @@ export default function ({ getService }: FtrProviderContext) {
return `user-${this.jobId}`;
},
dependentVariable: 'stab',
trainingPercent: '20',
trainingPercent: 20,
modelMemory: '20mb',
createIndexPattern: true,
expected: {

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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