[ML] Clone analytics job (#59791) (#60228)

* [ML] clone analytics job

* [ML] flyout clone header

* [ML] improve clone action context menu item

* [ML] support advanced job cloning

* [ML] extractCloningConfig

* [ML] fix isAdvancedSetting condition, add test

* [ML] clone job header

* [ML] job description placeholder

* [ML] setEstimatedModelMemoryLimit on source index change

* [ML] Fix types.

* [ML] useUpdateEffect in create_analytics_form.tsx

* [ML] setJobClone action

* [ML] remove CreateAnalyticsFlyoutWrapper instance from the create_analytics_button.tsx

* [ML] fix types

* [ML] hack to align Clone button with the other actions

* [ML] unknown props lead to advanced editor

* [ML] rename maximum_number_trees ot max_trees

* [ML] fix forceInput

* [ML] populate excludesOptions on the first update, skip setting mml on the fist update

* [ML] init functional test for cloning analytics jobs

* [ML] functional tests

* [ML] fix functional tests imports

* [ML] fix indices names for functional tests

* [ML] functional tests for outlier detection and regression jobs cloning

* [ML] delete james tag

* [ML] fix tests arrangement

Co-authored-by: Walter Rafelsberger <walter@elastic.co>

Co-authored-by: Walter Rafelsberger <walter@elastic.co>
This commit is contained in:
Dima Arnautov 2020-03-16 11:38:50 +01:00 committed by GitHub
parent d78150da17
commit 97233dde54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1133 additions and 106 deletions

View file

@ -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();

View file

@ -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 {

View file

@ -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);
});
});
});

View file

@ -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<CloneActionProps> = ({ 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 (
<EuiButtonEmpty
data-test-subj="mlAnalyticsJobCloneButton"
size="xs"
color="text"
iconType="copy"
onClick={onClick}
aria-label={buttonText}
>
{buttonText}
</EuiButtonEmpty>
);
};

View file

@ -54,6 +54,7 @@ export const DeleteAction: FC<DeleteActionProps> = ({ item }) => {
iconType="trash"
onClick={openModal}
aria-label={buttonDeleteText}
style={{ padding: 0 }}
>
{buttonDeleteText}
</EuiButtonEmpty>

View file

@ -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 <DeleteAction item={item} />;
},
},
{
render: (item: DataFrameAnalyticsListRow) => {
return <CloneAction item={item} createAnalyticsForm={createAnalyticsForm} />;
},
},
];
};

View file

@ -254,7 +254,8 @@ export const DataFrameAnalyticsList: FC<Props> = ({
expandedRowItemIds,
setExpandedRowItemIds,
isManagementTable,
isMlEnabledInSpace
isMlEnabledInSpace,
createAnalyticsForm
);
const sorting = {
@ -375,6 +376,10 @@ export const DataFrameAnalyticsList: FC<Props> = ({
})}
/>
</div>
{!isManagementTable && createAnalyticsForm?.state.isModalVisible && (
<CreateAnalyticsFlyoutWrapper {...createAnalyticsForm} />
)}
</Fragment>
);
};

View file

@ -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<React.SetStateAction<DataFrameAnalyticsId[]>>,
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);

View file

