[ML] API integration tests for job validation (#66265)

* [ML] TS refactoring

* [ML] fix page overflow

* [ML] tests

* [ML] fix typing issues and unit tests

* [ML] remove string conversion

* [ML] indexOf checks

* [ML] fix tooltip overflow

* [ML] fix i18n

* [ML] fix unit tests

* [ML] use MlJobAggregation

* [ML] use enums

* Revert "[ML] fix tooltip overflow"

This reverts commit 103c36bc

* Revert "[ML] fix page overflow"

This reverts commit 3c869228
This commit is contained in:
Dima Arnautov 2020-05-13 18:52:49 +02:00 committed by GitHub
parent 69d4199feb
commit d0b9840041
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 739 additions and 430 deletions

View file

@ -4,21 +4,47 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { once } from 'lodash';
import { i18n } from '@kbn/i18n';
import { JOB_ID_MAX_LENGTH } from '../../../common/constants/validation';
import { JOB_ID_MAX_LENGTH, VALIDATION_STATUS } from './validation';
let messages;
export type MessageId = keyof ReturnType<typeof getMessages>;
export const getMessages = () => {
if (messages) {
return messages;
}
export interface JobValidationMessageDef {
status: VALIDATION_STATUS;
text: string;
url?: string;
heading?: string;
}
export type JobValidationMessageId =
| MessageId
| 'model_memory_limit_invalid'
| 'model_memory_limit_valid'
| 'model_memory_limit_units_invalid'
| 'model_memory_limit_units_valid'
| 'query_delay_invalid'
| 'query_delay_valid'
| 'frequency_valid'
| 'frequency_invalid'
// because we have some spread around
| string;
export type JobValidationMessage = {
id: JobValidationMessageId;
url?: string;
fieldName?: string;
modelPlotCardinality?: number;
} & {
[key: string]: any;
};
export const getMessages = once(() => {
const createJobsDocsUrl = `https://www.elastic.co/guide/en/machine-learning/{{version}}/create-jobs.html`;
return (messages = {
return {
field_not_aggregatable: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.fieldNotAggregatableMessage', {
defaultMessage: 'Detector field {fieldName} is not an aggregatable field.',
values: {
@ -29,7 +55,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-aggregation.html',
},
fields_not_aggregatable: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.fieldsNotAggregatableMessage', {
defaultMessage: 'One of the detector fields is not an aggregatable field.',
}),
@ -37,7 +63,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-aggregation.html',
},
cardinality_by_field: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate('xpack.ml.models.jobValidation.messages.cardinalityByFieldMessage', {
defaultMessage:
'Cardinality of {fieldName} is above 1000 and might result in high memory usage.',
@ -48,7 +74,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#cardinality`,
},
cardinality_over_field_low: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.cardinalityOverFieldLowMessage',
{
@ -62,7 +88,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#cardinality`,
},
cardinality_over_field_high: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.cardinalityOverFieldHighMessage',
{
@ -76,7 +102,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#cardinality`,
},
cardinality_partition_field: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.cardinalityPartitionFieldMessage',
{
@ -90,7 +116,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#cardinality`,
},
cardinality_model_plot_high: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.cardinalityModelPlotHighMessage',
{
@ -104,7 +130,7 @@ export const getMessages = () => {
),
},
categorization_filters_valid: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.categorizationFiltersValidMessage',
{
@ -115,7 +141,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-categories.html',
},
categorization_filters_invalid: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.categorizationFiltersInvalidMessage',
{
@ -131,7 +157,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig',
},
bucket_span_empty: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.bucketSpanEmptyMessage', {
defaultMessage: 'The bucket span field must be specified.',
}),
@ -139,7 +165,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig',
},
bucket_span_estimation_mismatch: {
status: 'INFO',
status: VALIDATION_STATUS.INFO,
heading: i18n.translate(
'xpack.ml.models.jobValidation.messages.bucketSpanEstimationMismatchHeading',
{
@ -160,7 +186,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#bucket-span`,
},
bucket_span_high: {
status: 'INFO',
status: VALIDATION_STATUS.INFO,
heading: i18n.translate('xpack.ml.models.jobValidation.messages.bucketSpanHighHeading', {
defaultMessage: 'Bucket span',
}),
@ -171,7 +197,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#bucket-span`,
},
bucket_span_valid: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
heading: i18n.translate('xpack.ml.models.jobValidation.messages.bucketSpanValidHeading', {
defaultMessage: 'Bucket span',
}),
@ -185,7 +211,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig',
},
bucket_span_invalid: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
heading: i18n.translate('xpack.ml.models.jobValidation.messages.bucketSpanInvalidHeading', {
defaultMessage: 'Bucket span',
}),
@ -197,7 +223,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig',
},
detectors_duplicates: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.detectorsDuplicatesMessage', {
defaultMessage:
'Duplicate detectors were found. Detectors having the same combined configuration for ' +
@ -214,21 +240,21 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#detectors`,
},
detectors_empty: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.detectorsEmptyMessage', {
defaultMessage: 'No detectors were found. At least one detector must be specified.',
}),
url: `${createJobsDocsUrl}#detectors`,
},
detectors_function_empty: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.detectorsFunctionEmptyMessage', {
defaultMessage: 'One of the detector functions is empty.',
}),
url: `${createJobsDocsUrl}#detectors`,
},
detectors_function_not_empty: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
heading: i18n.translate(
'xpack.ml.models.jobValidation.messages.detectorsFunctionNotEmptyHeading',
{
@ -244,19 +270,19 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#detectors`,
},
index_fields_invalid: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.indexFieldsInvalidMessage', {
defaultMessage: 'Could not load fields from index.',
}),
},
index_fields_valid: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
text: i18n.translate('xpack.ml.models.jobValidation.messages.indexFieldsValidMessage', {
defaultMessage: 'Index fields are present in the datafeed.',
}),
},
influencer_high: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate('xpack.ml.models.jobValidation.messages.influencerHighMessage', {
defaultMessage:
'The job configuration includes more than 3 influencers. ' +
@ -265,7 +291,7 @@ export const getMessages = () => {
url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html',
},
influencer_low: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate('xpack.ml.models.jobValidation.messages.influencerLowMessage', {
defaultMessage:
'No influencers have been configured. Picking an influencer is strongly recommended.',
@ -273,7 +299,7 @@ export const getMessages = () => {
url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html',
},
influencer_low_suggestion: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.influencerLowSuggestionMessage',
{
@ -285,7 +311,7 @@ export const getMessages = () => {
url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html',
},
influencer_low_suggestions: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.influencerLowSuggestionsMessage',
{
@ -297,7 +323,7 @@ export const getMessages = () => {
url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html',
},
job_id_empty: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.jobIdEmptyMessage', {
defaultMessage: 'Job ID field must not be empty.',
}),
@ -305,7 +331,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
},
job_id_invalid: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.jobIdInvalidMessage', {
defaultMessage:
'Job ID is invalid. It can contain lowercase alphanumeric (a-z and 0-9) characters, ' +
@ -315,7 +341,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
},
job_id_invalid_max_length: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.jobIdInvalidMaxLengthErrorMessage',
{
@ -330,7 +356,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
},
job_id_valid: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
heading: i18n.translate('xpack.ml.models.jobValidation.messages.jobIdValidHeading', {
defaultMessage: 'Job ID format is valid',
}),
@ -347,7 +373,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
},
job_group_id_invalid: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.jobGroupIdInvalidMessage', {
defaultMessage:
'One of the job group names is invalid. They can contain lowercase ' +
@ -357,7 +383,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
},
job_group_id_invalid_max_length: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.jobGroupIdInvalidMaxLengthErrorMessage',
{
@ -372,7 +398,7 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
},
job_group_id_valid: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
heading: i18n.translate('xpack.ml.models.jobValidation.messages.jobGroupIdValidHeading', {
defaultMessage: 'Job group id formats are valid',
}),
@ -389,14 +415,14 @@ export const getMessages = () => {
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
},
skipped_extended_tests: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate('xpack.ml.models.jobValidation.messages.skippedExtendedTestsMessage', {
defaultMessage:
'Skipped additional checks because the basic requirements of the job configuration were not met.',
}),
},
success_cardinality: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
heading: i18n.translate('xpack.ml.models.jobValidation.messages.successCardinalityHeading', {
defaultMessage: 'Cardinality',
}),
@ -406,7 +432,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#cardinality`,
},
success_bucket_span: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
heading: i18n.translate('xpack.ml.models.jobValidation.messages.successBucketSpanHeading', {
defaultMessage: 'Bucket span',
}),
@ -417,14 +443,14 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#bucket-span`,
},
success_influencers: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
text: i18n.translate('xpack.ml.models.jobValidation.messages.successInfluencersMessage', {
defaultMessage: 'Influencer configuration passed the validation checks.',
}),
url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html',
},
estimated_mml_greater_than_max_mml: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.estimatedMmlGreaterThanMaxMmlMessage',
{
@ -434,7 +460,7 @@ export const getMessages = () => {
),
},
mml_greater_than_effective_max_mml: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.mmlGreaterThanEffectiveMaxMmlMessage',
{
@ -445,14 +471,14 @@ export const getMessages = () => {
),
},
mml_greater_than_max_mml: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.mmlGreaterThanMaxMmlMessage', {
defaultMessage:
'The model memory limit is greater than the max model memory limit configured for this cluster.',
}),
},
mml_value_invalid: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.mmlValueInvalidMessage', {
defaultMessage:
'{mml} is not a valid value for model memory limit. The value needs to be at least ' +
@ -462,7 +488,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#model-memory-limits`,
},
half_estimated_mml_greater_than_mml: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.halfEstimatedMmlGreaterThanMmlMessage',
{
@ -474,7 +500,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#model-memory-limits`,
},
estimated_mml_greater_than_mml: {
status: 'INFO',
status: VALIDATION_STATUS.INFO,
text: i18n.translate(
'xpack.ml.models.jobValidation.messages.estimatedMmlGreaterThanMmlMessage',
{
@ -485,7 +511,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#model-memory-limits`,
},
success_mml: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
heading: i18n.translate('xpack.ml.models.jobValidation.messages.successMmlHeading', {
defaultMessage: 'Model memory limit',
}),
@ -495,7 +521,7 @@ export const getMessages = () => {
url: `${createJobsDocsUrl}#model-memory-limits`,
},
success_time_range: {
status: 'SUCCESS',
status: VALIDATION_STATUS.SUCCESS,
heading: i18n.translate('xpack.ml.models.jobValidation.messages.successTimeRangeHeading', {
defaultMessage: 'Time range',
}),
@ -504,7 +530,7 @@ export const getMessages = () => {
}),
},
time_field_invalid: {
status: 'ERROR',
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.timeFieldInvalidMessage', {
defaultMessage: `{timeField} cannot be used as the time field because it is not a field of type 'date' or 'date_nanos'.`,
values: {
@ -513,7 +539,7 @@ export const getMessages = () => {
}),
},
time_range_short: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
heading: i18n.translate('xpack.ml.models.jobValidation.messages.timeRangeShortHeading', {
defaultMessage: 'Time range',
}),
@ -528,7 +554,7 @@ export const getMessages = () => {
}),
},
time_range_before_epoch: {
status: 'WARNING',
status: VALIDATION_STATUS.WARNING,
heading: i18n.translate(
'xpack.ml.models.jobValidation.messages.timeRangeBeforeEpochHeading',
{
@ -541,5 +567,5 @@ export const getMessages = () => {
'the UNIX epoch beginning. Timestamps before 01/01/1970 00:00:00 (UTC) are not supported for machine learning jobs.',
}),
},
});
};
};
});

