({
htmlIdGenerator: () => () => 'mockId',
@@ -31,24 +31,25 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
const { kibana, observability, security, management } = DEFAULT_APP_CATEGORIES;
-function mockLink(label: string, category?: AppCategory) {
+function mockLink({ label = 'discover', category, onClick }: Partial) {
return {
key: label,
label,
href: label,
isActive: true,
- onClick: () => {},
+ onClick: onClick || (() => {}),
category,
'data-test-subj': label,
};
}
-function mockRecentNavLink(label: string) {
+function mockRecentNavLink({ label = 'recent', onClick }: Partial) {
return {
href: label,
label,
title: label,
'aria-label': label,
+ onClick,
};
}
@@ -67,6 +68,20 @@ function mockProps() {
};
}
+function expectShownNavLinksCount(component: ReactWrapper, count: number) {
+ expect(
+ component.find('.euiAccordion-isOpen a[data-test-subj^="collapsibleNavAppLink"]').length
+ ).toEqual(count);
+}
+
+function expectNavIsClosed(component: ReactWrapper) {
+ expectShownNavLinksCount(component, 0);
+}
+
+function clickGroup(component: ReactWrapper, group: string) {
+ component.find(`[data-test-subj="collapsibleNavGroup-${group}"] button`).simulate('click');
+}
+
describe('CollapsibleNav', () => {
// this test is mostly an "EUI works as expected" sanity check
it('renders the default nav', () => {
@@ -88,16 +103,19 @@ describe('CollapsibleNav', () => {
it('renders links grouped by category', () => {
// just a test of category functionality, categories are not accurate
const navLinks = [
- mockLink('discover', kibana),
- mockLink('siem', security),
- mockLink('metrics', observability),
- mockLink('monitoring', management),
- mockLink('visualize', kibana),
- mockLink('dashboard', kibana),
- mockLink('canvas'), // links should be able to be rendered top level as well
- mockLink('logs', observability),
+ mockLink({ label: 'discover', category: kibana }),
+ mockLink({ label: 'siem', category: security }),
+ mockLink({ label: 'metrics', category: observability }),
+ mockLink({ label: 'monitoring', category: management }),
+ mockLink({ label: 'visualize', category: kibana }),
+ mockLink({ label: 'dashboard', category: kibana }),
+ mockLink({ label: 'canvas' }), // links should be able to be rendered top level as well
+ mockLink({ label: 'logs', category: observability }),
+ ];
+ const recentNavLinks = [
+ mockRecentNavLink({ label: 'recent 1' }),
+ mockRecentNavLink({ label: 'recent 2' }),
];
- const recentNavLinks = [mockRecentNavLink('recent 1'), mockRecentNavLink('recent 2')];
const component = mount(
{
});
it('remembers collapsible section state', () => {
- function expectNavLinksCount(component: ReactWrapper, count: number) {
- expect(
- component.find('.euiAccordion-isOpen a[data-test-subj="collapsibleNavAppLink"]').length
- ).toEqual(count);
- }
-
- const navLinks = [
- mockLink('discover', kibana),
- mockLink('siem', security),
- mockLink('metrics', observability),
- mockLink('monitoring', management),
- mockLink('visualize', kibana),
- mockLink('dashboard', kibana),
- mockLink('logs', observability),
- ];
- const component = mount();
- expectNavLinksCount(component, 7);
- component.find('[data-test-subj="collapsibleNavGroup-kibana"] button').simulate('click');
- expectNavLinksCount(component, 4);
+ const navLinks = [mockLink({ category: kibana }), mockLink({ category: observability })];
+ const recentNavLinks = [mockRecentNavLink({})];
+ const component = mount(
+
+ );
+ expectShownNavLinksCount(component, 3);
+ clickGroup(component, 'kibana');
+ clickGroup(component, 'recentlyViewed');
+ expectShownNavLinksCount(component, 1);
component.setProps({ isOpen: false });
- expectNavLinksCount(component, 0); // double check the nav closed
+ expectNavIsClosed(component);
+ component.setProps({ isOpen: true });
+ expectShownNavLinksCount(component, 1);
+ });
+
+ it('closes the nav after clicking a link', () => {
+ const onClick = sinon.spy();
+ const onIsOpenUpdate = sinon.spy();
+ const navLinks = [mockLink({ category: kibana, onClick })];
+ const recentNavLinks = [mockRecentNavLink({ onClick })];
+ const component = mount(
+
+ );
+ component.setProps({
+ onIsOpenUpdate: (isOpen: boolean) => {
+ component.setProps({ isOpen });
+ onIsOpenUpdate();
+ },
+ });
+
+ component.find('[data-test-subj="collapsibleNavGroup-recentlyViewed"] a').simulate('click');
+ expect(onClick.callCount).toEqual(1);
+ expect(onIsOpenUpdate.callCount).toEqual(1);
+ expectNavIsClosed(component);
component.setProps({ isOpen: true });
- expectNavLinksCount(component, 4);
+ component.find('[data-test-subj="collapsibleNavGroup-kibana"] a').simulate('click');
+ expect(onClick.callCount).toEqual(2);
+ expect(onIsOpenUpdate.callCount).toEqual(2);
});
});
diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx
index 9adcc19b0f0e..81970bc4a267 100644
--- a/src/core/public/chrome/ui/header/collapsible_nav.tsx
+++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx
@@ -159,18 +159,23 @@ export function CollapsibleNav({
isCollapsible={true}
initialIsOpen={getIsCategoryOpen('recentlyViewed', storage)}
onToggle={isCategoryOpen => setIsCategoryOpen('recentlyViewed', isCategoryOpen, storage)}
+ data-test-subj="collapsibleNavGroup-recentlyViewed"
>
{recentNavLinks.length > 0 ? (
{
- // TODO #64541
- // Can remove icon from recent links completely
- const { iconType, ...linkWithoutIcon } = link;
- return linkWithoutIcon;
- })}
+ // TODO #64541
+ // Can remove icon from recent links completely
+ listItems={recentNavLinks.map(({ iconType, onClick = () => {}, ...link }) => ({
+ 'data-test-subj': 'collapsibleNavAppLink--recent',
+ onClick: (e: React.MouseEvent) => {
+ onIsOpenUpdate(false);
+ onClick(e);
+ },
+ ...link,
+ }))}
maxWidth="none"
color="subdued"
gutterSize="none"
@@ -191,7 +196,7 @@ export function CollapsibleNav({
{orderedCategories.map((categoryName, i) => {
const category = categoryDictionary[categoryName]!;
const links = allCategorizedLinks[categoryName].map(
- ({ label, href, isActive, isDisabled, onClick }: NavLink) => ({
+ ({ label, href, isActive, isDisabled, onClick }) => ({
label,
href,
isActive,
diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx
index 22708c796d7d..8003c22b99a3 100644
--- a/src/core/public/chrome/ui/header/nav_link.tsx
+++ b/src/core/public/chrome/ui/header/nav_link.tsx
@@ -142,6 +142,7 @@ export interface RecentNavLink {
title: string;
'aria-label': string;
iconType?: string;
+ onClick?(event: React.MouseEvent): void;
}
/**
diff --git a/x-pack/plugins/ml/server/models/job_validation/messages.js b/x-pack/plugins/ml/common/constants/messages.ts
similarity index 88%
rename from x-pack/plugins/ml/server/models/job_validation/messages.js
rename to x-pack/plugins/ml/common/constants/messages.ts
index 6cdbc457e6ad..b42eacbf7df5 100644
--- a/x-pack/plugins/ml/server/models/job_validation/messages.js
+++ b/x-pack/plugins/ml/common/constants/messages.ts
@@ -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;
-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.',
}),
},
- });
-};
+ };
+});
diff --git a/x-pack/plugins/ml/common/types/data_recognizer.ts b/x-pack/plugins/ml/common/types/data_recognizer.ts
index 2f77cc8c3389..35566970d80c 100644
--- a/x-pack/plugins/ml/common/types/data_recognizer.ts
+++ b/x-pack/plugins/ml/common/types/data_recognizer.ts
@@ -8,7 +8,7 @@ export interface JobStat {
id: string;
earliestTimestampMs: number;
latestTimestampMs: number;
- latestResultsTimestampMs: number;
+ latestResultsTimestampMs: number | undefined;
}
export interface JobExistResult {
diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.ts b/x-pack/plugins/ml/common/util/anomaly_utils.ts
index 36b91f5580b3..16802040059a 100644
--- a/x-pack/plugins/ml/common/util/anomaly_utils.ts
+++ b/x-pack/plugins/ml/common/util/anomaly_utils.ts
@@ -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.
diff --git a/x-pack/plugins/ml/common/util/job_utils.d.ts b/x-pack/plugins/ml/common/util/job_utils.d.ts
deleted file mode 100644
index 170e42aabc67..000000000000
--- a/x-pack/plugins/ml/common/util/job_utils.d.ts
+++ /dev/null
@@ -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[];
diff --git a/x-pack/plugins/ml/common/util/job_utils.test.js b/x-pack/plugins/ml/common/util/job_utils.test.ts
similarity index 96%
rename from x-pack/plugins/ml/common/util/job_utils.test.js
rename to x-pack/plugins/ml/common/util/job_utils.test.ts
index de269676a96e..233e2c2cd19a 100644
--- a/x-pack/plugins/ml/common/util/job_utils.test.js
+++ b/x-pack/plugins/ml/common/util/job_utils.test.ts
@@ -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');
diff --git a/x-pack/plugins/ml/common/util/job_utils.js b/x-pack/plugins/ml/common/util/job_utils.ts
similarity index 68%
rename from x-pack/plugins/ml/common/util/job_utils.js
rename to x-pack/plugins/ml/common/util/job_utils.ts
index 1217139872fc..3822e54ddd53 100644
--- a/x-pack/plugins/ml/common/util/job_utils.js
+++ b/x-pack/plugins/ml/common/util/job_utils.ts
@@ -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(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];
diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx
index decd1275fe88..3ca191d6251c 100644
--- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx
+++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx
@@ -96,6 +96,11 @@ const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) =
return (
+
+
{fullRefresh && loading === true && (
= ({
ref={resizeRef}
data-test-subj="mlPageSingleMetricViewer"
>
-
+
diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts
index c1a5ad5e38ec..a198fac4e3fe 100644
--- a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts
+++ b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts
@@ -155,7 +155,7 @@ function getSearchJsonFromConfig(
json.body.query = query;
- const aggs: Record> = {};
+ const aggs: Record> = {};
aggFieldNamePairs.forEach(({ agg, field }, i) => {
if (field !== null && field !== EVENT_RATE_FIELD_ID) {
diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts
deleted file mode 100644
index 6a9a7a0c1339..000000000000
--- a/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts
+++ /dev/null
@@ -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;
-
-export function validateJob(
- callAsCurrentUser: APICaller,
- payload?: DeepPartial,
- kbnVersion?: string,
- callAsInternalUser?: APICaller,
- isSecurityDisabled?: boolean
-): Promise;
diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts
index d907677855c1..3a86693e9182 100644
--- a/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts
+++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts
@@ -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);
});
});
});
diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.js b/x-pack/plugins/ml/server/models/job_validation/job_validation.ts
similarity index 67%
rename from x-pack/plugins/ml/server/models/job_validation/job_validation.js
rename to x-pack/plugins/ml/server/models/job_validation/job_validation.ts
index ce4643e313c8..f852de785c70 100644
--- a/x-pack/plugins/ml/server/models/job_validation/job_validation.js
+++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.ts
@@ -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;
+
+/**
+ * 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 },
}
);
}
diff --git a/x-pack/plugins/ml/server/models/job_validation/messages.d.ts b/x-pack/plugins/ml/server/models/job_validation/messages.d.ts
deleted file mode 100644
index 772d78b4187d..000000000000
--- a/x-pack/plugins/ml/server/models/job_validation/messages.d.ts
+++ /dev/null
@@ -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;
-}
diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js
index d1f90d76144b..883f1aed1209 100644
--- a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js
+++ b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js
@@ -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;
diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts
index 4001697d7432..84f865879d67 100644
--- a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts
+++ b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts
@@ -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);
}
diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts
index cf3d6d004c37..2ad483fb07ca 100644
--- a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts
+++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts
@@ -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> | never {
- const messages = [];
+): Promise | never {
+ const messages: Messages = [];
if (!validateJobObject(job)) {
// required for TS type casting, validateJobObject throws an error internally.
diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts
index 4fb09af94dcc..be6c9a7157ae 100644
--- a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts
+++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts
@@ -47,7 +47,7 @@ export async function isValidTimeField(callAsCurrentUser: APICaller, job: Combin
export async function validateTimeRange(
callAsCurrentUser: APICaller,
job: CombinedJob,
- timeRange?: TimeRange
+ timeRange?: Partial
) {
const messages: ValidateTimeRangeMessage[] = [];
diff --git a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts
index f12c85962a28..ddfb49ce42cb 100644
--- a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts
+++ b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts
@@ -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),
});
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 5a29dd4e2ea5..a50a95bfdab3 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -9998,9 +9998,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} が返されました。",
@@ -10058,7 +10055,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}:オブジェクトでなければなりません。",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 019b865e959a..b984c7ad94eb 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -10004,9 +10004,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}。",
@@ -10064,7 +10061,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}:需要是对象。",
diff --git a/x-pack/test/api_integration/apis/ml/anomaly_detectors/get.ts b/x-pack/test/api_integration/apis/ml/anomaly_detectors/get.ts
index 255afecde74c..c2d904e379dd 100644
--- a/x-pack/test/api_integration/apis/ml/anomaly_detectors/get.ts
+++ b/x-pack/test/api_integration/apis/ml/anomaly_detectors/get.ts
@@ -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',
diff --git a/x-pack/test/api_integration/apis/ml/job_validation/index.ts b/x-pack/test/api_integration/apis/ml/job_validation/index.ts
index fa894de839cd..774f2ef7b401 100644
--- a/x-pack/test/api_integration/apis/ml/job_validation/index.ts
+++ b/x-pack/test/api_integration/apis/ml/job_validation/index.ts
@@ -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'));
});
}
diff --git a/x-pack/test/api_integration/apis/ml/job_validation/validate.ts b/x-pack/test/api_integration/apis/ml/job_validation/validate.ts
new file mode 100644
index 000000000000..aaeead57345b
--- /dev/null
+++ b/x-pack/test/api_integration/apis/ml/job_validation/validate.ts
@@ -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');
+ });
+ });
+};