mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
* [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:
parent
d78150da17
commit
97233dde54
26 changed files with 1133 additions and 106 deletions
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -54,6 +54,7 @@ export const DeleteAction: FC<DeleteActionProps> = ({ item }) => {
|
|||
iconType="trash"
|
||||
onClick={openModal}
|
||||
aria-label={buttonDeleteText}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{buttonDeleteText}
|
||||
</EuiButtonEmpty>
|
||||
|
|
|
@ -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} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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, ' '),
|
||||
}))}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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}')`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue