diff --git a/x-pack/plugins/ml/__mocks__/shared_imports.ts b/x-pack/plugins/ml/__mocks__/shared_imports.ts index d044ab409eb7a5..f5fbbf32d30d7a 100644 --- a/x-pack/plugins/ml/__mocks__/shared_imports.ts +++ b/x-pack/plugins/ml/__mocks__/shared_imports.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export function XJsonMode() {} +export const XJsonMode = jest.fn(); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index f87578c4bce48d..9c239df3571635 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -19,25 +19,36 @@ export type IndexName = string; export type IndexPattern = string; export type DataFrameAnalyticsId = string; +export enum ANALYSIS_CONFIG_TYPE { + OUTLIER_DETECTION = 'outlier_detection', + REGRESSION = 'regression', + CLASSIFICATION = 'classification', +} + interface OutlierAnalysis { + [key: string]: {}; outlier_detection: {}; } -interface RegressionAnalysis { - regression: { - dependent_variable: string; - training_percent?: number; - prediction_field_name?: string; - }; +interface Regression { + dependent_variable: string; + training_percent?: number; + prediction_field_name?: string; +} +export interface RegressionAnalysis { + [key: string]: Regression; + regression: Regression; } -interface ClassificationAnalysis { - classification: { - dependent_variable: string; - training_percent?: number; - num_top_classes?: string; - prediction_field_name?: string; - }; +interface Classification { + dependent_variable: string; + training_percent?: number; + num_top_classes?: string; + prediction_field_name?: string; +} +export interface ClassificationAnalysis { + [key: string]: Classification; + classification: Classification; } export interface LoadExploreDataArg { @@ -136,13 +147,6 @@ type AnalysisConfig = | ClassificationAnalysis | GenericAnalysis; -export enum ANALYSIS_CONFIG_TYPE { - OUTLIER_DETECTION = 'outlier_detection', - REGRESSION = 'regression', - CLASSIFICATION = 'classification', - UNKNOWN = 'unknown', -} - export const getAnalysisType = (analysis: AnalysisConfig) => { const keys = Object.keys(analysis); @@ -150,7 +154,7 @@ export const getAnalysisType = (analysis: AnalysisConfig) => { return keys[0]; } - return ANALYSIS_CONFIG_TYPE.UNKNOWN; + return 'unknown'; }; export const getDependentVar = (analysis: AnalysisConfig) => { @@ -245,6 +249,7 @@ export interface DataFrameAnalyticsConfig { }; source: { index: IndexName | IndexName[]; + query?: any; }; analysis: AnalysisConfig; analyzed_fields: { @@ -254,6 +259,7 @@ export interface DataFrameAnalyticsConfig { model_memory_limit: string; create_time: number; version: string; + allow_lazy_start?: boolean; } export enum REFRESH_ANALYTICS_LIST_STATE { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.test.ts new file mode 100644 index 00000000000000..6225bca592be39 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.test.ts @@ -0,0 +1,254 @@ +/* + * 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 { isAdvancedConfig } from './action_clone'; + +describe('Analytics job clone action', () => { + describe('isAdvancedConfig', () => { + test('should detect a classification job created with the form', () => { + const formCreatedClassificationJob = { + description: "Classification job with 'bank-marketing' dataset", + source: { + index: ['bank-marketing'], + query: { + match_all: {}, + }, + }, + dest: { + index: 'dest_bank_1', + results_field: 'ml', + }, + analysis: { + classification: { + dependent_variable: 'y', + num_top_classes: 2, + prediction_field_name: 'y_prediction', + training_percent: 2, + randomize_seed: 6233212276062807000, + }, + }, + analyzed_fields: { + includes: [], + excludes: [], + }, + model_memory_limit: '350mb', + allow_lazy_start: false, + }; + + expect(isAdvancedConfig(formCreatedClassificationJob)).toBe(false); + }); + + test('should detect a outlier_detection job created with the form', () => { + const formCreatedOutlierDetectionJob = { + description: "Outlier detection job with 'glass' dataset", + source: { + index: ['glass_withoutdupl_norm'], + query: { + match_all: {}, + }, + }, + dest: { + index: 'dest_glass_1', + results_field: 'ml', + }, + analysis: { + outlier_detection: { + compute_feature_influence: true, + outlier_fraction: 0.05, + standardization_enabled: true, + }, + }, + analyzed_fields: { + includes: [], + excludes: ['id', 'outlier'], + }, + model_memory_limit: '1mb', + allow_lazy_start: false, + }; + expect(isAdvancedConfig(formCreatedOutlierDetectionJob)).toBe(false); + }); + + test('should detect a regression job created with the form', () => { + const formCreatedRegressionJob = { + description: "Regression job with 'electrical-grid-stability' dataset", + source: { + index: ['electrical-grid-stability'], + query: { + match_all: {}, + }, + }, + dest: { + index: 'dest_grid_1', + results_field: 'ml', + }, + analysis: { + regression: { + dependent_variable: 'stab', + prediction_field_name: 'stab_prediction', + training_percent: 20, + randomize_seed: -2228827740028660200, + }, + }, + analyzed_fields: { + includes: [], + excludes: [], + }, + model_memory_limit: '150mb', + allow_lazy_start: false, + }; + + expect(isAdvancedConfig(formCreatedRegressionJob)).toBe(false); + }); + + test('should detect advanced classification job', () => { + const advancedClassificationJob = { + description: "Classification job with 'bank-marketing' dataset", + source: { + index: ['bank-marketing'], + query: { + match_all: {}, + }, + }, + dest: { + index: 'dest_bank_1', + results_field: 'CUSTOM_RESULT_FIELD', + }, + analysis: { + classification: { + dependent_variable: 'y', + num_top_classes: 2, + prediction_field_name: 'y_prediction', + training_percent: 2, + randomize_seed: 6233212276062807000, + }, + }, + analyzed_fields: { + includes: [], + excludes: [], + }, + model_memory_limit: '350mb', + allow_lazy_start: false, + }; + + expect(isAdvancedConfig(advancedClassificationJob)).toBe(true); + }); + + test('should detect advanced outlier_detection job', () => { + const advancedOutlierDetectionJob = { + description: "Outlier detection job with 'glass' dataset", + source: { + index: ['glass_withoutdupl_norm'], + query: { + // TODO check default for `match` + match_all: {}, + }, + }, + dest: { + index: 'dest_glass_1', + results_field: 'ml', + }, + analysis: { + outlier_detection: { + compute_feature_influence: false, + outlier_fraction: 0.05, + standardization_enabled: true, + }, + }, + analyzed_fields: { + includes: [], + excludes: ['id', 'outlier'], + }, + model_memory_limit: '1mb', + allow_lazy_start: false, + }; + expect(isAdvancedConfig(advancedOutlierDetectionJob)).toBe(true); + }); + + test('should detect a custom query', () => { + const advancedRegressionJob = { + description: "Regression job with 'electrical-grid-stability' dataset", + source: { + index: ['electrical-grid-stability'], + query: { + match: { + custom_field: 'custom_match', + }, + }, + }, + dest: { + index: 'dest_grid_1', + results_field: 'ml', + }, + analysis: { + regression: { + dependent_variable: 'stab', + prediction_field_name: 'stab_prediction', + training_percent: 20, + randomize_seed: -2228827740028660200, + }, + }, + analyzed_fields: { + includes: [], + excludes: [], + }, + model_memory_limit: '150mb', + allow_lazy_start: false, + }; + + expect(isAdvancedConfig(advancedRegressionJob)).toBe(true); + }); + + test('should detect custom analysis settings', () => { + const config = { + description: "Classification clone with 'bank-marketing' dataset", + source: { + index: 'bank-marketing', + }, + dest: { + index: 'bank_classification4', + }, + analyzed_fields: { + excludes: [], + }, + analysis: { + classification: { + dependent_variable: 'y', + training_percent: 71, + max_trees: 1500, + }, + }, + model_memory_limit: '400mb', + }; + + expect(isAdvancedConfig(config)).toBe(true); + }); + + test('should detect as advanced if the prop is unknown', () => { + const config = { + description: "Classification clone with 'bank-marketing' dataset", + source: { + index: 'bank-marketing', + }, + dest: { + index: 'bank_classification4', + }, + analyzed_fields: { + excludes: [], + }, + analysis: { + classification: { + dependent_variable: 'y', + training_percent: 71, + maximum_number_trees: 1500, + }, + }, + model_memory_limit: '400mb', + }; + + expect(isAdvancedConfig(config)).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx new file mode 100644 index 00000000000000..7199453a15d7f9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_clone.tsx @@ -0,0 +1,327 @@ +/* + * 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 { EuiButtonEmpty } from '@elastic/eui'; +import React, { FC } from 'react'; +import { isEqual } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { DataFrameAnalyticsConfig, isOutlierAnalysis } from '../../../../common'; +import { isClassificationAnalysis, isRegressionAnalysis } from '../../../../common/analytics'; +import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; +import { State } from '../../hooks/use_create_analytics_form/state'; +import { DataFrameAnalyticsListRow } from './common'; + +interface PropDefinition { + /** + * Indicates if the property is optional + */ + optional: boolean; + /** + * Corresponding property from the form + */ + formKey?: keyof State['form']; + /** + * Default value of the property + */ + defaultValue?: any; + /** + * Indicates if the value has to be ignored + * during detecting advanced configuration + */ + ignore?: boolean; +} + +function isPropDefinition(a: PropDefinition | object): a is PropDefinition { + return a.hasOwnProperty('optional'); +} + +interface AnalyticsJobMetaData { + [key: string]: PropDefinition | AnalyticsJobMetaData; +} + +/** + * Provides a config definition. + */ +const getAnalyticsJobMeta = (config: CloneDataFrameAnalyticsConfig): AnalyticsJobMetaData => ({ + allow_lazy_start: { + optional: true, + defaultValue: false, + }, + description: { + optional: true, + formKey: 'description', + }, + analysis: { + ...(isClassificationAnalysis(config.analysis) + ? { + classification: { + dependent_variable: { + optional: false, + formKey: 'dependentVariable', + }, + training_percent: { + optional: true, + formKey: 'trainingPercent', + }, + eta: { + optional: true, + }, + feature_bag_fraction: { + optional: true, + }, + max_trees: { + optional: true, + }, + gamma: { + optional: true, + }, + lambda: { + optional: true, + }, + num_top_classes: { + optional: true, + defaultValue: 2, + }, + prediction_field_name: { + optional: true, + defaultValue: `${config.analysis.classification.dependent_variable}_prediction`, + }, + randomize_seed: { + optional: true, + // By default it is randomly generated + ignore: true, + }, + num_top_feature_importance_values: { + optional: true, + }, + }, + } + : {}), + ...(isOutlierAnalysis(config.analysis) + ? { + outlier_detection: { + standardization_enabled: { + defaultValue: true, + optional: true, + }, + compute_feature_influence: { + defaultValue: true, + optional: true, + }, + outlier_fraction: { + defaultValue: 0.05, + optional: true, + }, + feature_influence_threshold: { + optional: true, + }, + method: { + optional: true, + }, + n_neighbors: { + optional: true, + }, + }, + } + : {}), + ...(isRegressionAnalysis(config.analysis) + ? { + regression: { + dependent_variable: { + optional: false, + formKey: 'dependentVariable', + }, + training_percent: { + optional: true, + formKey: 'trainingPercent', + }, + eta: { + optional: true, + }, + feature_bag_fraction: { + optional: true, + }, + max_trees: { + optional: true, + }, + gamma: { + optional: true, + }, + lambda: { + optional: true, + }, + prediction_field_name: { + optional: true, + defaultValue: `${config.analysis.regression.dependent_variable}_prediction`, + }, + num_top_feature_importance_values: { + optional: true, + }, + randomize_seed: { + optional: true, + // By default it is randomly generated + ignore: true, + }, + }, + } + : {}), + }, + analyzed_fields: { + excludes: { + optional: true, + formKey: 'excludes', + defaultValue: [], + }, + includes: { + optional: true, + defaultValue: [], + }, + }, + source: { + index: { + formKey: 'sourceIndex', + optional: false, + }, + query: { + optional: true, + defaultValue: { + match_all: {}, + }, + }, + _source: { + optional: true, + }, + }, + dest: { + index: { + optional: false, + formKey: 'destinationIndex', + }, + results_field: { + optional: true, + defaultValue: 'ml', + }, + }, + model_memory_limit: { + optional: true, + formKey: 'modelMemoryLimit', + }, +}); + +/** + * Detects if analytics job configuration were created with + * the advanced editor and not supported by the regular form. + */ +export function isAdvancedConfig(config: any, meta?: AnalyticsJobMetaData): boolean; +export function isAdvancedConfig( + config: CloneDataFrameAnalyticsConfig, + meta: AnalyticsJobMetaData = getAnalyticsJobMeta(config) +): boolean { + for (const configKey in config) { + if (config.hasOwnProperty(configKey)) { + const fieldConfig = config[configKey as keyof typeof config]; + const fieldMeta = meta[configKey as keyof typeof meta]; + + if (!fieldMeta) { + // eslint-disable-next-line no-console + console.info(`Property "${configKey}" is unknown.`); + return true; + } + + if (isPropDefinition(fieldMeta)) { + const isAdvancedSetting = + fieldMeta.formKey === undefined && + fieldMeta.ignore !== true && + !isEqual(fieldMeta.defaultValue, fieldConfig); + + if (isAdvancedSetting) { + // eslint-disable-next-line no-console + console.info( + `Property "${configKey}" is not supported by the form or has a different value to the default.` + ); + return true; + } + } else if (isAdvancedConfig(fieldConfig, fieldMeta)) { + return true; + } + } + } + return false; +} + +export type CloneDataFrameAnalyticsConfig = Omit< + DataFrameAnalyticsConfig, + 'id' | 'version' | 'create_time' +>; + +export function extractCloningConfig( + originalConfig: DataFrameAnalyticsConfig +): CloneDataFrameAnalyticsConfig { + const { + // Omit non-relevant props from the configuration + id, + version, + create_time, + ...cloneConfig + } = originalConfig; + + // Reset the destination index + cloneConfig.dest.index = ''; + return cloneConfig; +} + +export function getCloneAction(createAnalyticsForm: CreateAnalyticsFormProps) { + const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.cloneJobButtonLabel', { + defaultMessage: 'Clone job', + }); + + const { actions } = createAnalyticsForm; + + const onClick = async (item: DataFrameAnalyticsListRow) => { + await actions.setJobClone(item.config); + }; + + return { + name: buttonText, + description: buttonText, + icon: 'copy', + onClick, + 'data-test-subj': 'mlAnalyticsJobCloneButton', + }; +} + +interface CloneActionProps { + item: DataFrameAnalyticsListRow; + createAnalyticsForm: CreateAnalyticsFormProps; +} + +/** + * Temp component to have Clone job button with the same look as the other actions. + * Replace with {@link getCloneAction} as soon as all the actions are refactored + * to support EuiContext with a valid DOM structure without nested buttons. + */ +export const CloneAction: FC = ({ createAnalyticsForm, item }) => { + const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.cloneJobButtonLabel', { + defaultMessage: 'Clone job', + }); + const { actions } = createAnalyticsForm; + const onClick = async () => { + await actions.setJobClone(item.config); + }; + + return ( + + {buttonText} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_delete.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_delete.tsx index 75841b52521bd5..47fc84cf450c04 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_delete.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/action_delete.tsx @@ -54,6 +54,7 @@ export const DeleteAction: FC = ({ item }) => { iconType="trash" onClick={openModal} aria-label={buttonDeleteText} + style={{ padding: 0 }} > {buttonDeleteText} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx index eb87bfd96c149f..0436bcfc368470 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx @@ -19,6 +19,8 @@ import { isOutlierAnalysis, isClassificationAnalysis, } from '../../../../common/analytics'; +import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; +import { CloneAction } from './action_clone'; import { getResultsUrl, isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from './common'; import { stopAnalytics } from '../../services/analytics_service'; @@ -57,7 +59,7 @@ export const AnalyticsViewAction = { }, }; -export const getActions = () => { +export const getActions = (createAnalyticsForm: CreateAnalyticsFormProps) => { const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics'); return [ @@ -104,5 +106,10 @@ export const getActions = () => { return ; }, }, + { + render: (item: DataFrameAnalyticsListRow) => { + return ; + }, + }, ]; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 412779513e533d..10be0a74e17e61 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -254,7 +254,8 @@ export const DataFrameAnalyticsList: FC = ({ expandedRowItemIds, setExpandedRowItemIds, isManagementTable, - isMlEnabledInSpace + isMlEnabledInSpace, + createAnalyticsForm ); const sorting = { @@ -375,6 +376,10 @@ export const DataFrameAnalyticsList: FC = ({ })} /> + + {!isManagementTable && createAnalyticsForm?.state.isModalVisible && ( + + )} ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx index 07ae2c176c3632..00cd9e3f1e0ddc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/columns.tsx @@ -20,6 +20,7 @@ import { } from '@elastic/eui'; import { getAnalysisType, DataFrameAnalyticsId } from '../../../../common'; +import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; import { getDataFrameAnalyticsProgress, isDataFrameAnalyticsFailed, @@ -125,9 +126,11 @@ export const getColumns = ( expandedRowItemIds: DataFrameAnalyticsId[], setExpandedRowItemIds: React.Dispatch>, isManagementTable: boolean = false, - isMlEnabledInSpace: boolean = true + isMlEnabledInSpace: boolean = true, + createAnalyticsForm?: CreateAnalyticsFormProps ) => { - const actions = isManagementTable === true ? [AnalyticsViewAction] : getActions(); + const actions = + isManagementTable === true ? [AnalyticsViewAction] : getActions(createAnalyticsForm!); function toggleDetails(item: DataFrameAnalyticsListRow) { const index = expandedRowItemIds.indexOf(item.config.id); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx index 399fa4c816877f..7675553515f846 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment } from 'react'; +import React, { FC, Fragment, useEffect, useRef } from 'react'; import { EuiCallOut, @@ -41,6 +41,8 @@ export const CreateAnalyticsAdvancedEditor: FC = ({ ac jobIdValid, } = state.form; + const forceInput = useRef(null); + const onChange = (str: string) => { setAdvancedEditorRawString(str); try { @@ -51,6 +53,16 @@ export const CreateAnalyticsAdvancedEditor: FC = ({ ac } }; + // Temp effect to close the context menu popover on Clone button click + useEffect(() => { + if (forceInput.current === null) { + return; + } + const evt = document.createEvent('MouseEvents'); + evt.initEvent('mouseup', true, true); + forceInput.current.dispatchEvent(evt); + }, []); + return ( {requestMessages.map((requestMessage, i) => ( @@ -98,6 +110,11 @@ export const CreateAnalyticsAdvancedEditor: FC = ({ ac ]} > { + if (input) { + forceInput.current = input; + } + }} disabled={isJobCreated} placeholder="analytics job ID" value={jobId} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx index 0958dff7a3f513..e5054e8a6ad2c0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx @@ -4,18 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC } from 'react'; - +import React, { FC } from 'react'; import { EuiButton, EuiToolTip } from '@elastic/eui'; - import { i18n } from '@kbn/i18n'; - import { createPermissionFailureMessage } from '../../../../../privilege/check_privilege'; - import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -import { CreateAnalyticsFlyoutWrapper } from '../create_analytics_flyout_wrapper'; - export const CreateAnalyticsButton: FC = props => { const { disabled } = props.state; const { openModal } = props.actions; @@ -46,10 +40,5 @@ export const CreateAnalyticsButton: FC = props => { ); } - return ( - - {button} - - - ); + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/create_analytics_flyout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/create_analytics_flyout.tsx index e31c12e2c62d0e..32384e1949d0a3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/create_analytics_flyout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_flyout/create_analytics_flyout.tsx @@ -26,17 +26,22 @@ export const CreateAnalyticsFlyout: FC = ({ state, }) => { const { closeModal, createAnalyticsJob, startAnalyticsJob } = actions; - const { isJobCreated, isJobStarted, isModalButtonDisabled, isValid } = state; + const { isJobCreated, isJobStarted, isModalButtonDisabled, isValid, cloneJob } = state; + + const headerText = !!cloneJob + ? i18n.translate('xpack.ml.dataframe.analytics.clone.flyoutHeaderTitle', { + defaultMessage: 'Clone job from {job_id}', + values: { job_id: cloneJob.id }, + }) + : i18n.translate('xpack.ml.dataframe.analytics.create.flyoutHeaderTitle', { + defaultMessage: 'Create analytics job', + }); return ( -

- {i18n.translate('xpack.ml.dataframe.analytics.create.flyoutHeaderTitle', { - defaultMessage: 'Create analytics job', - })} -

+

{headerText}

{children} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx index 97484b9da8b686..8e7024d2a9147d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useEffect, useMemo } from 'react'; +import React, { Fragment, FC, useEffect, useMemo, useRef } from 'react'; import { EuiComboBox, @@ -23,14 +23,13 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { useMlKibana } from '../../../../../contexts/kibana'; import { ml } from '../../../../../services/ml_api_service'; -import { Field } from '../../../../../../../common/types/fields'; import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; import { useMlContext } from '../../../../../contexts/ml'; import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; import { - JOB_TYPES, DEFAULT_MODEL_MEMORY_LIMIT, getJobConfigFromFormState, + State, } from '../../hooks/use_create_analytics_form/state'; import { JOB_ID_MAX_LENGTH } from '../../../../../../../common/constants/validation'; import { Messages } from './messages'; @@ -38,7 +37,11 @@ import { JobType } from './job_type'; import { JobDescriptionInput } from './job_description'; import { getModelMemoryLimitErrors } from '../../hooks/use_create_analytics_form/reducer'; import { IndexPattern, indexPatterns } from '../../../../../../../../../../src/plugins/data/public'; -import { DfAnalyticsExplainResponse, FieldSelectionItem } from '../../../../common/analytics'; +import { + ANALYSIS_CONFIG_TYPE, + DfAnalyticsExplainResponse, + FieldSelectionItem, +} from '../../../../common/analytics'; import { shouldAddAsDepVarOption, OMIT_FIELDS } from './form_options_validation'; export const CreateAnalyticsForm: FC = ({ actions, state }) => { @@ -50,6 +53,9 @@ export const CreateAnalyticsForm: FC = ({ actions, sta const mlContext = useMlContext(); const { form, indexPatternsMap, isAdvancedEditorEnabled, isJobCreated, requestMessages } = state; + const forceInput = useRef(null); + const firstUpdate = useRef(true); + const { createIndexPattern, dependentVariable, @@ -91,7 +97,7 @@ export const CreateAnalyticsForm: FC = ({ actions, sta ]); const isJobTypeWithDepVar = - jobType === JOB_TYPES.REGRESSION || jobType === JOB_TYPES.CLASSIFICATION; + jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; // Find out if index pattern contain numeric fields. Provides a hint in the form // that an analytics jobs is not able to identify outliers if there are no numeric fields present. @@ -139,6 +145,10 @@ export const CreateAnalyticsForm: FC = ({ actions, sta }; const debouncedGetExplainData = debounce(async () => { + const shouldUpdateModelMemoryLimit = !firstUpdate.current || !modelMemoryLimit; + if (firstUpdate.current) { + firstUpdate.current = false; + } // Reset if sourceIndex or jobType changes (jobType requires dependent_variable to be set - // which won't be the case if switching from outlier detection) if (previousSourceIndex !== sourceIndex || previousJobType !== jobType) { @@ -157,7 +167,9 @@ export const CreateAnalyticsForm: FC = ({ actions, sta ); const expectedMemoryWithoutDisk = resp.memory_estimation?.expected_memory_without_disk; - setEstimatedModelMemoryLimit(expectedMemoryWithoutDisk); + if (shouldUpdateModelMemoryLimit) { + setEstimatedModelMemoryLimit(expectedMemoryWithoutDisk); + } // If sourceIndex has changed load analysis field options again if (previousSourceIndex !== sourceIndex || previousJobType !== jobType) { @@ -172,7 +184,7 @@ export const CreateAnalyticsForm: FC = ({ actions, sta } setFormState({ - ...(!modelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), + ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), excludesOptions: analyzedFieldsOptions, loadingFieldOptions: false, fieldOptionsFetchFail: false, @@ -180,13 +192,13 @@ export const CreateAnalyticsForm: FC = ({ actions, sta }); } else { setFormState({ - ...(!modelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), + ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), }); } } catch (e) { let errorMessage; if ( - jobType === JOB_TYPES.CLASSIFICATION && + jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && e.message !== undefined && e.message.includes('status_exception') && e.message.includes('must have at most') @@ -202,16 +214,15 @@ export const CreateAnalyticsForm: FC = ({ actions, sta fieldOptionsFetchFail: true, maxDistinctValuesError: errorMessage, loadingFieldOptions: false, - modelMemoryLimit: fallbackModelMemoryLimit, + ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: fallbackModelMemoryLimit } : {}), }); } }, 400); - const loadDepVarOptions = async () => { + const loadDepVarOptions = async (formState: State['form']) => { setFormState({ loadingDepVarOptions: true, // clear when the source index changes - dependentVariable: '', maxDistinctValuesError: undefined, sourceIndexFieldsCheckFailed: false, sourceIndexContainsNumericalFields: true, @@ -222,23 +233,39 @@ export const CreateAnalyticsForm: FC = ({ actions, sta ); if (indexPattern !== undefined) { + const formStateUpdate: { + loadingDepVarOptions: boolean; + dependentVariableFetchFail: boolean; + dependentVariableOptions: State['form']['dependentVariableOptions']; + dependentVariable?: State['form']['dependentVariable']; + } = { + loadingDepVarOptions: false, + dependentVariableFetchFail: false, + dependentVariableOptions: [] as State['form']['dependentVariableOptions'], + }; + await newJobCapsService.initializeFromIndexPattern(indexPattern); // Get fields and filter for supported types for job type const { fields } = newJobCapsService; - const depVarOptions: EuiComboBoxOptionOption[] = []; - - fields.forEach((field: Field) => { + let resetDependentVariable = true; + for (const field of fields) { if (shouldAddAsDepVarOption(field, jobType)) { - depVarOptions.push({ label: field.id }); + formStateUpdate.dependentVariableOptions.push({ + label: field.id, + }); + + if (formState.dependentVariable === field.id) { + resetDependentVariable = false; + } } - }); + } - setFormState({ - dependentVariableOptions: depVarOptions, - loadingDepVarOptions: false, - dependentVariableFetchFail: false, - }); + if (resetDependentVariable) { + formStateUpdate.dependentVariable = ''; + } + + setFormState(formStateUpdate); } } catch (e) { setFormState({ loadingDepVarOptions: false, dependentVariableFetchFail: true }); @@ -284,10 +311,10 @@ export const CreateAnalyticsForm: FC = ({ actions, sta useEffect(() => { if (isJobTypeWithDepVar && sourceIndexNameEmpty === false) { - loadDepVarOptions(); + loadDepVarOptions(form); } - if (jobType === JOB_TYPES.OUTLIER_DETECTION && sourceIndexNameEmpty === false) { + if (jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && sourceIndexNameEmpty === false) { validateSourceIndexFields(); } }, [sourceIndex, jobType, sourceIndexNameEmpty]); @@ -297,7 +324,8 @@ export const CreateAnalyticsForm: FC = ({ actions, sta jobType !== undefined && sourceIndex !== '' && sourceIndexNameValid === true; const hasRequiredAnalysisFields = - (isJobTypeWithDepVar && dependentVariable !== '') || jobType === JOB_TYPES.OUTLIER_DETECTION; + (isJobTypeWithDepVar && dependentVariable !== '') || + jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION; if (hasBasicRequiredFields && hasRequiredAnalysisFields) { debouncedGetExplainData(); @@ -308,6 +336,16 @@ export const CreateAnalyticsForm: FC = ({ actions, sta }; }, [jobType, sourceIndex, sourceIndexNameEmpty, dependentVariable, trainingPercent]); + // Temp effect to close the context menu popover on Clone button click + useEffect(() => { + if (forceInput.current === null) { + return; + } + const evt = document.createEvent('MouseEvents'); + evt.initEvent('mouseup', true, true); + forceInput.current.dispatchEvent(evt); + }, []); + return ( @@ -375,6 +413,11 @@ export const CreateAnalyticsForm: FC = ({ actions, sta ]} > { + if (input) { + forceInput.current = input; + } + }} disabled={isJobCreated} placeholder={i18n.translate('xpack.ml.dataframe.analytics.create.jobIdPlaceholder', { defaultMessage: 'Job ID', @@ -495,7 +538,8 @@ export const CreateAnalyticsForm: FC = ({ actions, sta data-test-subj="mlAnalyticsCreateJobFlyoutDestinationIndexInput" /> - {(jobType === JOB_TYPES.REGRESSION || jobType === JOB_TYPES.CLASSIFICATION) && ( + {(jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || + jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) && ( = ({ description, setFormState }) => label={i18n.translate('xpack.ml.dataframe.analytics.create.jobDescription.label', { defaultMessage: 'Job description', })} - helpText={helpText} > { const value = e.target.value; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx index ffed1ebf522f40..0269ae2915d573 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/job_type.tsx @@ -8,8 +8,9 @@ import React, { Fragment, FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../common'; -import { AnalyticsJobType, JOB_TYPES } from '../../hooks/use_create_analytics_form/state'; +import { AnalyticsJobType } from '../../hooks/use_create_analytics_form/state'; interface Props { type: AnalyticsJobType; @@ -42,9 +43,9 @@ export const JobType: FC = ({ type, setFormState }) => { ); const helpText = { - outlier_detection: outlierHelpText, - regression: regressionHelpText, - classification: classificationHelpText, + [ANALYSIS_CONFIG_TYPE.REGRESSION]: regressionHelpText, + [ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION]: outlierHelpText, + [ANALYSIS_CONFIG_TYPE.CLASSIFICATION]: classificationHelpText, }; return ( @@ -56,7 +57,7 @@ export const JobType: FC = ({ type, setFormState }) => { helpText={type !== undefined ? helpText[type] : ''} > ({ + options={Object.values(ANALYSIS_CONFIG_TYPE).map(jobType => ({ value: jobType, text: jobType.replace(/_/g, ' '), }))} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts index 70228f0238fda0..8cedc38b1b59b2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { DataFrameAnalyticsConfig } from '../../../../common'; import { FormMessage, State, SourceIndexMap } from './state'; export enum ACTION { @@ -25,6 +26,7 @@ export enum ACTION { SET_JOB_IDS, SWITCH_TO_ADVANCED_EDITOR, SET_ESTIMATED_MODEL_MEMORY_LIMIT, + SET_JOB_CLONE, } export type Action = @@ -61,13 +63,14 @@ export type Action = | { type: ACTION.SET_IS_MODAL_VISIBLE; isModalVisible: State['isModalVisible'] } | { type: ACTION.SET_JOB_CONFIG; payload: State['jobConfig'] } | { type: ACTION.SET_JOB_IDS; jobIds: State['jobIds'] } - | { type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT; value: State['estimatedModelMemoryLimit'] }; + | { type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT; value: State['estimatedModelMemoryLimit'] } + | { type: ACTION.SET_JOB_CLONE; cloneJob: DataFrameAnalyticsConfig }; // Actions wrapping the dispatcher exposed by the custom hook export interface ActionDispatchers { closeModal: () => void; createAnalyticsJob: () => void; - openModal: () => void; + openModal: () => Promise; resetAdvancedEditorMessages: () => void; setAdvancedEditorRawString: (payload: State['advancedEditorRawString']) => void; setFormState: (payload: Partial) => void; @@ -76,4 +79,5 @@ export interface ActionDispatchers { startAnalyticsJob: () => void; switchToAdvancedEditor: () => void; setEstimatedModelMemoryLimit: (value: State['estimatedModelMemoryLimit']) => void; + setJobClone: (cloneJob: DataFrameAnalyticsConfig) => Promise; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts index 5c989f7248a9eb..8112a0fdb9e29c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts @@ -6,11 +6,11 @@ import { merge } from 'lodash'; -import { DataFrameAnalyticsConfig } from '../../../../common'; +import { ANALYSIS_CONFIG_TYPE, DataFrameAnalyticsConfig } from '../../../../common'; import { ACTION } from './actions'; import { reducer, validateAdvancedEditor, validateMinMML } from './reducer'; -import { getInitialState, JOB_TYPES } from './state'; +import { getInitialState } from './state'; type SourceIndex = DataFrameAnalyticsConfig['source']['index']; @@ -52,7 +52,7 @@ describe('useCreateAnalyticsForm', () => { destinationIndex: 'the-destination-index', jobId: 'the-analytics-job-id', sourceIndex: 'the-source-index', - jobType: JOB_TYPES.OUTLIER_DETECTION, + jobType: ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION, modelMemoryLimit: '200mb', }, }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index 5f21f17b92735f..d045749a1a0dd5 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -8,10 +8,11 @@ import { i18n } from '@kbn/i18n'; import { memoize } from 'lodash'; // @ts-ignore import numeral from '@elastic/numeral'; +import { isEmpty } from 'lodash'; import { isValidIndexName } from '../../../../../../../common/util/es_utils'; import { Action, ACTION } from './actions'; -import { getInitialState, getJobConfigFromFormState, State, JOB_TYPES } from './state'; +import { getInitialState, getJobConfigFromFormState, State } from './state'; import { isJobIdValid, validateModelMemoryLimitUnits, @@ -30,6 +31,7 @@ import { getDependentVar, isRegressionAnalysis, isClassificationAnalysis, + ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; import { indexPatterns } from '../../../../../../../../../../src/plugins/data/public'; @@ -142,7 +144,7 @@ export const validateAdvancedEditor = (state: State): State => { if ( jobConfig.analysis === undefined && - (jobType === JOB_TYPES.CLASSIFICATION || jobType === JOB_TYPES.REGRESSION) + (jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION || jobType === ANALYSIS_CONFIG_TYPE.REGRESSION) ) { dependentVariableEmpty = true; } @@ -315,7 +317,8 @@ const validateForm = (state: State): State => { const jobTypeEmpty = jobType === undefined; const dependentVariableEmpty = - (jobType === JOB_TYPES.REGRESSION || jobType === JOB_TYPES.CLASSIFICATION) && + (jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || + jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) && dependentVariable === ''; const mmlValidationResult = validateMml(estimatedModelMemoryLimit, modelMemoryLimit); @@ -437,7 +440,11 @@ export function reducer(state: State, action: Action): State { } case ACTION.SWITCH_TO_ADVANCED_EDITOR: - const jobConfig = getJobConfigFromFormState(state.form); + let { jobConfig } = state; + const isJobConfigEmpty = isEmpty(state.jobConfig); + if (isJobConfigEmpty) { + jobConfig = getJobConfigFromFormState(state.form); + } return validateAdvancedEditor({ ...state, advancedEditorRawString: JSON.stringify(jobConfig, null, 2), @@ -450,6 +457,12 @@ export function reducer(state: State, action: Action): State { ...state, estimatedModelMemoryLimit: action.value, }; + + case ACTION.SET_JOB_CLONE: + return { + ...state, + cloneJob: action.cloneJob, + }; } return state; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 170700d35e6511..515e0e42bd873a 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -7,9 +7,16 @@ import { EuiComboBoxOptionOption } from '@elastic/eui'; import { DeepPartial } from '../../../../../../../common/types/common'; import { checkPermission } from '../../../../../privilege/check_privilege'; -import { mlNodesAvailable } from '../../../../../ml_nodes_check/check_ml_nodes'; +import { mlNodesAvailable } from '../../../../../ml_nodes_check'; -import { DataFrameAnalyticsId, DataFrameAnalyticsConfig } from '../../../../common'; +import { + isClassificationAnalysis, + isRegressionAnalysis, + DataFrameAnalyticsId, + DataFrameAnalyticsConfig, + ANALYSIS_CONFIG_TYPE, +} from '../../../../common/analytics'; +import { CloneDataFrameAnalyticsConfig } from '../../components/analytics_list/action_clone'; export enum DEFAULT_MODEL_MEMORY_LIMIT { regression = '100mb', @@ -21,7 +28,7 @@ export enum DEFAULT_MODEL_MEMORY_LIMIT { export type EsIndexName = string; export type DependentVariable = string; export type IndexPatternTitle = string; -export type AnalyticsJobType = JOB_TYPES | undefined; +export type AnalyticsJobType = ANALYSIS_CONFIG_TYPE | undefined; type IndexPatternId = string; export type SourceIndexMap = Record< IndexPatternTitle, @@ -33,12 +40,6 @@ export interface FormMessage { message: string; } -export enum JOB_TYPES { - OUTLIER_DETECTION = 'outlier_detection', - REGRESSION = 'regression', - CLASSIFICATION = 'classification', -} - export interface State { advancedEditorMessages: FormMessage[]; advancedEditorRawString: string; @@ -90,6 +91,7 @@ export interface State { jobIds: DataFrameAnalyticsId[]; requestMessages: FormMessage[]; estimatedModelMemoryLimit: string; + cloneJob?: DataFrameAnalyticsConfig; } export const getInitialState = (): State => ({ @@ -174,8 +176,8 @@ export const getJobConfigFromFormState = ( }; if ( - formState.jobType === JOB_TYPES.REGRESSION || - formState.jobType === JOB_TYPES.CLASSIFICATION + formState.jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || + formState.jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION ) { jobConfig.analysis = { [formState.jobType]: { @@ -187,3 +189,35 @@ export const getJobConfigFromFormState = ( return jobConfig; }; + +/** + * Extracts form state for a job clone from the analytics job configuration. + * For cloning we keep job id and destination index empty. + */ +export function getCloneFormStateFromJobConfig( + analyticsJobConfig: CloneDataFrameAnalyticsConfig +): Partial { + const jobType = Object.keys(analyticsJobConfig.analysis)[0] as ANALYSIS_CONFIG_TYPE; + + const resultState: Partial = { + jobType, + description: analyticsJobConfig.description ?? '', + sourceIndex: Array.isArray(analyticsJobConfig.source.index) + ? analyticsJobConfig.source.index.join(',') + : analyticsJobConfig.source.index, + modelMemoryLimit: analyticsJobConfig.model_memory_limit, + excludes: analyticsJobConfig.analyzed_fields.excludes, + }; + + if ( + isRegressionAnalysis(analyticsJobConfig.analysis) || + isClassificationAnalysis(analyticsJobConfig.analysis) + ) { + const analysisConfig = analyticsJobConfig.analysis[jobType]; + + resultState.dependentVariable = analysisConfig.dependent_variable; + resultState.trainingPercent = analysisConfig.training_percent; + } + + return resultState; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 9a243e1b0316d8..74161d7c48c246 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -17,6 +17,10 @@ import { DataFrameAnalyticsId, DataFrameAnalyticsConfig, } from '../../../../common'; +import { + extractCloningConfig, + isAdvancedConfig, +} from '../../components/analytics_list/action_clone'; import { ActionDispatchers, ACTION } from './actions'; import { reducer } from './reducer'; @@ -27,6 +31,7 @@ import { FormMessage, State, SourceIndexMap, + getCloneFormStateFromJobConfig, } from './state'; export interface CreateAnalyticsFormProps { @@ -187,9 +192,7 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { } }; - const openModal = async () => { - resetForm(); - + const prepareFormValidation = async () => { // re-fetch existing analytics job IDs and indices for form validation try { setJobIds( @@ -248,7 +251,11 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { ), }); } + }; + const openModal = async () => { + resetForm(); + await prepareFormValidation(); dispatch({ type: ACTION.OPEN_MODAL }); }; @@ -301,6 +308,23 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { dispatch({ type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT, value }); }; + const setJobClone = async (cloneJob: DataFrameAnalyticsConfig) => { + resetForm(); + await prepareFormValidation(); + + const config = extractCloningConfig(cloneJob); + if (isAdvancedConfig(config)) { + setJobConfig(config); + switchToAdvancedEditor(); + } else { + setFormState(getCloneFormStateFromJobConfig(config)); + setEstimatedModelMemoryLimit(config.model_memory_limit); + } + + dispatch({ type: ACTION.SET_JOB_CLONE, cloneJob }); + dispatch({ type: ACTION.OPEN_MODAL }); + }; + const actions: ActionDispatchers = { closeModal, createAnalyticsJob, @@ -313,6 +337,7 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { startAnalyticsJob, switchToAdvancedEditor, setEstimatedModelMemoryLimit, + setJobClone, }; return { state, actions }; diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts new file mode 100644 index 00000000000000..512de861e673a3 --- /dev/null +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts @@ -0,0 +1,200 @@ +/* + * 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 { DeepPartial } from '../../../../../plugins/ml/common/types/common'; +import { DataFrameAnalyticsConfig } from '../../../../../plugins/ml/public/application/data_frame_analytics/common'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + describe('jobs cloning supported by UI form', function() { + this.tags(['smoke']); + + const testDataList: Array<{ + suiteTitle: string; + archive: string; + job: DeepPartial; + }> = (() => { + const timestamp = Date.now(); + + return [ + { + suiteTitle: 'classification job supported by the form', + archive: 'ml/bm_classification', + job: { + id: `bm_1_${timestamp}`, + description: + "Classification job based on 'bank-marketing' dataset with dependentVariable 'y' and trainingPercent '20'", + source: { + index: ['bank-marketing*'], + query: { + match_all: {}, + }, + }, + dest: { + get index(): string { + return `user-bm_1_${timestamp}`; + }, + results_field: 'ml', + }, + analysis: { + classification: { + dependent_variable: 'y', + training_percent: 20, + }, + }, + analyzed_fields: { + includes: [], + excludes: [], + }, + model_memory_limit: '350mb', + allow_lazy_start: false, + }, + }, + { + suiteTitle: 'outlier detection job supported by the form', + archive: 'ml/ihp_outlier', + job: { + id: `ihp_1_${timestamp}`, + description: 'This is the job description', + source: { + index: ['ihp_outlier'], + query: { + match_all: {}, + }, + }, + dest: { + get index(): string { + return `user-ihp_1_${timestamp}`; + }, + results_field: 'ml', + }, + analysis: { + outlier_detection: {}, + }, + analyzed_fields: { + includes: [], + excludes: [], + }, + model_memory_limit: '55mb', + }, + }, + { + suiteTitle: 'regression job supported by the form', + archive: 'ml/egs_regression', + job: { + id: `egs_1_${timestamp}`, + description: 'This is the job description', + source: { + index: ['egs_regression'], + query: { + match_all: {}, + }, + }, + dest: { + get index(): string { + return `user-egs_1_${timestamp}`; + }, + results_field: 'ml', + }, + analysis: { + regression: { + dependent_variable: 'stab', + training_percent: 20, + }, + }, + analyzed_fields: { + includes: [], + excludes: [], + }, + model_memory_limit: '105mb', + }, + }, + ]; + })(); + + before(async () => { + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + for (const testData of testDataList) { + describe(`${testData.suiteTitle}`, function() { + const cloneJobId = `${testData.job.id}_clone`; + const cloneDestIndex = `${testData.job!.dest!.index}_clone`; + + before(async () => { + await esArchiver.load(testData.archive); + await ml.api.createDataFrameAnalyticsJob(testData.job as DataFrameAnalyticsConfig); + + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataFrameAnalytics(); + await ml.dataFrameAnalyticsTable.waitForAnalyticsToLoad(); + await ml.dataFrameAnalyticsTable.filterWithSearchString(testData.job.id as string); + await ml.dataFrameAnalyticsTable.cloneJob(testData.job.id as string); + }); + + after(async () => { + await ml.api.deleteIndices(cloneDestIndex); + await ml.api.deleteIndices(testData.job.dest!.index as string); + await esArchiver.unload(testData.archive); + }); + + it('should open the flyout with a proper header', async () => { + expect(await ml.dataFrameAnalyticsCreation.getHeaderText()).to.be( + `Clone job from ${testData.job.id}` + ); + }); + + it('should have correct init form values', async () => { + await ml.dataFrameAnalyticsCreation.assertInitialCloneJobForm( + testData.job as DataFrameAnalyticsConfig + ); + }); + + it('should have disabled Create button on open', async () => { + expect(await ml.dataFrameAnalyticsCreation.isCreateButtonDisabled()).to.be(true); + }); + + it('should enable Create button on a valid form input', async () => { + await ml.dataFrameAnalyticsCreation.setJobId(cloneJobId); + await ml.dataFrameAnalyticsCreation.setDestIndex(cloneDestIndex); + expect(await ml.dataFrameAnalyticsCreation.isCreateButtonDisabled()).to.be(false); + }); + + it('should create a clone job', async () => { + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + }); + + it('should start the clone analytics job', async () => { + await ml.dataFrameAnalyticsCreation.assertStartButtonExists(); + await ml.dataFrameAnalyticsCreation.startAnalyticsJob(); + }); + + it('should close the create job flyout', async () => { + await ml.dataFrameAnalyticsCreation.assertCloseButtonExists(); + await ml.dataFrameAnalyticsCreation.closeCreateAnalyticsJobFlyout(); + }); + + it('displays the created job in the analytics table', async () => { + await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); + await ml.dataFrameAnalyticsTable.filterWithSearchString(cloneJobId); + const rows = await ml.dataFrameAnalyticsTable.parseAnalyticsTable(); + const filteredRows = rows.filter(row => row.id === cloneJobId); + expect(filteredRows).to.have.length( + 1, + `Filtered analytics table should have 1 row for job id '${cloneJobId}' (got matching items '${filteredRows}')` + ); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/index.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/index.ts index fda0c5d203f2e0..fe94f4aea9220d 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/index.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/index.ts @@ -12,5 +12,6 @@ export default function({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./outlier_detection_creation')); loadTestFile(require.resolve('./regression_creation')); loadTestFile(require.resolve('./classification_creation')); + loadTestFile(require.resolve('./cloning')); }); } diff --git a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts index 405e9575f4222c..f3731f46a5bcee 100644 --- a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts +++ b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts @@ -13,7 +13,6 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'security']); describe('security', function() { - this.tags(['james']); before(async () => { await esArchiver.load('empty_kibana'); diff --git a/x-pack/test/functional/services/machine_learning/api.ts b/x-pack/test/functional/services/machine_learning/api.ts index 89c81a800e4712..e305d23c1a1249 100644 --- a/x-pack/test/functional/services/machine_learning/api.ts +++ b/x-pack/test/functional/services/machine_learning/api.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; import { ProvidedType } from '@kbn/test/types/ftr'; +import { DataFrameAnalyticsConfig } from '../../../../plugins/ml/public/application/data_frame_analytics/common'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -355,5 +356,26 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { await this.waitForDatafeedState(datafeedConfig.datafeed_id, DATAFEED_STATE.STOPPED); await this.waitForJobState(jobConfig.job_id, JOB_STATE.CLOSED); }, + + async getDataFrameAnalyticsJob(analyticsId: string) { + return await esSupertest.get(`/_ml/data_frame/analytics/${analyticsId}`).expect(200); + }, + + async createDataFrameAnalyticsJob(jobConfig: DataFrameAnalyticsConfig) { + const { id: analyticsId, ...analyticsConfig } = jobConfig; + log.debug(`Creating data frame analytic job with id '${analyticsId}'...`); + await esSupertest + .put(`/_ml/data_frame/analytics/${analyticsId}`) + .send(analyticsConfig) + .expect(200); + + await retry.waitForWithTimeout(`'${analyticsId}' to be created`, 5 * 1000, async () => { + if (await this.getDataFrameAnalyticsJob(analyticsId)) { + return true; + } else { + throw new Error(`expected data frame analytics job '${analyticsId}' to be created`); + } + }); + }, }; } diff --git a/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts b/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts index 96dc8993c3d35e..9d5f5753e8b041 100644 --- a/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts @@ -4,10 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ import expect from '@kbn/expect'; +import { DataFrameAnalyticsConfig } from '../../../../plugins/ml/public/application/data_frame_analytics/common'; +import { + ClassificationAnalysis, + RegressionAnalysis, +} from '../../../../plugins/ml/public/application/data_frame_analytics/common/analytics'; import { FtrProviderContext } from '../../ftr_provider_context'; import { MlCommon } from './common'; +enum ANALYSIS_CONFIG_TYPE { + OUTLIER_DETECTION = 'outlier_detection', + REGRESSION = 'regression', + CLASSIFICATION = 'classification', +} + +const isRegressionAnalysis = (arg: any): arg is RegressionAnalysis => { + const keys = Object.keys(arg); + return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.REGRESSION; +}; + +const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => { + const keys = Object.keys(arg); + return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; +}; + export function MachineLearningDataFrameAnalyticsCreationProvider( { getService }: FtrProviderContext, mlCommon: MlCommon @@ -114,6 +135,16 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( ); }, + async assertExcludedFieldsSelection(expectedSelection: string[]) { + const actualSelection = await comboBox.getComboBoxSelectedOptions( + 'mlAnalyticsCreateJobFlyoutExcludesSelect > comboBoxInput' + ); + expect(actualSelection).to.eql( + expectedSelection, + `Excluded fields should be '${expectedSelection}' (got '${actualSelection}')` + ); + }, + async selectSourceIndex(sourceIndex: string) { await comboBox.set( 'mlAnalyticsCreateJobFlyoutSourceIndexSelect > comboBoxInput', @@ -297,6 +328,11 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( await testSubjects.missingOrFail('mlAnalyticsCreateJobFlyoutCreateButton'); }, + async isCreateButtonDisabled() { + const isEnabled = await testSubjects.isEnabled('mlAnalyticsCreateJobFlyoutCreateButton'); + return !isEnabled; + }, + async createAnalyticsJob() { await testSubjects.click('mlAnalyticsCreateJobFlyoutCreateButton'); await retry.tryForTime(5000, async () => { @@ -331,5 +367,24 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( await testSubjects.missingOrFail('mlAnalyticsCreateJobFlyout'); }); }, + + async getHeaderText() { + return await testSubjects.getVisibleText('mlDataFrameAnalyticsFlyoutHeaderTitle'); + }, + + async assertInitialCloneJobForm(job: DataFrameAnalyticsConfig) { + const jobType = Object.keys(job.analysis)[0]; + await this.assertJobTypeSelection(jobType); + await this.assertJobIdValue(''); // id should be empty + await this.assertJobDescriptionValue(String(job.description)); + await this.assertSourceIndexSelection(job.source.index as string[]); + await this.assertDestIndexValue(''); // destination index should be empty + if (isClassificationAnalysis(job.analysis) || isRegressionAnalysis(job.analysis)) { + await this.assertDependentVariableSelection([job.analysis[jobType].dependent_variable]); + await this.assertTrainingPercentValue(String(job.analysis[jobType].training_percent)); + } + await this.assertExcludedFieldsSelection(job.analyzed_fields.excludes); + await this.assertModelMemoryValue(job.model_memory_limit); + }, }; } diff --git a/x-pack/test/functional/services/machine_learning/data_frame_analytics_table.ts b/x-pack/test/functional/services/machine_learning/data_frame_analytics_table.ts index 1d710a1c4cec72..921981768dabaf 100644 --- a/x-pack/test/functional/services/machine_learning/data_frame_analytics_table.ts +++ b/x-pack/test/functional/services/machine_learning/data_frame_analytics_table.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function MachineLearningDataFrameAnalyticsTableProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const find = getService('find'); return new (class AnalyticsTable { public async parseAnalyticsTable() { @@ -108,5 +109,18 @@ export function MachineLearningDataFrameAnalyticsTableProvider({ getService }: F const analyticsRow = rows.filter(row => row.id === analyticsId)[0]; expect(analyticsRow).to.eql(expectedRow); } + + public async openRowActions(analyticsId: string) { + await find.clickByCssSelector( + `[data-test-subj="mlAnalyticsTableRow row-${analyticsId}"] [data-test-subj=euiCollapsedItemActionsButton]` + ); + await find.existsByCssSelector('.euiPanel', 20 * 1000); + } + + public async cloneJob(analyticsId: string) { + await this.openRowActions(analyticsId); + await testSubjects.click(`mlAnalyticsJobCloneButton`); + await testSubjects.existOrFail('mlAnalyticsCreateJobFlyout'); + } })(); }