diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index ed97db020035..9bb101334beb 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -125,12 +125,14 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "aria-label": "recent 1", "href": "recent 1", "label": "recent 1", + "onClick": undefined, "title": "recent 1", }, Object { "aria-label": "recent 2", "href": "recent 2", "label": "recent 2", + "onClick": undefined, "title": "recent 2", }, ] @@ -298,6 +300,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
@@ -375,6 +379,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` @@ -934,6 +939,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
@@ -1011,6 +1018,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` @@ -1638,6 +1646,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiFlexItem eui-yScroll" > } className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" id="mockId" initialIsOpen={true} onToggle={[Function]} @@ -1675,6 +1685,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
} className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" id="mockId" initialIsOpen={true} onToggle={[Function]} @@ -3311,6 +3336,7 @@ exports[`CollapsibleNav renders the default nav 2`] = ` >
} className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" id="mockId" initialIsOpen={true} onToggle={[Function]} @@ -4235,6 +4265,7 @@ exports[`CollapsibleNav renders the default nav 3`] = ` >
({ 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'); + }); + }); +};