[ML] Anomaly Detection: Migrate validation messages links to use docLinks. (#94568)

- To make use of the docsLinks service which is only usable in client side code, Anomaly Detection's validation messages are not fully returned from the server anymore. Instead just the message ID and necessary metadata to parse the message template gets returned.
- getMessages() no longer uses inline hard coded documentation links but picks links from the docsLinks service.
- The code that rendered the messages originally on the server has been move to a function parseMessages() which can now be used on the client side and accepts the docsLinks services to get URLs to documentation from it.
- This means we no longer need to get the current version/branch information for the server side code.
- Tests have been updated to reflect the changes: API integration tests only check for the now reduced messages containing only message IDs and metadata. The expected results of the API integration tests are used as mocks for the client side function parseMessages(), this allows use to cover the same code and messages as previously.
This commit is contained in:
Walter Rafelsberger 2021-03-17 07:53:46 +01:00 committed by GitHub
parent bbee40c819
commit 83c6bcc554
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 363 additions and 279 deletions

View file

@ -161,7 +161,16 @@ export class DocLinksService {
aggregations: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-aggregation.html`,
anomalyDetection: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/xpack-ml.html`,
anomalyDetectionJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-jobs.html`,
anomalyDetectionConfiguringCategories: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-categories.html`,
anomalyDetectionBucketSpan: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#bucket-span`,
anomalyDetectionCardinality: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#cardinality`,
anomalyDetectionCreateJobs: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html`,
anomalyDetectionDetectors: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#detectors`,
anomalyDetectionInfluencers: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-influencers.html`,
anomalyDetectionJobResource: `${ELASTICSEARCH_DOCS}ml-put-job.html#ml-put-job-path-parms`,
anomalyDetectionJobResourceAnalysisConfig: `${ELASTICSEARCH_DOCS}ml-put-job.html#put-analysisconfig`,
anomalyDetectionJobTips: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#job-tips`,
anomalyDetectionModelMemoryLimits: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#model-memory-limits`,
calendars: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-calendars.html`,
classificationEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-classification`,
customRules: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-rules.html`,

View file

@ -0,0 +1,81 @@
/*
* 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.
*/
/* To keep tests in sync, these mocks should be used in API intregation tests
* as expected values to check against, and in the client side jest tests to be
* the values used as function arguments for `parseMessages()` to retrieve the
* messages populated with translations and documentation links.
*/
export const basicValidJobMessages = [
{
id: 'job_id_valid',
},
{
id: 'detectors_function_not_empty',
},
{
id: 'success_bucket_span',
bucketSpan: '15m',
},
{
id: 'success_time_range',
},
{
id: 'success_mml',
},
];
export const basicInvalidJobMessages = [
{
id: 'job_id_invalid',
},
{
id: 'detectors_function_not_empty',
},
{
id: 'bucket_span_valid',
bucketSpan: '15m',
},
{
id: 'skipped_extended_tests',
},
];
export const nonBasicIssuesMessages = [
{
id: 'job_id_valid',
},
{
id: 'detectors_function_not_empty',
},
{
id: 'cardinality_model_plot_high',
},
{
id: 'cardinality_partition_field',
fieldName: 'order_id',
},
{
id: 'bucket_span_high',
},
{
bucketSpanCompareFactor: 25,
id: 'time_range_short',
minTimeSpanReadable: '2 hours',
},
{
id: 'success_influencers',
},
{
id: 'half_estimated_mml_greater_than_mml',
mml: '1MB',
},
{
id: 'missing_summary_count_field_name',
},
];

View file

@ -0,0 +1,178 @@
/*
* 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 { docLinksServiceMock } from 'src/core/public/mocks';
import { parseMessages } from './messages';
import {
basicValidJobMessages,
basicInvalidJobMessages,
nonBasicIssuesMessages,
} from './messages.test.mock';
describe('Constants: Messages parseMessages()', () => {
const docLinksService = docLinksServiceMock.createStartContract();
it('should parse valid job configuration messages', () => {
expect(parseMessages(basicValidJobMessages, docLinksService)).toStrictEqual([
{
heading: 'Job ID format is valid',
id: 'job_id_valid',
status: 'success',
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/mocked-test-branch/ml-put-job.html#ml-put-job-path-parms',
},
{
heading: 'Detector functions',
id: 'detectors_function_not_empty',
status: 'success',
text: 'Presence of detector functions validated in all detectors.',
url:
'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#detectors',
},
{
bucketSpan: '15m',
heading: 'Bucket span',
id: 'success_bucket_span',
status: 'success',
text: 'Format of "15m" is valid and passed validation checks.',
url:
'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#bucket-span',
},
{
heading: 'Time range',
id: 'success_time_range',
status: 'success',
text: 'Valid and long enough to model patterns in the data.',
},
{
heading: 'Model memory limit',
id: 'success_mml',
status: 'success',
text: 'Valid and within the estimated model memory limit.',
url:
'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#model-memory-limits',
},
]);
});
it('should parse basic invalid job configuration messages', () => {
expect(parseMessages(basicInvalidJobMessages, docLinksService)).toStrictEqual([
{
id: 'job_id_invalid',
status: 'error',
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/mocked-test-branch/ml-put-job.html#ml-put-job-path-parms',
},
{
heading: 'Detector functions',
id: 'detectors_function_not_empty',
status: 'success',
text: 'Presence of detector functions validated in all detectors.',
url:
'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#detectors',
},
{
bucketSpan: '15m',
heading: 'Bucket span',
id: 'bucket_span_valid',
status: 'success',
text: 'Format of "15m" is valid.',
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/ml-put-job.html#put-analysisconfig',
},
{
id: 'skipped_extended_tests',
status: 'warning',
text:
'Skipped additional checks because the basic requirements of the job configuration were not met.',
},
]);
});
it('should parse non-basic issues messages', () => {
expect(parseMessages(nonBasicIssuesMessages, docLinksService)).toStrictEqual([
{
heading: 'Job ID format is valid',
id: 'job_id_valid',
status: 'success',
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/mocked-test-branch/ml-put-job.html#ml-put-job-path-parms',
},
{
heading: 'Detector functions',
id: 'detectors_function_not_empty',
status: 'success',
text: 'Presence of detector functions validated in all detectors.',
url:
'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/create-jobs.html#detectors',
},
{
id: 'cardinality_model_plot_high',
status: 'warning',
text:
'The estimated cardinality of undefined of fields relevant to creating model plots might result in resource intensive jobs.',
},
{
fieldName: 'order_id',
id: 'cardinality_partition_field',
status: 'warning',
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/mocked-test-branch/create-jobs.html#cardinality',
},
{
heading: 'Bucket span',
id: 'bucket_span_high',
status: 'info',
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/mocked-test-branch/create-jobs.html#bucket-span',
},
{
bucketSpanCompareFactor: 25,
heading: 'Time range',
id: 'time_range_short',
minTimeSpanReadable: '2 hours',
status: 'warning',
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.',
},
{
id: 'success_influencers',
status: 'success',
text: 'Influencer configuration passed the validation checks.',
url:
'https://www.elastic.co/guide/en/machine-learning/mocked-test-branch/ml-influencers.html',
},
{
id: 'half_estimated_mml_greater_than_mml',
mml: '1MB',
status: 'warning',
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/mocked-test-branch/create-jobs.html#model-memory-limits',
},
{
id: 'missing_summary_count_field_name',
status: 'error',
text:
'A job configured with a datafeed with aggregations must set summary_count_field_name; use doc_count or suitable alternative.',
},
]);
});
});

View file

@ -7,8 +7,13 @@
import { once } from 'lodash';
import { i18n } from '@kbn/i18n';
import type { DocLinksStart } from 'kibana/public';
import { JOB_ID_MAX_LENGTH, VALIDATION_STATUS } from './validation';
import { renderTemplate } from '../util/string_utils';
export type MessageId = keyof ReturnType<typeof getMessages>;
export interface JobValidationMessageDef {
@ -40,9 +45,9 @@ export type JobValidationMessage = {
[key: string]: any;
};
export const getMessages = once(() => {
const createJobsDocsUrl = `https://www.elastic.co/guide/en/machine-learning/{{version}}/create-jobs.html`;
// This is still consumed by a legacy class based React component.
// Once we migrate that component to use hooks, we may replace `once()` with `useMemo()`.
export const getMessages = once((docLinks?: DocLinksStart) => {
return {
categorizer_detector_missing_per_partition_field: {
status: VALIDATION_STATUS.ERROR,
@ -53,8 +58,7 @@ export const getMessages = once(() => {
'Partition field must be set for detectors that reference "mlcategory" when per-partition categorization is enabled.',
}
),
url:
'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-categories.html',
url: docLinks?.links.ml.anomalyDetectionConfiguringCategories,
},
categorizer_varying_per_partition_fields: {
status: VALIDATION_STATUS.ERROR,
@ -69,8 +73,7 @@ export const getMessages = once(() => {
},
}
),
url:
'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-categories.html',
url: docLinks?.links.ml.anomalyDetectionConfiguringCategories,
},
field_not_aggregatable: {
status: VALIDATION_STATUS.ERROR,
@ -80,16 +83,14 @@ export const getMessages = once(() => {
fieldName: '"{{fieldName}}"',
},
}),
url:
'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-aggregation.html',
url: docLinks?.links.ml.aggregrations,
},
fields_not_aggregatable: {
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.fieldsNotAggregatableMessage', {
defaultMessage: 'One of the detector fields is not an aggregatable field.',
}),
url:
'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-aggregation.html',
url: docLinks?.links.ml.aggregrations,
},
cardinality_no_results: {
status: VALIDATION_STATUS.WARNING,
@ -120,8 +121,7 @@ export const getMessages = once(() => {
},
}
),
url:
'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-aggregation.html',
url: docLinks?.links.ml.aggregrations,
},
cardinality_by_field: {
status: VALIDATION_STATUS.WARNING,
@ -132,7 +132,7 @@ export const getMessages = once(() => {
fieldName: 'by_field "{{fieldName}}"',
},
}),
url: `${createJobsDocsUrl}#cardinality`,
url: docLinks?.links.ml.anomalyDetectionCardinality,
},
cardinality_over_field_low: {
status: VALIDATION_STATUS.WARNING,
@ -146,7 +146,7 @@ export const getMessages = once(() => {
},
}
),
url: `${createJobsDocsUrl}#cardinality`,
url: docLinks?.links.ml.anomalyDetectionCardinality,
},
cardinality_over_field_high: {
status: VALIDATION_STATUS.WARNING,
@ -160,7 +160,7 @@ export const getMessages = once(() => {
},
}
),
url: `${createJobsDocsUrl}#cardinality`,
url: docLinks?.links.ml.anomalyDetectionCardinality,
},
cardinality_partition_field: {
status: VALIDATION_STATUS.WARNING,
@ -174,7 +174,7 @@ export const getMessages = once(() => {
},
}
),
url: `${createJobsDocsUrl}#cardinality`,
url: docLinks?.links.ml.anomalyDetectionCardinality,
},
cardinality_model_plot_high: {
status: VALIDATION_STATUS.WARNING,
@ -198,8 +198,7 @@ export const getMessages = once(() => {
defaultMessage: 'Categorization filters checks passed.',
}
),
url:
'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-configuring-categories.html',
url: docLinks?.links.ml.anomalyDetectionConfiguringCategories,
},
categorization_filters_invalid: {
status: VALIDATION_STATUS.ERROR,
@ -214,16 +213,14 @@ export const getMessages = once(() => {
},
}
),
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig',
url: docLinks?.links.ml.anomalyDetectionJobResourceAnalysisConfig,
},
bucket_span_empty: {
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.bucketSpanEmptyMessage', {
defaultMessage: 'The bucket span field must be specified.',
}),
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig',
url: docLinks?.links.ml.anomalyDetectionJobResourceAnalysisConfig,
},
bucket_span_estimation_mismatch: {
status: VALIDATION_STATUS.INFO,
@ -244,7 +241,7 @@ export const getMessages = once(() => {
},
}
),
url: `${createJobsDocsUrl}#bucket-span`,
url: docLinks?.links.ml.anomalyDetectionBucketSpan,
},
bucket_span_high: {
status: VALIDATION_STATUS.INFO,
@ -255,7 +252,7 @@ export const getMessages = once(() => {
defaultMessage:
'Bucket span is 1 day or more. Be aware that days are considered as UTC days, not local days.',
}),
url: `${createJobsDocsUrl}#bucket-span`,
url: docLinks?.links.ml.anomalyDetectionBucketSpan,
},
bucket_span_valid: {
status: VALIDATION_STATUS.SUCCESS,
@ -268,8 +265,7 @@ export const getMessages = once(() => {
bucketSpan: '"{{bucketSpan}}"',
},
}),
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig',
url: docLinks?.links.ml.anomalyDetectionJobResourceAnalysisConfig,
},
bucket_span_invalid: {
status: VALIDATION_STATUS.ERROR,
@ -280,8 +276,7 @@ export const getMessages = once(() => {
defaultMessage:
'The specified bucket span is not a valid time interval format e.g. 10m, 1h. It also needs to be higher than zero.',
}),
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-analysisconfig',
url: docLinks?.links.ml.anomalyDetectionJobResourceAnalysisConfig,
},
detectors_duplicates: {
status: VALIDATION_STATUS.ERROR,
@ -298,21 +293,21 @@ export const getMessages = once(() => {
partitionFieldNameParam: `'partition_field_name'`,
},
}),
url: `${createJobsDocsUrl}#detectors`,
url: docLinks?.links.ml.anomalyDetectionDetectors,
},
detectors_empty: {
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`,
url: docLinks?.links.ml.anomalyDetectionDetectors,
},
detectors_function_empty: {
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.detectorsFunctionEmptyMessage', {
defaultMessage: 'One of the detector functions is empty.',
}),
url: `${createJobsDocsUrl}#detectors`,
url: docLinks?.links.ml.anomalyDetectionDetectors,
},
detectors_function_not_empty: {
status: VALIDATION_STATUS.SUCCESS,
@ -328,7 +323,7 @@ export const getMessages = once(() => {
defaultMessage: 'Presence of detector functions validated in all detectors.',
}
),
url: `${createJobsDocsUrl}#detectors`,
url: docLinks?.links.ml.anomalyDetectionDetectors,
},
index_fields_invalid: {
status: VALIDATION_STATUS.ERROR,
@ -349,7 +344,7 @@ export const getMessages = once(() => {
'The job configuration includes more than 3 influencers. ' +
'Consider using fewer influencers or creating multiple jobs.',
}),
url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html',
url: docLinks?.links.ml.anomalyDetectionInfluencers,
},
influencer_low: {
status: VALIDATION_STATUS.WARNING,
@ -357,7 +352,7 @@ export const getMessages = once(() => {
defaultMessage:
'No influencers have been configured. Picking an influencer is strongly recommended.',
}),
url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html',
url: docLinks?.links.ml.anomalyDetectionInfluencers,
},
influencer_low_suggestion: {
status: VALIDATION_STATUS.WARNING,
@ -369,7 +364,7 @@ export const getMessages = once(() => {
values: { influencerSuggestion: '{{influencerSuggestion}}' },
}
),
url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html',
url: docLinks?.links.ml.anomalyDetectionInfluencers,
},
influencer_low_suggestions: {
status: VALIDATION_STATUS.WARNING,
@ -381,15 +376,14 @@ export const getMessages = once(() => {
values: { influencerSuggestion: '{{influencerSuggestion}}' },
}
),
url: 'https://www.elastic.co/guide/en/machine-learning/{{version}}/ml-influencers.html',
url: docLinks?.links.ml.anomalyDetectionInfluencers,
},
job_id_empty: {
status: VALIDATION_STATUS.ERROR,
text: i18n.translate('xpack.ml.models.jobValidation.messages.jobIdEmptyMessage', {
defaultMessage: 'Job ID field must not be empty.',
}),
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
url: docLinks?.links.ml.anomalyDetectionJobResource,
},
job_id_invalid: {
status: VALIDATION_STATUS.ERROR,
@ -398,8 +392,7 @@ export const getMessages = once(() => {
'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/{{version}}/ml-job-resource.html#ml-job-resource',
url: docLinks?.links.ml.anomalyDetectionJobResource,
},
job_id_invalid_max_length: {
status: VALIDATION_STATUS.ERROR,
@ -413,8 +406,7 @@ export const getMessages = once(() => {
},
}
),
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
url: docLinks?.links.ml.anomalyDetectionJobResource,
},
job_id_valid: {
status: VALIDATION_STATUS.SUCCESS,
@ -430,8 +422,7 @@ export const getMessages = once(() => {
maxLength: JOB_ID_MAX_LENGTH,
},
}),
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
url: docLinks?.links.ml.anomalyDetectionJobResource,
},
job_group_id_invalid: {
status: VALIDATION_STATUS.ERROR,
@ -440,8 +431,7 @@ export const getMessages = once(() => {
'One of the job group names is invalid. They 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/{{version}}/ml-job-resource.html#ml-job-resource',
url: docLinks?.links.ml.anomalyDetectionJobResource,
},
job_group_id_invalid_max_length: {
status: VALIDATION_STATUS.ERROR,
@ -455,8 +445,7 @@ export const getMessages = once(() => {
},
}
),
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
url: docLinks?.links.ml.anomalyDetectionJobResource,
},
job_group_id_valid: {
status: VALIDATION_STATUS.SUCCESS,
@ -472,8 +461,7 @@ export const getMessages = once(() => {
maxLength: JOB_ID_MAX_LENGTH,
},
}),
url:
'https://www.elastic.co/guide/en/elasticsearch/reference/{{version}}/ml-job-resource.html#ml-job-resource',
url: docLinks?.links.ml.anomalyDetectionJobResource,
},
missing_summary_count_field_name: {
status: VALIDATION_STATUS.ERROR,
@ -500,7 +488,7 @@ export const getMessages = once(() => {
text: i18n.translate('xpack.ml.models.jobValidation.messages.successCardinalityMessage', {
defaultMessage: 'Cardinality of detector fields is within recommended bounds.',
}),
url: `${createJobsDocsUrl}#cardinality`,
url: docLinks?.links.ml.anomalyDetectionCardinality,
},
success_bucket_span: {
status: VALIDATION_STATUS.SUCCESS,
@ -511,14 +499,14 @@ export const getMessages = once(() => {
defaultMessage: 'Format of {bucketSpan} is valid and passed validation checks.',
values: { bucketSpan: '"{{bucketSpan}}"' },
}),
url: `${createJobsDocsUrl}#bucket-span`,
url: docLinks?.links.ml.anomalyDetectionBucketSpan,
},
success_influencers: {
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',
url: docLinks?.links.ml.anomalyDetectionInfluencers,
},
estimated_mml_greater_than_max_mml: {
status: VALIDATION_STATUS.WARNING,
@ -556,7 +544,7 @@ export const getMessages = once(() => {
'1MB and should be specified in bytes e.g. 10MB.',
values: { mml: '{{mml}}' },
}),
url: `${createJobsDocsUrl}#model-memory-limits`,
url: docLinks?.links.ml.anomalyDetectionModelMemoryLimits,
},
half_estimated_mml_greater_than_mml: {
status: VALIDATION_STATUS.WARNING,
@ -568,7 +556,7 @@ export const getMessages = once(() => {
'memory limit and will likely hit the hard limit.',
}
),
url: `${createJobsDocsUrl}#model-memory-limits`,
url: docLinks?.links.ml.anomalyDetectionModelMemoryLimits,
},
estimated_mml_greater_than_mml: {
status: VALIDATION_STATUS.INFO,
@ -579,7 +567,7 @@ export const getMessages = once(() => {
'The estimated model memory limit is greater than the model memory limit you have configured.',
}
),
url: `${createJobsDocsUrl}#model-memory-limits`,
url: docLinks?.links.ml.anomalyDetectionModelMemoryLimits,
},
success_mml: {
status: VALIDATION_STATUS.SUCCESS,
@ -589,7 +577,7 @@ export const getMessages = once(() => {
text: i18n.translate('xpack.ml.models.jobValidation.messages.successMmlMessage', {
defaultMessage: 'Valid and within the estimated model memory limit.',
}),
url: `${createJobsDocsUrl}#model-memory-limits`,
url: docLinks?.links.ml.anomalyDetectionModelMemoryLimits,
},
success_time_range: {
status: VALIDATION_STATUS.SUCCESS,
@ -640,3 +628,36 @@ export const getMessages = once(() => {
},
};
});
export const parseMessages = (
validationMessages: JobValidationMessage[],
docLinks: DocLinksStart
) => {
const messages = getMessages(docLinks);
return validationMessages.map((message) => {
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 messageDef.heading !== 'undefined') {
message.heading = renderTemplate(messageDef.heading, 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 messageDef.url !== 'undefined') {
message.url = messageDef.url;
}
message.status = messageDef.status;
} else {
message.text = i18n.translate('xpack.ml.models.jobValidation.unknownMessageIdErrorMessage', {
defaultMessage: '{messageId} (unknown message id)',
values: { messageId },
});
}
return message;
});
};

View file

@ -28,6 +28,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { getDocLinks } from '../../util/dependency_cache';
import { parseMessages } from '../../../../common/constants/messages';
import { VALIDATION_STATUS } from '../../../../common/constants/validation';
import { Callout, statusToEuiIconType } from '../callout';
import { getMostSevereMessageStatus } from '../../../../common/util/validation_utils';
@ -132,7 +133,8 @@ export class ValidateJobUI extends Component {
this.props.ml
.validateJob({ duration, fields, job })
.then((messages) => {
.then((validationMessages) => {
const messages = parseMessages(validationMessages, getDocLinks());
shouldShowLoadingIndicator = false;
const messagesContainError = messages.some((m) => m.status === VALIDATION_STATUS.ERROR);

View file

@ -8,7 +8,6 @@
import { IScopedClusterClient } from 'kibana/server';
import { validateJob, ValidateJobPayload } from './job_validation';
import { JobValidationMessage } from '../../../common/constants/messages';
import { HITS_TOTAL_RELATION } from '../../../common/types/es_client';
import type { MlClient } from '../../lib/ml_client';
@ -277,7 +276,6 @@ describe('ML - validateJob', () => {
});
});
// Failing https://github.com/elastic/kibana/issues/65865
it('basic validation passes, extended checks return some messages', () => {
const payload = getBasicPayload();
return validateJob(mlClusterClient, mlClient, payload).then((messages) => {
@ -291,7 +289,6 @@ describe('ML - validateJob', () => {
});
});
// Failing https://github.com/elastic/kibana/issues/65866
it('categorization job using mlcategory passes aggregatable field check', () => {
const payload: any = {
job: {
@ -358,7 +355,6 @@ describe('ML - validateJob', () => {
});
});
// Failing https://github.com/elastic/kibana/issues/65867
it('script field not reported as non aggregatable', () => {
const payload: any = {
job: {
@ -401,27 +397,4 @@ describe('ML - validateJob', () => {
]);
});
});
// the following two tests validate the correct template rendering of
// urls in messages with {{version}} in them to be replaced with the
// specified version. (defaulting to 'current')
const docsTestPayload = getBasicPayload() as any;
docsTestPayload.job.analysis_config.detectors = [{ function: 'count', by_field_name: 'airline' }];
it('creates a docs url pointing to the current docs version', () => {
return validateJob(mlClusterClient, mlClient, docsTestPayload).then((messages) => {
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(mlClusterClient, mlClient, docsTestPayload, 'master').then((messages) => {
const message = messages[
messages.findIndex((m) => m.id === 'field_not_aggregatable')
] as JobValidationMessage;
expect(message.url!.search('/master/')).not.toBe(-1);
});
});
});

View file

@ -5,17 +5,11 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import Boom from '@hapi/boom';
import { IScopedClusterClient } from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
import { fieldsServiceProvider } from '../fields_service';
import { renderTemplate } from '../../../common/util/string_utils';
import {
getMessages,
MessageId,
JobValidationMessageDef,
} from '../../../common/constants/messages';
import { getMessages, MessageId, JobValidationMessage } from '../../../common/constants/messages';
import { VALIDATION_STATUS } from '../../../common/constants/validation';
import { basicJobValidation, uniqWithIsEqual } from '../../../common/util/job_utils';
@ -40,7 +34,6 @@ export async function validateJob(
client: IScopedClusterClient,
mlClient: MlClient,
payload: ValidateJobPayload,
kbnVersion = 'current',
isSecurityDisabled?: boolean
) {
const messages = getMessages();
@ -55,7 +48,7 @@ export async function validateJob(
// if so, run the extended tests and merge the messages.
// otherwise just return the basic test messages.
const basicValidation = basicJobValidation(job, fields, {}, true);
let validationMessages;
let validationMessages: JobValidationMessage[];
if (basicValidation.valid === true) {
// remove basic success messages from tests
@ -113,36 +106,7 @@ export async function validateJob(
validationMessages.push({ id: 'skipped_extended_tests' });
}
return uniqWithIsEqual(validationMessages).map((message) => {
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 messageDef.heading !== 'undefined') {
message.heading = renderTemplate(messageDef.heading, 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 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(messageDef.url, { version: kbnVersion! });
}
message.status = messageDef.status;
} else {
message.text = i18n.translate(
'xpack.ml.models.jobValidation.unknownMessageIdErrorMessage',
{
defaultMessage: '{messageId} (unknown message id)',
values: { messageId },
}
);
}
return message;
});
return uniqWithIsEqual(validationMessages);
} catch (error) {
throw Boom.badRequest(error);
}

View file

@ -67,7 +67,6 @@ export type MlPluginStart = void;
export class MlServerPlugin
implements Plugin<MlPluginSetup, MlPluginStart, PluginsSetup, PluginsStart> {
private log: Logger;
private version: string;
private mlLicense: MlLicense;
private capabilities: CapabilitiesStart | null = null;
private clusterClient: IClusterClient | null = null;
@ -79,7 +78,6 @@ export class MlServerPlugin
constructor(ctx: PluginInitializerContext) {
this.log = ctx.logger.get();
this.version = ctx.env.packageInfo.branch;
this.mlLicense = new MlLicense();
this.isMlReady = new Promise((resolve) => (this.setMlReady = resolve));
}
@ -182,7 +180,7 @@ export class MlServerPlugin
jobServiceRoutes(routeInit);
notificationRoutes(routeInit);
resultsServiceRoutes(routeInit);
jobValidationRoutes(routeInit, this.version);
jobValidationRoutes(routeInit);
savedObjectsRoutes(routeInit, {
getSpaces,
resolveMlCapabilities,

View file

@ -27,10 +27,7 @@ type CalculateModelMemoryLimitPayload = TypeOf<typeof modelMemoryLimitSchema>;
/**
* Routes for job validation
*/
export function jobValidationRoutes(
{ router, mlLicense, routeGuard }: RouteInitialization,
version: string
) {
export function jobValidationRoutes({ router, mlLicense, routeGuard }: RouteInitialization) {
function calculateModelMemoryLimit(
client: IScopedClusterClient,
mlClient: MlClient,
@ -191,12 +188,10 @@ export function jobValidationRoutes(
},
routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => {
try {
// version corresponds to the version used in documentation links.
const resp = await validateJob(
client,
mlClient,
request.body,
version,
mlLicense.isSecurityEnabled() === false
);

View file

@ -6,18 +6,20 @@
*/
import expect from '@kbn/expect';
import {
basicValidJobMessages,
basicInvalidJobMessages,
nonBasicIssuesMessages,
} from '../../../../../../x-pack/plugins/ml/common/constants/messages.test.mock';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { USER } from '../../../../functional/services/ml/security_common';
import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api';
import pkg from '../../../../../../package.json';
export default ({ getService }: FtrProviderContext) => {
const esArchiver = getService('esArchiver');
const supertest = getService('supertestWithoutAuth');
const ml = getService('ml');
const VALIDATED_SEPARATELY = 'this value is not validated directly';
describe('Validate job', function () {
before(async () => {
await esArchiver.loadIfNeeded('ml/ecommerce');
@ -75,44 +77,7 @@ export default ({ getService }: FtrProviderContext) => {
.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/${pkg.branch}/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/${pkg.branch}/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/${pkg.branch}/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/${pkg.branch}/create-jobs.html#model-memory-limits`,
status: 'success',
},
]);
expect(body).to.eql(basicValidJobMessages);
});
it('should recognize a basic invalid job configuration and skip advanced checks', async () => {
@ -156,36 +121,7 @@ export default ({ getService }: FtrProviderContext) => {
.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/${pkg.branch}/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/${pkg.branch}/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/${pkg.branch}/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',
},
]);
expect(body).to.eql(basicInvalidJobMessages);
});
it('should recognize non-basic issues in job configuration', async () => {
@ -244,74 +180,7 @@ export default ({ getService }: FtrProviderContext) => {
}
});
const expectedResponse = [
{
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/${pkg.branch}/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/${pkg.branch}/create-jobs.html#detectors`,
status: 'success',
},
{
id: 'cardinality_model_plot_high',
modelPlotCardinality: VALIDATED_SEPARATELY,
text: VALIDATED_SEPARATELY,
status: VALIDATED_SEPARATELY,
},
{
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/${pkg.branch}/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/${pkg.branch}/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/${pkg.branch}/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/${pkg.branch}/create-jobs.html#model-memory-limits`,
status: 'warning',
},
{
id: 'missing_summary_count_field_name',
status: 'error',
text:
'A job configured with a datafeed with aggregations must set summary_count_field_name; use doc_count or suitable alternative.',
},
];
const expectedResponse = nonBasicIssuesMessages;
expect(body.length).to.eql(
expectedResponse.length,
@ -327,12 +196,6 @@ export default ({ getService }: FtrProviderContext) => {
if (entry.id === 'cardinality_model_plot_high') {
// don't check the exact value of modelPlotCardinality as this is an approximation
expect(responseEntry).to.have.property('modelPlotCardinality');
expect(responseEntry)
.to.have.property('text')
.match(
/^The estimated cardinality of [0-9]+ of fields relevant to creating model plots might result in resource intensive jobs./
);
expect(responseEntry).to.have.property('status', 'warning');
} else {
expect(responseEntry).to.eql(entry);
}