View file

@ -8,7 +8,7 @@ export interface JobStat {
id: string;
earliestTimestampMs: number;
latestTimestampMs: number;
latestResultsTimestampMs: number;
latestResultsTimestampMs: number | undefined;
}
export interface JobExistResult {

View file

@ -29,7 +29,7 @@ export enum ENTITY_FIELD_TYPE {
export interface EntityField {
fieldName: string;
fieldValue: string | number | undefined;
fieldType: ENTITY_FIELD_TYPE;
fieldType?: ENTITY_FIELD_TYPE;
}
// List of function descriptions for which actual values from record level results should be displayed.

View file

@ -1,58 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Job } from '../types/anomaly_detection_jobs';
export interface ValidationMessage {
id: string;
}
export interface ValidationResults {
messages: ValidationMessage[];
valid: boolean;
contains: (id: string) => boolean;
find: (id: string) => ValidationMessage | undefined;
}
export function calculateDatafeedFrequencyDefaultSeconds(bucketSpanSeconds: number): number;
// TODO - use real types for job. Job interface first needs to move to a common location
export function isTimeSeriesViewJob(job: any): boolean;
export function basicJobValidation(
job: any,
fields: any[] | undefined,
limits: any,
skipMmlCheck?: boolean
): ValidationResults;
export function basicDatafeedValidation(job: any): ValidationResults;
export const ML_MEDIAN_PERCENTS: number;
export const ML_DATA_PREVIEW_COUNT: number;
export function isJobIdValid(jobId: string): boolean;
export function validateModelMemoryLimitUnits(
modelMemoryLimit: string
): { valid: boolean; messages: any[]; contains: () => boolean; find: () => void };
export function processCreatedBy(customSettings: { created_by?: string }): void;
export function mlFunctionToESAggregation(functionName: string): string | null;
export function isModelPlotEnabled(job: Job, detectorIndex: number, entityFields: any[]): boolean;
export function isModelPlotChartableForDetector(job: Job, detectorIndex: number): boolean;
export function getSafeAggregationName(fieldName: string, index: number): string;
export function getLatestDataOrBucketTimestamp(
latestDataTimestamp: number,
latestBucketTimestamp: number
): number;
export function prefixDatafeedId(datafeedId: string, prefix: string): string;
export function splitIndexPatternNames(indexPatternName: string): string[];

View file

@ -15,11 +15,11 @@ import {
isJobVersionGte,
mlFunctionToESAggregation,
isJobIdValid,
ML_MEDIAN_PERCENTS,
prefixDatafeedId,
getSafeAggregationName,
getLatestDataOrBucketTimestamp,
} from './job_utils';
import { CombinedJob, Job } from '../types/anomaly_detection_jobs';
describe('ML - job utils', () => {
describe('calculateDatafeedFrequencyDefaultSeconds', () => {
@ -51,7 +51,7 @@ describe('ML - job utils', () => {
describe('isTimeSeriesViewJob', () => {
test('returns true when job has a single detector with a metric function', () => {
const job = {
const job = ({
analysis_config: {
detectors: [
{
@ -61,13 +61,13 @@ describe('ML - job utils', () => {
},
],
},
};
} as unknown) as CombinedJob;
expect(isTimeSeriesViewJob(job)).toBe(true);
});
test('returns true when job has at least one detector with a metric function', () => {
const job = {
const job = ({
analysis_config: {
detectors: [
{
@ -83,13 +83,13 @@ describe('ML - job utils', () => {
},
],
},
};
} as unknown) as CombinedJob;
expect(isTimeSeriesViewJob(job)).toBe(true);
});
test('returns false when job does not have at least one detector with a metric function', () => {
const job = {
const job = ({
analysis_config: {
detectors: [
{
@ -105,13 +105,13 @@ describe('ML - job utils', () => {
},
],
},
};
} as unknown) as CombinedJob;
expect(isTimeSeriesViewJob(job)).toBe(false);
});
test('returns false when job has a single count by category detector', () => {
const job = {
const job = ({
analysis_config: {
detectors: [
{
@ -121,14 +121,14 @@ describe('ML - job utils', () => {
},
],
},
};
} as unknown) as CombinedJob;
expect(isTimeSeriesViewJob(job)).toBe(false);
});
});
describe('isTimeSeriesViewDetector', () => {
const job = {
const job = ({
analysis_config: {
detectors: [
{
@ -168,7 +168,7 @@ describe('ML - job utils', () => {
},
},
},
};
} as unknown) as CombinedJob;
test('returns true for a detector with a metric function', () => {
expect(isTimeSeriesViewDetector(job, 0)).toBe(true);
@ -192,7 +192,7 @@ describe('ML - job utils', () => {
});
describe('isSourceDataChartableForDetector', () => {
const job = {
const job = ({
analysis_config: {
detectors: [
{ function: 'count' }, // 0
@ -251,7 +251,7 @@ describe('ML - job utils', () => {
},
},
},
};
} as unknown) as CombinedJob;
test('returns true for expected detectors', () => {
expect(isSourceDataChartableForDetector(job, 0)).toBe(true);
@ -299,13 +299,13 @@ describe('ML - job utils', () => {
});
describe('isModelPlotChartableForDetector', () => {
const job1 = {
const job1 = ({
analysis_config: {
detectors: [{ function: 'count' }],
},
};
} as unknown) as Job;
const job2 = {
const job2 = ({
analysis_config: {
detectors: [
{ function: 'count' },
@ -319,7 +319,7 @@ describe('ML - job utils', () => {
model_plot_config: {
enabled: true,
},
};
} as unknown) as Job;
test('returns false when model plot is not enabled', () => {
expect(isModelPlotChartableForDetector(job1, 0)).toBe(false);
@ -339,7 +339,7 @@ describe('ML - job utils', () => {
});
describe('getPartitioningFieldNames', () => {
const job = {
const job = ({
analysis_config: {
detectors: [
{
@ -367,7 +367,7 @@ describe('ML - job utils', () => {
},
],
},
};
} as unknown) as CombinedJob;
test('returns empty array for a detector with no partitioning fields', () => {
const resp = getPartitioningFieldNames(job, 0);
@ -392,7 +392,7 @@ describe('ML - job utils', () => {
describe('isModelPlotEnabled', () => {
test('returns true for a job in which model plot has been enabled', () => {
const job = {
const job = ({
analysis_config: {
detectors: [
{
@ -405,13 +405,13 @@ describe('ML - job utils', () => {
model_plot_config: {
enabled: true,
},
};
} as unknown) as Job;
expect(isModelPlotEnabled(job, 0)).toBe(true);
});
test('returns expected values for a job in which model plot has been enabled with terms', () => {
const job = {
const job = ({
analysis_config: {
detectors: [
{
@ -426,7 +426,7 @@ describe('ML - job utils', () => {
enabled: true,
terms: 'US,AAL',
},
};
} as unknown) as Job;
expect(
isModelPlotEnabled(job, 0, [
@ -450,7 +450,7 @@ describe('ML - job utils', () => {
});
test('returns true for jobs in which model plot has not been enabled', () => {
const job1 = {
const job1 = ({
analysis_config: {
detectors: [
{
@ -463,8 +463,8 @@ describe('ML - job utils', () => {
model_plot_config: {
enabled: false,
},
};
const job2 = {};
} as unknown) as CombinedJob;
const job2 = ({} as unknown) as CombinedJob;
expect(isModelPlotEnabled(job1, 0)).toBe(false);
expect(isModelPlotEnabled(job2, 0)).toBe(false);
@ -472,9 +472,9 @@ describe('ML - job utils', () => {
});
describe('isJobVersionGte', () => {
const job = {
const job = ({
job_version: '6.1.1',
};
} as unknown) as CombinedJob;
test('returns true for later job version', () => {
expect(isJobVersionGte(job, '6.1.0')).toBe(true);
@ -548,12 +548,6 @@ describe('ML - job utils', () => {
});
});
describe('ML_MEDIAN_PERCENTS', () => {
test("is '50.0'", () => {
expect(ML_MEDIAN_PERCENTS).toBe('50.0');
});
});
describe('prefixDatafeedId', () => {
test('returns datafeed-prefix-job from datafeed-job"', () => {
expect(prefixDatafeedId('datafeed-job', 'prefix-')).toBe('datafeed-prefix-job');

View file

@ -6,15 +6,28 @@
import _ from 'lodash';
import semver from 'semver';
// @ts-ignore
import numeral from '@elastic/numeral';
import { ALLOWED_DATA_UNITS, JOB_ID_MAX_LENGTH } from '../constants/validation';
import { parseInterval } from './parse_interval';
import { maxLengthValidator } from './validators';
import { CREATED_BY_LABEL } from '../constants/new_job';
import { CombinedJob, CustomSettings, Datafeed, JobId, Job } from '../types/anomaly_detection_jobs';
import { EntityField } from './anomaly_utils';
import { MlServerLimits } from '../types/ml_server_info';
import { JobValidationMessage, JobValidationMessageId } from '../constants/messages';
import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '../constants/aggregation_types';
export interface ValidationResults {
valid: boolean;
messages: JobValidationMessage[];
contains: (id: JobValidationMessageId) => boolean;
find: (id: JobValidationMessageId) => { id: JobValidationMessageId } | undefined;
}
// work out the default frequency based on the bucket_span in seconds
export function calculateDatafeedFrequencyDefaultSeconds(bucketSpanSeconds) {
export function calculateDatafeedFrequencyDefaultSeconds(bucketSpanSeconds: number): number {
let freq = 3600;
if (bucketSpanSeconds <= 120) {
freq = 60;
@ -29,40 +42,36 @@ export function calculateDatafeedFrequencyDefaultSeconds(bucketSpanSeconds) {
// Returns a flag to indicate whether the job is suitable for viewing
// in the Time Series dashboard.
export function isTimeSeriesViewJob(job) {
export function isTimeSeriesViewJob(job: CombinedJob): boolean {
// only allow jobs with at least one detector whose function corresponds to
// an ES aggregation which can be viewed in the single metric view and which
// doesn't use a scripted field which can be very difficult or impossible to
// invert to a reverse search, or when model plot has been enabled.
let isViewable = false;
const dtrs = job.analysis_config.detectors;
for (let i = 0; i < dtrs.length; i++) {
isViewable = isTimeSeriesViewDetector(job, i);
if (isViewable === true) {
break;
for (let i = 0; i < job.analysis_config.detectors.length; i++) {
if (isTimeSeriesViewDetector(job, i)) {
return true;
}
}
return isViewable;
return false;
}
// Returns a flag to indicate whether the detector at the index in the specified job
// is suitable for viewing in the Time Series dashboard.
export function isTimeSeriesViewDetector(job, dtrIndex) {
export function isTimeSeriesViewDetector(job: CombinedJob, detectorIndex: number): boolean {
return (
isSourceDataChartableForDetector(job, dtrIndex) ||
isModelPlotChartableForDetector(job, dtrIndex)
isSourceDataChartableForDetector(job, detectorIndex) ||
isModelPlotChartableForDetector(job, detectorIndex)
);
}
// Returns a flag to indicate whether the source data can be plotted in a time
// series chart for the specified detector.
export function isSourceDataChartableForDetector(job, detectorIndex) {
export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex: number): boolean {
let isSourceDataChartable = false;
const dtrs = job.analysis_config.detectors;
if (detectorIndex >= 0 && detectorIndex < dtrs.length) {
const dtr = dtrs[detectorIndex];
const { detectors } = job.analysis_config;
if (detectorIndex >= 0 && detectorIndex < detectors.length) {
const dtr = detectors[detectorIndex];
const functionName = dtr.function;
// Check that the function maps to an ES aggregation,
@ -79,15 +88,14 @@ export function isSourceDataChartableForDetector(job, detectorIndex) {
// If the datafeed uses script fields, we can only plot the time series if
// model plot is enabled. Without model plot it will be very difficult or impossible
// to invert to a reverse search of the underlying metric data.
const usesScriptFields = _.has(job, 'datafeed_config.script_fields');
if (isSourceDataChartable === true && usesScriptFields === true) {
if (isSourceDataChartable === true && typeof job.datafeed_config?.script_fields === 'object') {
// Perform extra check to see if the detector is using a scripted field.
const scriptFields = usesScriptFields ? _.keys(job.datafeed_config.script_fields) : [];
const scriptFields = Object.keys(job.datafeed_config.script_fields);
isSourceDataChartable =
scriptFields.indexOf(dtr.field_name) === -1 &&
scriptFields.indexOf(dtr.partition_field_name) === -1 &&
scriptFields.indexOf(dtr.by_field_name) === -1 &&
scriptFields.indexOf(dtr.over_field_name) === -1;
scriptFields.indexOf(dtr.field_name!) === -1 &&
scriptFields.indexOf(dtr.partition_field_name!) === -1 &&
scriptFields.indexOf(dtr.by_field_name!) === -1 &&
scriptFields.indexOf(dtr.over_field_name!) === -1;
}
}
@ -96,29 +104,29 @@ export function isSourceDataChartableForDetector(job, detectorIndex) {
// Returns a flag to indicate whether model plot data can be plotted in a time
// series chart for the specified detector.
export function isModelPlotChartableForDetector(job, detectorIndex) {
export function isModelPlotChartableForDetector(job: Job, detectorIndex: number): boolean {
let isModelPlotChartable = false;
const modelPlotEnabled = _.get(job, ['model_plot_config', 'enabled'], false);
const dtrs = job.analysis_config.detectors;
if (detectorIndex >= 0 && detectorIndex < dtrs.length && modelPlotEnabled === true) {
const dtr = dtrs[detectorIndex];
const functionName = dtr.function;
const modelPlotEnabled = job.model_plot_config?.enabled ?? false;
const { detectors } = job.analysis_config;
if (detectorIndex >= 0 && detectorIndex < detectors.length && modelPlotEnabled) {
const dtr = detectors[detectorIndex];
const functionName = dtr.function as ML_JOB_AGGREGATION;
// Model plot can be charted for any of the functions which map to ES aggregations
// (except rare, for which no model plot results are generated),
// plus varp and info_content functions.
isModelPlotChartable =
functionName !== 'rare' &&
functionName !== ML_JOB_AGGREGATION.RARE &&
(mlFunctionToESAggregation(functionName) !== null ||
[
'varp',
'high_varp',
'low_varp',
'info_content',
'high_info_content',
'low_info_content',
].includes(functionName) === true);
ML_JOB_AGGREGATION.VARP,
ML_JOB_AGGREGATION.HIGH_VARP,
ML_JOB_AGGREGATION.LOW_VARP,
ML_JOB_AGGREGATION.INFO_CONTENT,
ML_JOB_AGGREGATION.HIGH_INFO_CONTENT,
ML_JOB_AGGREGATION.LOW_INFO_CONTENT,
].includes(functionName));
}
return isModelPlotChartable;
@ -126,16 +134,16 @@ export function isModelPlotChartableForDetector(job, detectorIndex) {
// Returns the names of the partition, by, and over fields for the detector with the
// specified index from the supplied ML job configuration.
export function getPartitioningFieldNames(job, detectorIndex) {
const fieldNames = [];
export function getPartitioningFieldNames(job: CombinedJob, detectorIndex: number): string[] {
const fieldNames: string[] = [];
const detector = job.analysis_config.detectors[detectorIndex];
if (_.has(detector, 'partition_field_name')) {
if (typeof detector.partition_field_name === 'string') {
fieldNames.push(detector.partition_field_name);
}
if (_.has(detector, 'by_field_name')) {
if (typeof detector.by_field_name === 'string') {
fieldNames.push(detector.by_field_name);
}
if (_.has(detector, 'over_field_name')) {
if (typeof detector.over_field_name === 'string') {
fieldNames.push(detector.over_field_name);
}
@ -148,31 +156,41 @@ export function getPartitioningFieldNames(job, detectorIndex) {
// the supplied entities contains 'by' and 'partition' fields in the detector,
// if configured, whose values are in the configured model_plot_config terms,
// where entityFields is in the format [{fieldName:status, fieldValue:404}].
export function isModelPlotEnabled(job, detectorIndex, entityFields) {
export function isModelPlotEnabled(
job: Job,
detectorIndex: number,
entityFields?: EntityField[]
): boolean {
// Check if model_plot_config is enabled.
let isEnabled = _.get(job, ['model_plot_config', 'enabled'], false);
let isEnabled = job.model_plot_config?.enabled ?? false;
if (isEnabled === true && entityFields !== undefined && entityFields.length > 0) {
if (isEnabled && entityFields !== undefined && entityFields.length > 0) {
// If terms filter is configured in model_plot_config, check supplied entities.
const termsStr = _.get(job, ['model_plot_config', 'terms'], '');
const termsStr = job.model_plot_config?.terms ?? '';
if (termsStr !== '') {
// NB. Do not currently support empty string values as being valid 'by' or
// 'partition' field values even though this is supported on the back-end.
// If supplied, check both the by and partition entities are in the terms.
const detector = job.analysis_config.detectors[detectorIndex];
const detectorHasPartitionField = _.has(detector, 'partition_field_name');
const detectorHasByField = _.has(detector, 'by_field_name');
const detectorHasPartitionField = detector.hasOwnProperty('partition_field_name');
const detectorHasByField = detector.hasOwnProperty('by_field_name');
const terms = termsStr.split(',');
if (detectorHasPartitionField === true) {
const partitionEntity = _.find(entityFields, { fieldName: detector.partition_field_name });
if (detectorHasPartitionField) {
const partitionEntity = entityFields.find(
entityField => entityField.fieldName === detector.partition_field_name
);
isEnabled =
partitionEntity !== undefined && terms.indexOf(partitionEntity.fieldValue) !== -1;
partitionEntity?.fieldValue !== undefined &&
terms.indexOf(String(partitionEntity.fieldValue)) !== -1;
}
if (isEnabled === true && detectorHasByField === true) {
const byEntity = _.find(entityFields, { fieldName: detector.by_field_name });
isEnabled = byEntity !== undefined && terms.indexOf(byEntity.fieldValue) !== -1;
const byEntity = entityFields.find(
entityField => entityField.fieldName === detector.by_field_name
);
isEnabled =
byEntity?.fieldValue !== undefined && terms.indexOf(String(byEntity.fieldValue)) !== -1;
}
}
}
@ -182,8 +200,8 @@ export function isModelPlotEnabled(job, detectorIndex, entityFields) {
// Returns whether the version of the job (the version number of the elastic stack that the job was
// created with) is greater than or equal to the supplied version (e.g. '6.1.0').
export function isJobVersionGte(job, version) {
const jobVersion = _.get(job, 'job_version', '0.0.0');
export function isJobVersionGte(job: CombinedJob, version: string): boolean {
const jobVersion = job.job_version ?? '0.0.0';
return semver.gte(jobVersion, version);
}
@ -191,60 +209,62 @@ export function isJobVersionGte(job, version) {
// for querying metric data. Returns null if there is no suitable ES aggregation.
// Note that the 'function' field in a record contains what the user entered e.g. 'high_count',
// whereas the 'function_description' field holds an ML-built display hint for function e.g. 'count'.
export function mlFunctionToESAggregation(functionName) {
export function mlFunctionToESAggregation(
functionName: ML_JOB_AGGREGATION | string
): ES_AGGREGATION | null {
if (
functionName === 'mean' ||
functionName === 'high_mean' ||
functionName === 'low_mean' ||
functionName === 'metric'
functionName === ML_JOB_AGGREGATION.MEAN ||
functionName === ML_JOB_AGGREGATION.HIGH_MEAN ||
functionName === ML_JOB_AGGREGATION.LOW_MEAN ||
functionName === ML_JOB_AGGREGATION.METRIC
) {
return 'avg';
return ES_AGGREGATION.AVG;
}
if (
functionName === 'sum' ||
functionName === 'high_sum' ||
functionName === 'low_sum' ||
functionName === 'non_null_sum' ||
functionName === 'low_non_null_sum' ||
functionName === 'high_non_null_sum'
functionName === ML_JOB_AGGREGATION.SUM ||
functionName === ML_JOB_AGGREGATION.HIGH_SUM ||
functionName === ML_JOB_AGGREGATION.LOW_SUM ||
functionName === ML_JOB_AGGREGATION.NON_NULL_SUM ||
functionName === ML_JOB_AGGREGATION.LOW_NON_NULL_SUM ||
functionName === ML_JOB_AGGREGATION.HIGH_NON_NULL_SUM
) {
return 'sum';
return ES_AGGREGATION.SUM;
}
if (
functionName === 'count' ||
functionName === 'high_count' ||
functionName === 'low_count' ||
functionName === 'non_zero_count' ||
functionName === 'low_non_zero_count' ||
functionName === 'high_non_zero_count'
functionName === ML_JOB_AGGREGATION.COUNT ||
functionName === ML_JOB_AGGREGATION.HIGH_COUNT ||
functionName === ML_JOB_AGGREGATION.LOW_COUNT ||
functionName === ML_JOB_AGGREGATION.NON_ZERO_COUNT ||
functionName === ML_JOB_AGGREGATION.LOW_NON_ZERO_COUNT ||
functionName === ML_JOB_AGGREGATION.HIGH_NON_ZERO_COUNT
) {
return 'count';
return ES_AGGREGATION.COUNT;
}
if (
functionName === 'distinct_count' ||
functionName === 'low_distinct_count' ||
functionName === 'high_distinct_count'
functionName === ML_JOB_AGGREGATION.DISTINCT_COUNT ||
functionName === ML_JOB_AGGREGATION.LOW_DISTINCT_COUNT ||
functionName === ML_JOB_AGGREGATION.HIGH_DISTINCT_COUNT
) {
return 'cardinality';
return ES_AGGREGATION.CARDINALITY;
}
if (
functionName === 'median' ||
functionName === 'high_median' ||
functionName === 'low_median'
functionName === ML_JOB_AGGREGATION.MEDIAN ||
functionName === ML_JOB_AGGREGATION.HIGH_MEDIAN ||
functionName === ML_JOB_AGGREGATION.LOW_MEDIAN
) {
return 'percentiles';
return ES_AGGREGATION.PERCENTILES;
}
if (functionName === 'min' || functionName === 'max') {
return functionName;
if (functionName === ML_JOB_AGGREGATION.MIN || functionName === ML_JOB_AGGREGATION.MAX) {
return (functionName as unknown) as ES_AGGREGATION;
}
if (functionName === 'rare') {
return 'count';
if (functionName === ML_JOB_AGGREGATION.RARE) {
return ES_AGGREGATION.COUNT;
}
// Return null if ML function does not map to an ES aggregation.
@ -256,7 +276,7 @@ export function mlFunctionToESAggregation(functionName) {
// Job name must contain lowercase alphanumeric (a-z and 0-9), hyphens or underscores;
// it must also start and end with an alphanumeric character'
export function isJobIdValid(jobId) {
export function isJobIdValid(jobId: JobId): boolean {
return /^[a-z0-9\-\_]+$/g.test(jobId) && !/^([_-].*)?(.*[_-])?$/g.test(jobId);
}
@ -270,7 +290,7 @@ export const ML_MEDIAN_PERCENTS = '50.0';
export const ML_DATA_PREVIEW_COUNT = 10;
// add a prefix to a datafeed id before the "datafeed-" part of the name
export function prefixDatafeedId(datafeedId, prefix) {
export function prefixDatafeedId(datafeedId: string, prefix: string): string {
return datafeedId.match(/^datafeed-/)
? datafeedId.replace(/^datafeed-/, `datafeed-${prefix}`)
: `datafeed-${prefix}${datafeedId}`;
@ -280,13 +300,13 @@ export function prefixDatafeedId(datafeedId, prefix) {
// field name. Aggregation names must be alpha-numeric and can only contain '_' and '-' characters,
// so if the supplied field names contains disallowed characters, the provided index
// identifier is used to return a safe 'dummy' name in the format 'field_index' e.g. field_0, field_1
export function getSafeAggregationName(fieldName, index) {
export function getSafeAggregationName(fieldName: string, index: number): string {
return fieldName.match(/^[a-zA-Z0-9-_.]+$/) ? fieldName : `field_${index}`;
}
export function uniqWithIsEqual(arr) {
export function uniqWithIsEqual<T extends any[]>(arr: T): T {
return arr.reduce((dedupedArray, value) => {
if (dedupedArray.filter(compareValue => _.isEqual(compareValue, value)).length === 0) {
if (dedupedArray.filter((compareValue: any) => _.isEqual(compareValue, value)).length === 0) {
dedupedArray.push(value);
}
return dedupedArray;
@ -296,8 +316,13 @@ export function uniqWithIsEqual(arr) {
// check job without manipulating UI and return a list of messages
// job and fields get passed as arguments and are not accessed as $scope.* via the outer scope
// because the plan is to move this function to the common code area so that it can be used on the server side too.
export function basicJobValidation(job, fields, limits, skipMmlChecks = false) {
const messages = [];
export function basicJobValidation(
job: Job,
fields: object | undefined,
limits: MlServerLimits,
skipMmlChecks = false
): ValidationResults {
const messages: ValidationResults['messages'] = [];
let valid = true;
if (job) {
@ -459,8 +484,8 @@ export function basicJobValidation(job, fields, limits, skipMmlChecks = false) {
};
}
export function basicDatafeedValidation(datafeed) {
const messages = [];
export function basicDatafeedValidation(datafeed: Datafeed): ValidationResults {
const messages: ValidationResults['messages'] = [];
let valid = true;
if (datafeed) {
@ -487,8 +512,8 @@ export function basicDatafeedValidation(datafeed) {
};
}
export function validateModelMemoryLimit(job, limits) {
const messages = [];
export function validateModelMemoryLimit(job: Job, limits: MlServerLimits): ValidationResults {
const messages: ValidationResults['messages'] = [];
let valid = true;
// model memory limit
if (
@ -499,7 +524,9 @@ export function validateModelMemoryLimit(job, limits) {
const max = limits.max_model_memory_limit.toUpperCase();
const mml = job.analysis_limits.model_memory_limit.toUpperCase();
// @ts-ignore
const mmlBytes = numeral(mml).value();
// @ts-ignore
const maxBytes = numeral(max).value();
if (mmlBytes > maxBytes) {
@ -518,8 +545,10 @@ export function validateModelMemoryLimit(job, limits) {
};
}
export function validateModelMemoryLimitUnits(modelMemoryLimit) {
const messages = [];
export function validateModelMemoryLimitUnits(
modelMemoryLimit: string | undefined
): ValidationResults {
const messages: ValidationResults['messages'] = [];
let valid = true;
if (modelMemoryLimit !== undefined) {
@ -527,13 +556,14 @@ export function validateModelMemoryLimitUnits(modelMemoryLimit) {
const mmlSplit = mml.match(/\d+(\w+)$/);
const unit = mmlSplit && mmlSplit.length === 2 ? mmlSplit[1] : null;
if (ALLOWED_DATA_UNITS.indexOf(unit) === -1) {
if (unit === null || ALLOWED_DATA_UNITS.indexOf(unit) === -1) {
messages.push({ id: 'model_memory_limit_units_invalid' });
valid = false;
} else {
messages.push({ id: 'model_memory_limit_units_valid' });
}
}
return {
valid,
messages,
@ -542,9 +572,9 @@ export function validateModelMemoryLimitUnits(modelMemoryLimit) {
};
}
export function validateGroupNames(job) {
export function validateGroupNames(job: Job): ValidationResults {
const { groups = [] } = job;
const errorMessages = [
const errorMessages: ValidationResults['messages'] = [
...(groups.some(group => !isJobIdValid(group)) ? [{ id: 'job_group_id_invalid' }] : []),
...(groups.some(group => maxLengthValidator(JOB_ID_MAX_LENGTH)(group))
? [{ id: 'job_group_id_invalid_max_length' }]
@ -561,18 +591,21 @@ export function validateGroupNames(job) {
};
}
function isValidTimeFormat(value) {
function isValidTimeFormat(value: string | undefined): boolean {
if (value === undefined) {
return true;
}
const interval = parseInterval(value, false);
const interval = parseInterval(value);
return interval !== null && interval.asMilliseconds() !== 0;
}
// Returns the latest of the last source data and last processed bucket timestamp,
// as used for example in setting the end time of results views for cases where
// anomalies might have been raised after the point at which data ingest has stopped.
export function getLatestDataOrBucketTimestamp(latestDataTimestamp, latestBucketTimestamp) {
export function getLatestDataOrBucketTimestamp(
latestDataTimestamp: number | undefined,
latestBucketTimestamp: number | undefined
): number | undefined {
if (latestDataTimestamp !== undefined && latestBucketTimestamp !== undefined) {
return Math.max(latestDataTimestamp, latestBucketTimestamp);
} else {
@ -585,13 +618,13 @@ export function getLatestDataOrBucketTimestamp(latestDataTimestamp, latestBucket
* it was created by a job wizard as the rules cannot currently be edited
* in the job wizards and so would be lost in a clone.
*/
export function processCreatedBy(customSettings) {
if (Object.values(CREATED_BY_LABEL).includes(customSettings.created_by)) {
export function processCreatedBy(customSettings: CustomSettings) {
if (Object.values(CREATED_BY_LABEL).includes(customSettings.created_by!)) {
delete customSettings.created_by;
}
}
export function splitIndexPatternNames(indexPatternName) {
export function splitIndexPatternNames(indexPatternName: string): string[] {
return indexPatternName.includes(',')
? indexPatternName.split(',').map(i => i.trim())
: [indexPatternName];

View file

@ -12,8 +12,9 @@ import {
JOB_ID_MAX_LENGTH,
} from '../../../../../../common/constants/validation';
import { getNewJobLimits } from '../../../../services/ml_server_info';
import { ValidationResults, ValidationMessage } from '../../../../../../common/util/job_utils';
import { ValidationResults } from '../../../../../../common/util/job_utils';
import { ExistingJobsAndGroups } from '../../../../services/job_service';
import { JobValidationMessage } from '../../../../../../common/constants/messages';
export function populateValidationMessages(
validationResults: ValidationResults,
@ -176,7 +177,7 @@ export function checkForExistingJobAndGroupIds(
groupIds: string[],
existingJobsAndGroups: ExistingJobsAndGroups
): ValidationResults {
const messages: ValidationMessage[] = [];
const messages: JobValidationMessage[] = [];
// check that job id does not already exist as a job or group or a newly created group
if (

View file

@ -18,11 +18,12 @@ import { buildConfigFromDetector } from '../util/chart_config_builder';
import { mlResultsService } from '../services/results_service';
import { ModelPlotOutput } from '../services/results_service/result_service_rx';
import { Job } from '../../../common/types/anomaly_detection_jobs';
import { EntityField } from '../..';
function getMetricData(
job: Job,
detectorIndex: number,
entityFields: object[],
entityFields: EntityField[],
earliestMs: number,
latestMs: number,
interval: string

View file

@ -155,7 +155,7 @@ function getSearchJsonFromConfig(
json.body.query = query;
const aggs: Record<number, Record<string, { field: string; percents?: number[] }>> = {};
const aggs: Record<number, Record<string, { field: string; percents?: string[] }>> = {};
aggFieldNamePairs.forEach(({ agg, field }, i) => {
if (field !== null && field !== EVENT_RATE_FIELD_ID) {

View file

@ -1,24 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { APICaller } from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
import { DeepPartial } from '../../../common/types/common';
import { validateJobSchema } from '../../routes/schemas/job_validation_schema';
import { ValidationMessage } from './messages';
export type ValidateJobPayload = TypeOf<typeof validateJobSchema>;
export function validateJob(
callAsCurrentUser: APICaller,
payload?: DeepPartial<ValidateJobPayload>,
kbnVersion?: string,
callAsInternalUser?: APICaller,
isSecurityDisabled?: boolean
): Promise<ValidationMessage[]>;

View file

@ -6,7 +6,8 @@
import { APICaller } from 'kibana/server';
import { validateJob } from './job_validation';
import { validateJob, ValidateJobPayload } from './job_validation';
import { JobValidationMessage } from '../../../common/constants/messages';
// mock callWithRequest
const callWithRequest: APICaller = (method: string) => {
@ -30,48 +31,10 @@ const callWithRequest: APICaller = (method: string) => {
// so we can simulate possible runtime payloads
// that don't satisfy the TypeScript specs.
describe('ML - validateJob', () => {
it('calling factory without payload throws an error', done => {
validateJob(callWithRequest).then(
() => done(new Error('Promise should not resolve for this test without payload.')),
() => done()
);
});
it('calling factory with incomplete payload throws an error', done => {
const payload = {};
validateJob(callWithRequest, payload).then(
() => done(new Error('Promise should not resolve for this test with incomplete payload.')),
() => done()
);
});
it('throws an error because job.analysis_config is not an object', done => {
const payload = { job: {} };
validateJob(callWithRequest, payload).then(
() =>
done(
new Error(
'Promise should not resolve for this test with job.analysis_config not being an object.'
)
),
() => done()
);
});
it('throws an error because job.analysis_config.detectors is not an Array', done => {
const payload = { job: { analysis_config: {} } };
validateJob(callWithRequest, payload).then(
() =>
done(new Error('Promise should not resolve for this test when detectors is not an Array.')),
() => done()
);
});
it('basic validation messages', () => {
const payload = { job: { analysis_config: { detectors: [] } } };
const payload = ({
job: { analysis_config: { detectors: [] } },
} as unknown) as ValidateJobPayload;
return validateJob(callWithRequest, payload).then(messages => {
const ids = messages.map(m => m.id);
@ -87,12 +50,12 @@ describe('ML - validateJob', () => {
const jobIdTests = (testIds: string[], messageId: string) => {
const promises = testIds.map(id => {
const payload = {
const payload = ({
job: {
analysis_config: { detectors: [] },
job_id: id,
},
};
} as unknown) as ValidateJobPayload;
return validateJob(callWithRequest, payload).catch(() => {
new Error('Promise should not fail for jobIdTests.');
});
@ -110,7 +73,9 @@ describe('ML - validateJob', () => {
};
const jobGroupIdTest = (testIds: string[], messageId: string) => {
const payload = { job: { analysis_config: { detectors: [] }, groups: testIds } };
const payload = ({
job: { analysis_config: { detectors: [] }, groups: testIds },
} as unknown) as ValidateJobPayload;
return validateJob(callWithRequest, payload).then(messages => {
const ids = messages.map(m => m.id);
@ -149,7 +114,9 @@ describe('ML - validateJob', () => {
const bucketSpanFormatTests = (testFormats: string[], messageId: string) => {
const promises = testFormats.map(format => {
const payload = { job: { analysis_config: { bucket_span: format, detectors: [] } } };
const payload = ({
job: { analysis_config: { bucket_span: format, detectors: [] } },
} as unknown) as ValidateJobPayload;
return validateJob(callWithRequest, payload).catch(() => {
new Error('Promise should not fail for bucketSpanFormatTests.');
});
@ -175,7 +142,9 @@ describe('ML - validateJob', () => {
});
it('at least one detector function is empty', () => {
const payload = { job: { analysis_config: { detectors: [] as Array<{ function?: string }> } } };
const payload = ({
job: { analysis_config: { detectors: [] as Array<{ function?: string }> } },
} as unknown) as ValidateJobPayload;
payload.job.analysis_config.detectors.push({
function: 'count',
});
@ -183,6 +152,7 @@ describe('ML - validateJob', () => {
function: '',
});
payload.job.analysis_config.detectors.push({
// @ts-ignore
function: undefined,
});
@ -193,7 +163,9 @@ describe('ML - validateJob', () => {
});
it('detector function is not empty', () => {
const payload = { job: { analysis_config: { detectors: [] as Array<{ function?: string }> } } };
const payload = ({
job: { analysis_config: { detectors: [] as Array<{ function?: string }> } },
} as unknown) as ValidateJobPayload;
payload.job.analysis_config.detectors.push({
function: 'count',
});
@ -205,10 +177,10 @@ describe('ML - validateJob', () => {
});
it('invalid index fields', () => {
const payload = {
const payload = ({
job: { analysis_config: { detectors: [] } },
fields: {},
};
} as unknown) as ValidateJobPayload;
return validateJob(callWithRequest, payload).then(messages => {
const ids = messages.map(m => m.id);
@ -217,10 +189,10 @@ describe('ML - validateJob', () => {
});
it('valid index fields', () => {
const payload = {
const payload = ({
job: { analysis_config: { detectors: [] } },
fields: { testField: {} },
};
} as unknown) as ValidateJobPayload;
return validateJob(callWithRequest, payload).then(messages => {
const ids = messages.map(m => m.id);
@ -429,15 +401,19 @@ describe('ML - validateJob', () => {
docsTestPayload.job.analysis_config.detectors = [{ function: 'count', by_field_name: 'airline' }];
it('creates a docs url pointing to the current docs version', () => {
return validateJob(callWithRequest, docsTestPayload).then(messages => {
const message = messages[messages.findIndex(m => m.id === 'field_not_aggregatable')];
expect(message.url.search('/current/')).not.toBe(-1);
const message = messages[
messages.findIndex(m => m.id === 'field_not_aggregatable')
] as JobValidationMessage;
expect(message.url!.search('/current/')).not.toBe(-1);
});
});
it('creates a docs url pointing to the master docs version', () => {
return validateJob(callWithRequest, docsTestPayload, 'master').then(messages => {
const message = messages[messages.findIndex(m => m.id === 'field_not_aggregatable')];
expect(message.url.search('/master/')).not.toBe(-1);
const message = messages[
messages.findIndex(m => m.id === 'field_not_aggregatable')
] as JobValidationMessage;
expect(message.url!.search('/master/')).not.toBe(-1);
});
});
});

View file

@ -6,67 +6,48 @@
import { i18n } from '@kbn/i18n';
import Boom from 'boom';
import { APICaller } from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
import { fieldsServiceProvider } from '../fields_service';
import { renderTemplate } from '../../../common/util/string_utils';
import { getMessages } from './messages';
import {
getMessages,
MessageId,
JobValidationMessageDef,
} from '../../../common/constants/messages';
import { VALIDATION_STATUS } from '../../../common/constants/validation';
import { basicJobValidation, uniqWithIsEqual } from '../../../common/util/job_utils';
// @ts-ignore
import { validateBucketSpan } from './validate_bucket_span';
import { validateCardinality } from './validate_cardinality';
import { validateInfluencers } from './validate_influencers';
import { validateModelMemoryLimit } from './validate_model_memory_limit';
import { validateTimeRange, isValidTimeField } from './validate_time_range';
import { validateJobSchema } from '../../routes/schemas/job_validation_schema';
import { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
export type ValidateJobPayload = TypeOf<typeof validateJobSchema>;
/**
* Validates the job configuration after
* @kbn/config-schema has checked the payload {@link validateJobSchema}.
*/
export async function validateJob(
callWithRequest,
payload,
callWithRequest: APICaller,
payload: ValidateJobPayload,
kbnVersion = 'current',
callAsInternalUser,
isSecurityDisabled
callAsInternalUser?: APICaller,
isSecurityDisabled?: boolean
) {
const messages = getMessages();
try {
if (typeof payload !== 'object' || payload === null) {
throw new Error(
i18n.translate('xpack.ml.models.jobValidation.payloadIsNotObjectErrorMessage', {
defaultMessage: 'Invalid {invalidParamName}: Needs to be an object.',
values: { invalidParamName: 'payload' },
})
);
}
const { fields, job } = payload;
const { fields } = payload;
let { duration } = payload;
if (typeof job !== 'object') {
throw new Error(
i18n.translate('xpack.ml.models.jobValidation.jobIsNotObjectErrorMessage', {
defaultMessage: 'Invalid {invalidParamName}: Needs to be an object.',
values: { invalidParamName: 'job' },
})
);
}
if (typeof job.analysis_config !== 'object') {
throw new Error(
i18n.translate('xpack.ml.models.jobValidation.analysisConfigIsNotObjectErrorMessage', {
defaultMessage: 'Invalid {invalidParamName}: Needs to be an object.',
values: { invalidParamName: 'job.analysis_config' },
})
);
}
if (!Array.isArray(job.analysis_config.detectors)) {
throw new Error(
i18n.translate('xpack.ml.models.jobValidation.detectorsAreNotArrayErrorMessage', {
defaultMessage: 'Invalid {invalidParamName}: Needs to be an array.',
values: { invalidParamName: 'job.analysis_config.detectors' },
})
);
}
const job = payload.job as CombinedJob;
// check if basic tests pass the requirements to run the extended tests.
// if so, run the extended tests and merge the messages.
@ -103,7 +84,7 @@ export async function validateJob(
const cardinalityMessages = await validateCardinality(callWithRequest, job);
validationMessages.push(...cardinalityMessages);
const cardinalityError = cardinalityMessages.some(m => {
return VALIDATION_STATUS[messages[m.id].status] === VALIDATION_STATUS.ERROR;
return messages[m.id as MessageId].status === VALIDATION_STATUS.ERROR;
});
validationMessages.push(
@ -131,27 +112,29 @@ export async function validateJob(
}
return uniqWithIsEqual(validationMessages).map(message => {
if (typeof messages[message.id] !== 'undefined') {
const messageId = message.id as MessageId;
const messageDef = messages[messageId] as JobValidationMessageDef;
if (typeof messageDef !== 'undefined') {
// render the message template with the provided metadata
if (typeof messages[message.id].heading !== 'undefined') {
message.heading = renderTemplate(messages[message.id].heading, message);
if (typeof messageDef.heading !== 'undefined') {
message.heading = renderTemplate(messageDef.heading, message);
}
message.text = renderTemplate(messages[message.id].text, message);
message.text = renderTemplate(messageDef.text, message);
// check if the error message provides a link with further information
// if so, add it to the message to be returned with it
if (typeof messages[message.id].url !== 'undefined') {
if (typeof messageDef.url !== 'undefined') {
// the link is also treated as a template so we're able to dynamically link to
// documentation links matching the running version of Kibana.
message.url = renderTemplate(messages[message.id].url, { version: kbnVersion });
message.url = renderTemplate(messageDef.url, { version: kbnVersion! });
}
message.status = VALIDATION_STATUS[messages[message.id].status];
message.status = messageDef.status;
} else {
message.text = i18n.translate(
'xpack.ml.models.jobValidation.unknownMessageIdErrorMessage',
{
defaultMessage: '{messageId} (unknown message id)',
values: { messageId: message.id },
values: { messageId },
}
);
}

View file

@ -1,10 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface ValidationMessage {
id: string;
url: string;
}

View file

@ -65,7 +65,7 @@ export async function validateBucketSpan(
}
const messages = [];
const parsedBucketSpan = parseInterval(job.analysis_config.bucket_span, false);
const parsedBucketSpan = parseInterval(job.analysis_config.bucket_span);
if (parsedBucketSpan === null || parsedBucketSpan.asMilliseconds() === 0) {
messages.push({ id: 'bucket_span_invalid' });
return messages;

View file

@ -6,7 +6,7 @@
import { SKIP_BUCKET_SPAN_ESTIMATION } from '../../../common/constants/validation';
import { ValidationMessage } from './messages';
import { JobValidationMessage } from '../../../common/constants/messages';
// @ts-ignore
import { validateBucketSpan } from './validate_bucket_span';
@ -88,7 +88,7 @@ describe('ML - validateBucketSpan', () => {
};
return validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse), job).then(
(messages: ValidationMessage[]) => {
(messages: JobValidationMessage[]) => {
const ids = messages.map(m => m.id);
expect(ids).toStrictEqual([]);
}
@ -113,7 +113,7 @@ describe('ML - validateBucketSpan', () => {
callWithRequestFactory(mockFareQuoteSearchResponse),
job,
duration
).then((messages: ValidationMessage[]) => {
).then((messages: JobValidationMessage[]) => {
const ids = messages.map(m => m.id);
expect(ids).toStrictEqual(['success_bucket_span']);
});
@ -127,7 +127,7 @@ describe('ML - validateBucketSpan', () => {
callWithRequestFactory(mockFareQuoteSearchResponse),
job,
duration
).then((messages: ValidationMessage[]) => {
).then((messages: JobValidationMessage[]) => {
const ids = messages.map(m => m.id);
expect(ids).toStrictEqual(['bucket_span_high']);
});
@ -148,7 +148,7 @@ describe('ML - validateBucketSpan', () => {
});
return validateBucketSpan(callWithRequestFactory(mockSearchResponse), job, {}).then(
(messages: ValidationMessage[]) => {
(messages: JobValidationMessage[]) => {
const ids = messages.map(m => m.id);
test(ids);
}

View file

@ -10,6 +10,7 @@ import { DataVisualizer } from '../data_visualizer';
import { validateJobObject } from './validate_job_object';
import { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
import { Detector } from '../../../common/types/anomaly_detection_jobs';
import { MessageId, JobValidationMessage } from '../../../common/constants/messages';
function isValidCategorizationConfig(job: CombinedJob, fieldName: string): boolean {
return (
@ -31,12 +32,12 @@ const PARTITION_FIELD_CARDINALITY_THRESHOLD = 1000;
const BY_FIELD_CARDINALITY_THRESHOLD = 1000;
const MODEL_PLOT_THRESHOLD_HIGH = 100;
type Messages = Array<{ id: string; fieldName?: string }>;
export type Messages = JobValidationMessage[];
type Validator = (obj: {
type: string;
isInvalid: (cardinality: number) => boolean;
messageId?: string;
messageId?: MessageId;
}) => Promise<{
modelPlotCardinality: number;
messages: Messages;
@ -105,7 +106,7 @@ const validateFactory = (callWithRequest: APICaller, job: CombinedJob): Validato
if (isInvalid(field.stats.cardinality!)) {
messages.push({
id: messageId || `cardinality_${type}_field`,
id: messageId || (`cardinality_${type}_field` as MessageId),
fieldName: uniqueFieldName,
});
}
@ -149,8 +150,8 @@ const validateFactory = (callWithRequest: APICaller, job: CombinedJob): Validato
export async function validateCardinality(
callWithRequest: APICaller,
job?: CombinedJob
): Promise<Array<{ id: string; modelPlotCardinality?: number; fieldName?: string }>> | never {
const messages = [];
): Promise<Messages> | never {
const messages: Messages = [];
if (!validateJobObject(job)) {
// required for TS type casting, validateJobObject throws an error internally.

View file

@ -47,7 +47,7 @@ export async function isValidTimeField(callAsCurrentUser: APICaller, job: Combin
export async function validateTimeRange(
callAsCurrentUser: APICaller,
job: CombinedJob,
timeRange?: TimeRange
timeRange?: Partial<TimeRange>
) {
const messages: ValidateTimeRangeMessage[] = [];

View file

@ -29,10 +29,12 @@ export const modelMemoryLimitSchema = schema.object({
});
export const validateJobSchema = schema.object({
duration: schema.object({
start: schema.maybe(schema.number()),
end: schema.maybe(schema.number()),
}),
duration: schema.maybe(
schema.object({
start: schema.maybe(schema.number()),
end: schema.maybe(schema.number()),
})
),
fields: schema.maybe(schema.any()),
job: schema.object(anomalyDetectionJobSchema),
});

View file

@ -9999,9 +9999,6 @@
"xpack.ml.models.jobService.deletingJob": "削除中",
"xpack.ml.models.jobService.jobHasNoDatafeedErrorMessage": "ジョブにデータフィードがありません",
"xpack.ml.models.jobService.requestToActionTimedOutErrorMessage": "「{id}」を{action}するリクエストがタイムアウトしました。{extra}",
"xpack.ml.models.jobValidation.analysisConfigIsNotObjectErrorMessage": "無効な {invalidParamName}:オブジェクトでなければなりません。",
"xpack.ml.models.jobValidation.detectorsAreNotArrayErrorMessage": "無効な {invalidParamName}:配列でなければなりません。",
"xpack.ml.models.jobValidation.jobIsNotObjectErrorMessage": "無効な {invalidParamName}:オブジェクトでなければなりません。",
"xpack.ml.models.jobValidation.messages.bucketSpanEmptyMessage": "バケットスパンフィールドを指定する必要があります。",
"xpack.ml.models.jobValidation.messages.bucketSpanEstimationMismatchHeading": "バケットスパン",
"xpack.ml.models.jobValidation.messages.bucketSpanEstimationMismatchMessage": "現在のバケットスパンは {currentBucketSpan} ですが、バケットスパンの予測からは {estimateBucketSpan} が返されました。",
@ -10059,7 +10056,6 @@
"xpack.ml.models.jobValidation.messages.timeRangeBeforeEpochMessage": "選択された、または利用可能な時間範囲には、UNIX 時間の開始以前のタイムスタンプのデータが含まれています。01/01/1970 00:00:00 (UTC) よりも前のタイムスタンプは機械学習ジョブでサポートされていません。",
"xpack.ml.models.jobValidation.messages.timeRangeShortHeading": "時間範囲",
"xpack.ml.models.jobValidation.messages.timeRangeShortMessage": "選択された、または利用可能な時間範囲が短すぎます。推奨最低時間範囲は {minTimeSpanReadable} で、バケットスパンの {bucketSpanCompareFactor} 倍です。",
"xpack.ml.models.jobValidation.payloadIsNotObjectErrorMessage": "無効な {invalidParamName}:オブジェクトでなければなりません。",
"xpack.ml.models.jobValidation.unknownMessageIdErrorMessage": "{messageId} (不明なメッセージ ID)",
"xpack.ml.models.jobValidation.validateJobObject.analysisConfigIsNotObjectErrorMessage": "無効な {invalidParamName}:オブジェクトでなければなりません。",
"xpack.ml.models.jobValidation.validateJobObject.dataDescriptionIsNotObjectErrorMessage": "無効な {invalidParamName}:オブジェクトでなければなりません。",

View file

@ -10005,9 +10005,6 @@
"xpack.ml.models.jobService.deletingJob": "正在删除",
"xpack.ml.models.jobService.jobHasNoDatafeedErrorMessage": "作业没有数据馈送",
"xpack.ml.models.jobService.requestToActionTimedOutErrorMessage": "对 {action} “{id}” 的请求超时。{extra}",
"xpack.ml.models.jobValidation.analysisConfigIsNotObjectErrorMessage": "无效的 {invalidParamName}:需要是对象。",
"xpack.ml.models.jobValidation.detectorsAreNotArrayErrorMessage": "无效的 {invalidParamName}:需要是数组。",
"xpack.ml.models.jobValidation.jobIsNotObjectErrorMessage": "无效的 {invalidParamName}:需要是对象。",
"xpack.ml.models.jobValidation.messages.bucketSpanEmptyMessage": "必须指定存储桶跨度字段。",
"xpack.ml.models.jobValidation.messages.bucketSpanEstimationMismatchHeading": "存储桶跨度",
"xpack.ml.models.jobValidation.messages.bucketSpanEstimationMismatchMessage": "当前存储桶跨度为 {currentBucketSpan},但存储桶跨度估计返回 {estimateBucketSpan}。",
@ -10065,7 +10062,6 @@
"xpack.ml.models.jobValidation.messages.timeRangeBeforeEpochMessage": "选定或可用时间范围包含时间戳在 UNIX epoch 开始之前的数据。Machine Learning 作业不支持在 01/01/1970 00:00:00 (UTC) 之前的时间戳。",
"xpack.ml.models.jobValidation.messages.timeRangeShortHeading": "时间范围",
"xpack.ml.models.jobValidation.messages.timeRangeShortMessage": "选定或可用时间范围可能过短。建议的最小时间范围应至少为 {minTimeSpanReadable} 且是存储桶跨度的 {bucketSpanCompareFactor} 倍。",
"xpack.ml.models.jobValidation.payloadIsNotObjectErrorMessage": "无效的 {invalidParamName}:需要是对象。",
"xpack.ml.models.jobValidation.unknownMessageIdErrorMessage": "{messageId}(未知消息 ID",
"xpack.ml.models.jobValidation.validateJobObject.analysisConfigIsNotObjectErrorMessage": "无效的 {invalidParamName}:需要是对象。",
"xpack.ml.models.jobValidation.validateJobObject.dataDescriptionIsNotObjectErrorMessage": "无效的 {invalidParamName}:需要是对象。",

View file

@ -145,7 +145,7 @@ export default ({ getService }: FtrProviderContext) => {
expect(body.count).to.eql(2);
expect(body.jobs.length).to.eql(2);
expect(body.jobs[0].job_id).to.eql(`${jobId}_1`);
expect(body.jobs[0]).to.keys(
expect(body.jobs[0]).to.have.keys(
'timing_stats',
'state',
'forecasts_stats',
@ -178,7 +178,7 @@ export default ({ getService }: FtrProviderContext) => {
expect(body.count).to.eql(1);
expect(body.jobs.length).to.eql(1);
expect(body.jobs[0].job_id).to.eql(`${jobId}_1`);
expect(body.jobs[0]).to.keys(
expect(body.jobs[0]).to.have.keys(
'timing_stats',
'state',
'forecasts_stats',
@ -197,7 +197,7 @@ export default ({ getService }: FtrProviderContext) => {
expect(body.count).to.eql(2);
expect(body.jobs.length).to.eql(2);
expect(body.jobs[0].job_id).to.eql(`${jobId}_1`);
expect(body.jobs[0]).to.keys(
expect(body.jobs[0]).to.have.keys(
'timing_stats',
'state',
'forecasts_stats',

View file

@ -10,5 +10,6 @@ export default function({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./bucket_span_estimator'));
loadTestFile(require.resolve('./calculate_model_memory_limit'));
loadTestFile(require.resolve('./cardinality'));
loadTestFile(require.resolve('./validate'));
});
}

View file

@ -0,0 +1,391 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { USER } from '../../../../functional/services/machine_learning/security_common';
const COMMON_HEADERS = {
'kbn-xsrf': 'some-xsrf-token',
};
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const esArchiver = getService('esArchiver');
const supertest = getService('supertestWithoutAuth');
const ml = getService('ml');
describe('Validate job', function() {
before(async () => {
await esArchiver.loadIfNeeded('ml/ecommerce');
await ml.testResources.setKibanaTimeZoneToUTC();
});
after(async () => {
await ml.api.cleanMlIndices();
});
it(`should recognize a valid job configuration`, async () => {
const requestBody = {
duration: { start: 1586995459000, end: 1589672736000 },
job: {
job_id: 'test',
description: '',
groups: [],
analysis_config: {
bucket_span: '15m',
detectors: [{ function: 'mean', field_name: 'products.discount_amount' }],
influencers: [],
summary_count_field_name: 'doc_count',
},
data_description: { time_field: 'order_date' },
analysis_limits: { model_memory_limit: '11MB' },
model_plot_config: { enabled: true },
datafeed_config: {
datafeed_id: 'datafeed-test',
job_id: 'test',
indices: ['ft_ecommerce'],
query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } },
aggregations: {
buckets: {
date_histogram: { field: 'order_date', fixed_interval: '90000ms' },
aggregations: {
'products.discount_amount': { avg: { field: 'products.discount_amount' } },
order_date: { max: { field: 'order_date' } },
},
},
},
},
},
};
const { body } = await supertest
.post('/api/ml/validate/job')
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
.set(COMMON_HEADERS)
.send(requestBody)
.expect(200);
expect(body).to.eql([
{
id: 'job_id_valid',
heading: 'Job ID format is valid',
text:
'Lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores, starts and ends with an alphanumeric character, and is no more than 64 characters long.',
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/master/ml-job-resource.html#ml-job-resource',
status: 'success',
},
{
id: 'detectors_function_not_empty',
heading: 'Detector functions',
text: 'Presence of detector functions validated in all detectors.',
url: 'https://www.elastic.co/guide/en/machine-learning/master/create-jobs.html#detectors',
status: 'success',
},
{
id: 'success_bucket_span',
bucketSpan: '15m',
heading: 'Bucket span',
text: 'Format of "15m" is valid and passed validation checks.',
url:
'https://www.elastic.co/guide/en/machine-learning/master/create-jobs.html#bucket-span',
status: 'success',
},
{
id: 'success_time_range',
heading: 'Time range',
text: 'Valid and long enough to model patterns in the data.',
status: 'success',
},
{
id: 'success_mml',
heading: 'Model memory limit',
text: 'Valid and within the estimated model memory limit.',
url:
'https://www.elastic.co/guide/en/machine-learning/master/create-jobs.html#model-memory-limits',
status: 'success',
},
]);
});
it('should recognize a basic invalid job configuration and skip advanced checks', async () => {
const requestBody = {
duration: { start: 1586995459000, end: 1589672736000 },
job: {
job_id: '-(*&^',
description: '',
groups: [],
analysis_config: {
bucket_span: '15m',
detectors: [{ function: 'mean', field_name: 'products.discount_amount' }],
influencers: [],
summary_count_field_name: 'doc_count',
},
data_description: { time_field: 'order_date' },
analysis_limits: { model_memory_limit: '11MB' },
model_plot_config: { enabled: true },
datafeed_config: {
datafeed_id: 'datafeed-test',
job_id: 'test',
indices: ['ft_ecommerce'],
query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } },
aggregations: {
buckets: {
date_histogram: { field: 'order_date', fixed_interval: '90000ms' },
aggregations: {
'products.discount_amount': { avg: { field: 'products.discount_amount' } },
order_date: { max: { field: 'order_date' } },
},
},
},
},
},
};
const { body } = await supertest
.post('/api/ml/validate/job')
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
.set(COMMON_HEADERS)
.send(requestBody)
.expect(200);
expect(body).to.eql([
{
id: 'job_id_invalid',
text:
'Job ID is invalid. It can contain lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores and must start and end with an alphanumeric character.',
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/master/ml-job-resource.html#ml-job-resource',
status: 'error',
},
{
id: 'detectors_function_not_empty',
heading: 'Detector functions',
text: 'Presence of detector functions validated in all detectors.',
url: 'https://www.elastic.co/guide/en/machine-learning/master/create-jobs.html#detectors',
status: 'success',
},
{
id: 'bucket_span_valid',
bucketSpan: '15m',
heading: 'Bucket span',
text: 'Format of "15m" is valid.',
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/master/ml-job-resource.html#ml-analysisconfig',
status: 'success',
},
{
id: 'skipped_extended_tests',
text:
'Skipped additional checks because the basic requirements of the job configuration were not met.',
status: 'warning',
},
]);
});
it('should recognize non-basic issues in job configuration', async () => {
const requestBody = {
duration: { start: 1586995459000, end: 1589672736000 },
job: {
job_id: 'test',
description: '',
groups: [],
analysis_config: {
bucket_span: '1000000m',
detectors: [
{
function: 'mean',
field_name: 'products.base_price',
// some high cardinality field
partition_field_name: 'order_id',
},
],
influencers: ['order_id'],
},
data_description: { time_field: 'order_date' },
analysis_limits: { model_memory_limit: '1MB' },
model_plot_config: { enabled: true },
datafeed_config: {
datafeed_id: 'datafeed-test',
job_id: 'test',
indices: ['ft_ecommerce'],
query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } },
aggregations: {
buckets: {
date_histogram: { field: 'order_date', fixed_interval: '90000ms' },
aggregations: {
'products.discount_amount': { avg: { field: 'products.discount_amount' } },
order_date: { max: { field: 'order_date' } },
},
},
},
},
},
};
const { body } = await supertest
.post('/api/ml/validate/job')
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
.set(COMMON_HEADERS)
.send(requestBody)
.expect(200);
expect(body).to.eql([
{
id: 'job_id_valid',
heading: 'Job ID format is valid',
text:
'Lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores, starts and ends with an alphanumeric character, and is no more than 64 characters long.',
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/master/ml-job-resource.html#ml-job-resource',
status: 'success',
},
{
id: 'detectors_function_not_empty',
heading: 'Detector functions',
text: 'Presence of detector functions validated in all detectors.',
url: 'https://www.elastic.co/guide/en/machine-learning/master/create-jobs.html#detectors',
status: 'success',
},
{
id: 'cardinality_model_plot_high',
modelPlotCardinality: 4711,
text:
'The estimated cardinality of 4711 of fields relevant to creating model plots might result in resource intensive jobs.',
status: 'warning',
},
{
id: 'cardinality_partition_field',
fieldName: 'order_id',
text:
'Cardinality of partition_field "order_id" is above 1000 and might result in high memory usage.',
url:
'https://www.elastic.co/guide/en/machine-learning/master/create-jobs.html#cardinality',
status: 'warning',
},
{
id: 'bucket_span_high',
heading: 'Bucket span',
text:
'Bucket span is 1 day or more. Be aware that days are considered as UTC days, not local days.',
url:
'https://www.elastic.co/guide/en/machine-learning/master/create-jobs.html#bucket-span',
status: 'info',
},
{
bucketSpanCompareFactor: 25,
id: 'time_range_short',
minTimeSpanReadable: '2 hours',
heading: 'Time range',
text:
'The selected or available time range might be too short. The recommended minimum time range should be at least 2 hours and 25 times the bucket span.',
status: 'warning',
},
{
id: 'success_influencers',
text: 'Influencer configuration passed the validation checks.',
url: 'https://www.elastic.co/guide/en/machine-learning/master/ml-influencers.html',
status: 'success',
},
{
id: 'half_estimated_mml_greater_than_mml',
mml: '1MB',
text:
'The specified model memory limit is less than half of the estimated model memory limit and will likely hit the hard limit.',
url:
'https://www.elastic.co/guide/en/machine-learning/master/create-jobs.html#model-memory-limits',
status: 'warning',
},
]);
});
it('should not validate configuration in case request payload is invalid', async () => {
const requestBody = {
duration: { start: 1586995459000, end: 1589672736000 },
job: {
job_id: 'test',
description: '',
groups: [],
// missing analysis_config
data_description: { time_field: 'order_date' },
analysis_limits: { model_memory_limit: '11MB' },
model_plot_config: { enabled: true },
datafeed_config: {
datafeed_id: 'datafeed-test',
job_id: 'test',
indices: ['ft_ecommerce'],
query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } },
aggregations: {
buckets: {
date_histogram: { field: 'order_date', fixed_interval: '90000ms' },
aggregations: {
'products.discount_amount': { avg: { field: 'products.discount_amount' } },
order_date: { max: { field: 'order_date' } },
},
},
},
},
},
};
const { body } = await supertest
.post('/api/ml/validate/job')
.auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER))
.set(COMMON_HEADERS)
.send(requestBody)
.expect(400);
expect(body.error).to.eql('Bad Request');
expect(body.message).to.eql(
'[request body.job.analysis_config.detectors]: expected value of type [array] but got [undefined]'
);
});
it('should not validate if the user does not have required permissions', async () => {
const requestBody = {
job: {
job_id: 'test',
description: '',
groups: [],
analysis_config: {
bucket_span: '15m',
detectors: [{ function: 'mean', field_name: 'products.discount_amount' }],
influencers: [],
summary_count_field_name: 'doc_count',
},
data_description: { time_field: 'order_date' },
analysis_limits: { model_memory_limit: '11MB' },
model_plot_config: { enabled: true },
datafeed_config: {
datafeed_id: 'datafeed-test',
job_id: 'test',
indices: ['ft_ecommerce'],
query: { bool: { must: [{ match_all: {} }], filter: [], must_not: [] } },
aggregations: {
buckets: {
date_histogram: { field: 'order_date', fixed_interval: '90000ms' },
aggregations: {
'products.discount_amount': { avg: { field: 'products.discount_amount' } },
order_date: { max: { field: 'order_date' } },
},
},
},
},
},
};
const { body } = await supertest
.post('/api/ml/validate/job')
.auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER))
.set(COMMON_HEADERS)
.send(requestBody)
.expect(404);
expect(body.error).to.eql('Not Found');
expect(body.message).to.eql('Not Found');
});
});
};