@ -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<CreateAnalyticsFormProps> = ({ ac
jobIdValid,
} = state.form;
const forceInput = useRef<HTMLInputElement | null>(null);
const onChange = (str: string) => {
setAdvancedEditorRawString(str);
try {
@ -51,6 +53,16 @@ export const CreateAnalyticsAdvancedEditor: FC<CreateAnalyticsFormProps> = ({ 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 (
<EuiForm className="mlDataFrameAnalyticsCreateForm">
{requestMessages.map((requestMessage, i) => (
@ -98,6 +110,11 @@ export const CreateAnalyticsAdvancedEditor: FC<CreateAnalyticsFormProps> = ({ ac
]}
>
<EuiFieldText
inputRef={input => {
if (input) {
forceInput.current = input;
}
}}
disabled={isJobCreated}
placeholder="analytics job ID"
value={jobId}

View file

@ -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<CreateAnalyticsFormProps> = props => {
const { disabled } = props.state;
const { openModal } = props.actions;
@ -46,10 +40,5 @@ export const CreateAnalyticsButton: FC<CreateAnalyticsFormProps> = props => {
);
}
return (
<Fragment>
{button}
<CreateAnalyticsFlyoutWrapper {...props} />
</Fragment>
);
return button;
};

View file

@ -26,17 +26,22 @@ export const CreateAnalyticsFlyout: FC<CreateAnalyticsFormProps> = ({
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 (
<EuiFlyout size="m" onClose={closeModal} data-test-subj="mlAnalyticsCreateJobFlyout">
<EuiFlyoutHeader>
<EuiTitle>
<h2 data-test-subj="mlDataFrameAnalyticsFlyoutHeaderTitle">
{i18n.translate('xpack.ml.dataframe.analytics.create.flyoutHeaderTitle', {
defaultMessage: 'Create analytics job',
})}
</h2>
<h2 data-test-subj="mlDataFrameAnalyticsFlyoutHeaderTitle">{headerText}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>{children}</EuiFlyoutBody>

View file

@ -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<CreateAnalyticsFormProps> = ({ actions, state }) => {
@ -50,6 +53,9 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, sta
const mlContext = useMlContext();
const { form, indexPatternsMap, isAdvancedEditorEnabled, isJobCreated, requestMessages } = state;
const forceInput = useRef<HTMLInputElement | null>(null);
const firstUpdate = useRef<boolean>(true);
const {
createIndexPattern,
dependentVariable,
@ -91,7 +97,7 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ 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<CreateAnalyticsFormProps> = ({ 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<CreateAnalyticsFormProps> = ({ 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<CreateAnalyticsFormProps> = ({ actions, sta
}
setFormState({
...(!modelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}),
...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}),
excludesOptions: analyzedFieldsOptions,
loadingFieldOptions: false,
fieldOptionsFetchFail: false,
@ -180,13 +192,13 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ 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<CreateAnalyticsFormProps> = ({ 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<CreateAnalyticsFormProps> = ({ 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,
});
setFormState({
dependentVariableOptions: depVarOptions,
loadingDepVarOptions: false,
dependentVariableFetchFail: false,
});
if (formState.dependentVariable === field.id) {
resetDependentVariable = false;
}
}
}
if (resetDependentVariable) {
formStateUpdate.dependentVariable = '';
}
setFormState(formStateUpdate);
}
} catch (e) {
setFormState({ loadingDepVarOptions: false, dependentVariableFetchFail: true });
@ -284,10 +311,10 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ 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<CreateAnalyticsFormProps> = ({ 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<CreateAnalyticsFormProps> = ({ 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 (
<EuiForm className="mlDataFrameAnalyticsCreateForm">
<Messages messages={requestMessages} />
@ -375,6 +413,11 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, sta
]}
>
<EuiFieldText
inputRef={input => {
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<CreateAnalyticsFormProps> = ({ actions, sta
data-test-subj="mlAnalyticsCreateJobFlyoutDestinationIndexInput"
/>
</EuiFormRow>
{(jobType === JOB_TYPES.REGRESSION || jobType === JOB_TYPES.CLASSIFICATION) && (
{(jobType === ANALYSIS_CONFIG_TYPE.REGRESSION ||
jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) && (
<Fragment>
<EuiFormRow
fullWidth

View file

@ -6,7 +6,8 @@
import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public';
import { Field, EVENT_RATE_FIELD_ID } from '../../../../../../../common/types/fields';
import { JOB_TYPES, AnalyticsJobType } from '../../hooks/use_create_analytics_form/state';
import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics';
import { AnalyticsJobType } from '../../hooks/use_create_analytics_form/state';
import { BASIC_NUMERICAL_TYPES, EXTENDED_NUMERICAL_TYPES } from '../../../../common/fields';
const CATEGORICAL_TYPES = new Set(['ip', 'keyword', 'text']);
@ -23,8 +24,8 @@ export const shouldAddAsDepVarOption = (field: Field, jobType: AnalyticsJobType)
const isSupportedByClassification =
isBasicNumerical || CATEGORICAL_TYPES.has(field.type) || field.type === ES_FIELD_TYPES.BOOLEAN;
if (jobType === JOB_TYPES.REGRESSION) {
if (jobType === ANALYSIS_CONFIG_TYPE.REGRESSION) {
return isBasicNumerical || EXTENDED_NUMERICAL_TYPES.has(field.type);
}
if (jobType === JOB_TYPES.CLASSIFICATION) return isSupportedByClassification;
if (jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) return isSupportedByClassification;
};

View file

@ -22,10 +22,10 @@ export const JobDescriptionInput: FC<Props> = ({ description, setFormState }) =>
label={i18n.translate('xpack.ml.dataframe.analytics.create.jobDescription.label', {
defaultMessage: 'Job description',
})}
helpText={helpText}
>
<EuiTextArea
value={description}
placeholder={helpText}
rows={2}
onChange={e => {
const value = e.target.value;

View file

@ -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<Props> = ({ 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<Props> = ({ type, setFormState }) => {
helpText={type !== undefined ? helpText[type] : ''}
>
<EuiSelect
options={Object.values(JOB_TYPES).map(jobType => ({
options={Object.values(ANALYSIS_CONFIG_TYPE).map(jobType => ({
value: jobType,
text: jobType.replace(/_/g, ' '),
}))}

View file

@ -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<void>;
resetAdvancedEditorMessages: () => void;
setAdvancedEditorRawString: (payload: State['advancedEditorRawString']) => void;
setFormState: (payload: Partial<State['form']>) => void;
@ -76,4 +79,5 @@ export interface ActionDispatchers {
startAnalyticsJob: () => void;
switchToAdvancedEditor: () => void;
setEstimatedModelMemoryLimit: (value: State['estimatedModelMemoryLimit']) => void;
setJobClone: (cloneJob: DataFrameAnalyticsConfig) => Promise<void>;
}

View file

@ -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',
},
});

View file

@ -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;

View file

@ -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<State['form']> {
const jobType = Object.keys(analyticsJobConfig.analysis)[0] as ANALYSIS_CONFIG_TYPE;
const resultState: Partial<State['form']> = {
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;
}

View file

@ -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 };

View file

@ -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<DataFrameAnalyticsConfig>;
}> = (() => {
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}')`
);
});
});
}
});
}

View file

@ -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'));
});
}

View file

@ -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');

View file

@ -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`);
}
});
},
};
}

View file

@ -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);
},
};
}

View file

@ -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');
}
})();
}