[ML] Data Frame Analytics: Regression creation and results view (#48159) (#48248)

* Add regression job type option and fields

* fetch dependent variable options when regression

* convert modal to flyout.disable delete if job not stopped

* update modelMemoryLimit for regression

* ensure eventRateFieldId not included in numerical fields

* add evaluateDataFrameAnalyticsRegression endpoint

* create regression result page generalization and training error panel

* update advancedEditorToggle help text

* remove unused translations

* rename creationModal test to creationFlyout

* Updates from PR feedback
This commit is contained in:
Melissa Alvarez 2019-10-15 13:50:03 -04:00 committed by GitHub
parent c73a4d1204
commit 5f3faad168
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 874 additions and 175 deletions

View file

@ -1,3 +1,4 @@
@import 'pages/analytics_exploration/components/exploration/index';
@import 'pages/analytics_management/components/analytics_list/index';
@import 'pages/analytics_management/components/create_analytics_form/index';
@import 'pages/analytics_management/components/create_analytics_flyout/index';

View file

@ -24,13 +24,24 @@ interface RegressionAnalysis {
};
}
export interface RegressionEvaluateResponse {
regression: {
mean_squared_error: {
error: number;
};
r_squared: {
value: number;
};
};
}
interface GenericAnalysis {
[key: string]: Record<string, any>;
}
type AnalysisConfig = OutlierAnalysis | RegressionAnalysis | GenericAnalysis;
enum ANALYSIS_CONFIG_TYPE {
export enum ANALYSIS_CONFIG_TYPE {
OUTLIER_DETECTION = 'outlier_detection',
REGRESSION = 'regression',
UNKNOWN = 'unknown',
@ -46,11 +57,24 @@ export const getAnalysisType = (analysis: AnalysisConfig) => {
return ANALYSIS_CONFIG_TYPE.UNKNOWN;
};
export const getDependentVar = (analysis: AnalysisConfig) => {
let depVar;
if (isRegressionAnalysis(analysis)) {
depVar = analysis.regression.dependent_variable;
}
return depVar;
};
export const isOutlierAnalysis = (arg: any): arg is OutlierAnalysis => {
const keys = Object.keys(arg);
return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION;
};
export const isRegressionAnalysis = (arg: any): arg is RegressionAnalysis => {
const keys = Object.keys(arg);
return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.REGRESSION;
};
export interface DataFrameAnalyticsConfig {
id: DataFrameAnalyticsId;
// Description attribute is not supported yet

View file

@ -6,6 +6,7 @@
export {
getAnalysisType,
getDependentVar,
isOutlierAnalysis,
refreshAnalyticsList$,
useRefreshAnalyticsList,
@ -14,6 +15,8 @@ export {
IndexName,
IndexPattern,
REFRESH_ANALYTICS_LIST_STATE,
ANALYSIS_CONFIG_TYPE,
RegressionEvaluateResponse,
} from './analytics';
export {

View file

@ -0,0 +1,68 @@
/*
* 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 React, { FC, Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiCallOut } from '@elastic/eui';
interface Props {
error: string;
}
export const ErrorCallout: FC<Props> = ({ error }) => {
let errorCallout = (
<EuiCallOut
title={i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.generalError', {
defaultMessage: 'An error occurred loading the data.',
})}
color="danger"
iconType="cross"
>
<p>{error}</p>
</EuiCallOut>
);
// Job was created but not started so the destination index has not been created
if (error.includes('index_not_found')) {
errorCallout = (
<EuiCallOut
title={i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.evaluateError', {
defaultMessage: 'An error occurred loading the data.',
})}
color="danger"
iconType="cross"
>
<p>
{i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.noIndexCalloutBody', {
defaultMessage:
'The query for the index returned no results. Please make sure the destination index exists and contains documents.',
})}
</p>
</EuiCallOut>
);
} else if (error.includes('No documents found')) {
// Job was started but no results have been written yet
errorCallout = (
<EuiCallOut
title={i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutTitle',
{
defaultMessage: 'Empty index query result.',
}
)}
color="primary"
>
<p>
{i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutBody', {
defaultMessage:
'The query for the index returned no results. Please make sure the job has been completed and the index contains documents.',
})}
</p>
</EuiCallOut>
);
}
return <Fragment>{errorCallout}</Fragment>;
};

View file

@ -0,0 +1,243 @@
/*
* 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 React, { FC, Fragment, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiStat, EuiTitle } from '@elastic/eui';
import { idx } from '@kbn/elastic-idx';
import { ml } from '../../../../../services/ml_api_service';
import { getErrorMessage } from '../../../analytics_management/hooks/use_create_analytics_form';
import { RegressionEvaluateResponse } from '../../../../common';
import { ErrorCallout } from './error_callout';
interface Props {
jobId: string;
index: string;
dependentVariable: string;
}
interface LoadEvaluateResult {
success: boolean;
eval: RegressionEvaluateResponse | null;
error: string | null;
}
interface Eval {
meanSquaredError: number | '';
rSquared: number | '';
error: null | string;
}
const meanSquaredErrorText = i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.meanSquaredErrorText',
{
defaultMessage: 'Mean squared error',
}
);
const rSquaredText = i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.rSquaredText',
{
defaultMessage: 'R squared',
}
);
const defaultEval: Eval = { meanSquaredError: '', rSquared: '', error: null };
const DEFAULT_SIG_FIGS = 3;
function getValuesFromResponse(response: RegressionEvaluateResponse) {
let meanSquaredError = idx(response, _ => _.regression.mean_squared_error.error) as number;
if (meanSquaredError) {
meanSquaredError = Number(meanSquaredError.toPrecision(DEFAULT_SIG_FIGS));
}
let rSquared = idx(response, _ => _.regression.r_squared.value) as number;
if (rSquared) {
rSquared = Number(rSquared.toPrecision(DEFAULT_SIG_FIGS));
}
return { meanSquaredError, rSquared };
}
export const EvaluatePanel: FC<Props> = ({ jobId, index, dependentVariable }) => {
const [trainingEval, setTrainingEval] = useState<Eval>(defaultEval);
const [generalizationEval, setGeneralizationEval] = useState<Eval>(defaultEval);
const [isLoadingTraining, setIsLoadingTraining] = useState<boolean>(false);
const [isLoadingGeneralization, setIsLoadingGeneralization] = useState<boolean>(false);
const loadEvalData = async (isTraining: boolean) => {
const results: LoadEvaluateResult = { success: false, eval: null, error: null };
const config = {
index,
query: {
term: {
'ml.is_training': {
value: isTraining,
},
},
},
evaluation: {
regression: {
actual_field: dependentVariable,
predicted_field: `ml.${dependentVariable}_prediction`,
metrics: {
r_squared: {},
mean_squared_error: {},
},
},
},
};
try {
const evalResult = await ml.dataFrameAnalytics.evaluateDataFrameAnalytics(config);
results.success = true;
results.eval = evalResult;
return results;
} catch (e) {
results.error = getErrorMessage(e);
return results;
}
};
const loadData = async () => {
setIsLoadingGeneralization(true);
setIsLoadingTraining(true);
const genErrorEval = await loadEvalData(false);
if (genErrorEval.success === true && genErrorEval.eval) {
const { meanSquaredError, rSquared } = getValuesFromResponse(genErrorEval.eval);
setGeneralizationEval({
meanSquaredError,
rSquared,
error: null,
});
setIsLoadingGeneralization(false);
} else {
setIsLoadingGeneralization(false);
setGeneralizationEval({
meanSquaredError: '',
rSquared: '',
error: genErrorEval.error,
});
}
const trainingErrorEval = await loadEvalData(true);
if (trainingErrorEval.success === true && trainingErrorEval.eval) {
const { meanSquaredError, rSquared } = getValuesFromResponse(trainingErrorEval.eval);
setTrainingEval({
meanSquaredError,
rSquared,
error: null,
});
setIsLoadingTraining(false);
} else {
setIsLoadingTraining(false);
setTrainingEval({
meanSquaredError: '',
rSquared: '',
error: genErrorEval.error,
});
}
};
useEffect(() => {
loadData();
}, []);
return (
<EuiPanel>
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.jobIdTitle', {
defaultMessage: 'Job ID {jobId}',
values: { jobId },
})}
</span>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="s">
<span>
{i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle',
{
defaultMessage: 'Generalization error',
}
)}
</span>
</EuiTitle>
<EuiSpacer />
<EuiFlexGroup>
{generalizationEval.error !== null && <ErrorCallout error={generalizationEval.error} />}
{generalizationEval.error === null && (
<Fragment>
<EuiFlexItem>
<EuiStat
reverse
isLoading={isLoadingGeneralization}
title={generalizationEval.meanSquaredError}
description={meanSquaredErrorText}
titleSize="m"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiStat
reverse
isLoading={isLoadingGeneralization}
title={generalizationEval.rSquared}
description={rSquaredText}
titleSize="m"
/>
</EuiFlexItem>
</Fragment>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="s">
<span>
{i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.trainingErrorTitle',
{
defaultMessage: 'Training error',
}
)}
</span>
</EuiTitle>
<EuiSpacer />
<EuiFlexGroup>
{trainingEval.error !== null && <ErrorCallout error={trainingEval.error} />}
{trainingEval.error === null && (
<Fragment>
<EuiFlexItem>
<EuiStat
reverse
isLoading={isLoadingTraining}
title={trainingEval.meanSquaredError}
description={meanSquaredErrorText}
titleSize="m"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiStat
reverse
isLoading={isLoadingTraining}
title={trainingEval.rSquared}
description={rSquaredText}
titleSize="m"
/>
</EuiFlexItem>
</Fragment>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { CreateAnalyticsModal } from './create_analytics_modal';
export { RegressionExploration } from './regression_exploration';

View file

@ -0,0 +1,28 @@
/*
* 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 React, { FC, Fragment } from 'react';
// import { i18n } from '@kbn/i18n';
import { EuiSpacer } from '@elastic/eui';
import { EvaluatePanel } from './evaluate_panel';
// import { ResultsTable } from './results_table';
interface Props {
jobId: string;
destIndex: string;
dependentVariable: string;
}
export const RegressionExploration: FC<Props> = ({ jobId, destIndex, dependentVariable }) => {
return (
<Fragment>
<EvaluatePanel jobId={jobId} index={destIndex} dependentVariable={dependentVariable} />
<EuiSpacer />
{/* <ResultsTable jobId={jobId} /> */}
</Fragment>
);
};

View file

@ -50,7 +50,12 @@ module.directive('mlDataFrameAnalyticsExploration', ($injector: InjectorService)
ReactDOM.render(
<I18nContext>
<KibanaContext.Provider value={kibanaContext}>
<Page jobId={globalState.ml.jobId} />
<Page
jobId={globalState.ml.jobId}
analysisType={globalState.ml.analysisType}
destIndex={globalState.ml.destIndex}
depVar={globalState.ml.depVar}
/>
</KibanaContext.Provider>
</I18nContext>,
element[0]

View file

@ -23,8 +23,16 @@ import {
import { NavigationMenu } from '../../../components/navigation_menu/navigation_menu';
import { Exploration } from './components/exploration';
import { RegressionExploration } from './components/regression_exploration';
export const Page: FC<{ jobId: string }> = ({ jobId }) => (
import { ANALYSIS_CONFIG_TYPE } from '../../common/analytics';
export const Page: FC<{
jobId: string;
analysisType: string;
destIndex: string;
depVar: string;
}> = ({ jobId, analysisType, destIndex, depVar }) => (
<Fragment>
<NavigationMenu tabId="data_frame_analytics" />
<EuiPage data-test-subj="mlPageDataFrameAnalyticsExploration">
@ -58,7 +66,10 @@ export const Page: FC<{ jobId: string }> = ({ jobId }) => (
</EuiPageContentHeader>
<EuiPageContentBody>
<EuiSpacer size="l" />
<Exploration jobId={jobId} />
{analysisType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && <Exploration jobId={jobId} />}
{analysisType === ANALYSIS_CONFIG_TYPE.REGRESSION && (
<RegressionExploration jobId={jobId} destIndex={destIndex} dependentVariable={depVar} />
)}
</EuiPageContentBody>
</EuiPageBody>
</EuiPage>

View file

@ -28,7 +28,10 @@ interface DeleteActionProps {
}
export const DeleteAction: FC<DeleteActionProps> = ({ item }) => {
const disabled = item.stats.state === DATA_FRAME_TASK_STATE.STARTED;
const disabled =
item.stats.state === DATA_FRAME_TASK_STATE.STARTED ||
item.stats.state === DATA_FRAME_TASK_STATE.ANALYZING ||
item.stats.state === DATA_FRAME_TASK_STATE.REINDEXING;
const canDeleteDataFrameAnalytics: boolean = checkPermission('canDeleteDataFrameAnalytics');

View file

@ -13,7 +13,7 @@ import {
createPermissionFailureMessage,
} from '../../../../../privilege/check_privilege';
import { isOutlierAnalysis } from '../../../../common/analytics';
import { getAnalysisType, getDependentVar } from '../../../../common/analytics';
import { getResultsUrl, isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from './common';
import { stopAnalytics } from '../../services/analytics_service';
@ -24,10 +24,15 @@ import { DeleteAction } from './action_delete';
export const AnalyticsViewAction = {
isPrimary: true,
render: (item: DataFrameAnalyticsListRow) => {
const analysisType = getAnalysisType(item.config.analysis);
const destIndex = item.config.dest.index;
const dependentVariable = getDependentVar(item.config.analysis);
const url = getResultsUrl(item.id, analysisType, destIndex, dependentVariable);
return (
<EuiButtonEmpty
disabled={!isOutlierAnalysis(item.config.analysis)}
onClick={() => (window.location.href = getResultsUrl(item.id))}
onClick={() => (window.location.href = url)}
size="xs"
color="text"
iconType="visTable"

View file

@ -5,6 +5,7 @@
*/
import { DataFrameAnalyticsId, DataFrameAnalyticsConfig } from '../../../../common';
import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics';
export enum DATA_FRAME_TASK_STATE {
ANALYZING = 'analyzing',
@ -107,6 +108,17 @@ export function isCompletedAnalyticsJob(stats: DataFrameAnalyticsStats) {
return stats.state === DATA_FRAME_TASK_STATE.STOPPED && progress === 100;
}
export function getResultsUrl(jobId: string) {
return `ml#/data_frame_analytics/exploration?_g=(ml:(jobId:${jobId}))`;
export function getResultsUrl(
jobId: string,
analysisType: string,
destIndex: string = '',
dependentVariable: string = ''
) {
const destIndexParam = `,destIndex:${destIndex}`;
const depVarParam = `,depVar:${dependentVariable}`;
const isRegression = analysisType === ANALYSIS_CONFIG_TYPE.REGRESSION;
return `ml#/data_frame_analytics/exploration?_g=(ml:(jobId:${jobId},analysisType:${analysisType}${
isRegression && destIndex !== '' ? destIndexParam : ''
}${isRegression && dependentVariable !== '' ? depVarParam : ''}))`;
}

View file

@ -163,9 +163,12 @@ export const CreateAnalyticsAdvancedEditor: FC<CreateAnalyticsFormProps> = ({ ac
error={
createIndexPattern &&
destinationIndexPatternTitleExists && [
i18n.translate('xpack.ml.dataframe.analytics.create.indexPatternTitleError', {
defaultMessage: 'An index pattern with this title already exists.',
}),
i18n.translate(
'xpack.ml.dataframe.analytics.create.indexPatternAlreadyExistsError',
{
defaultMessage: 'An index pattern with this title already exists.',
}
),
]
}
>

View file

@ -16,7 +16,7 @@ import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'
import { CreateAnalyticsAdvancedEditor } from '../create_analytics_advanced_editor';
import { CreateAnalyticsForm } from '../create_analytics_form';
import { CreateAnalyticsModal } from '../create_analytics_modal';
import { CreateAnalyticsFlyout } from '../create_analytics_flyout';
export const CreateAnalyticsButton: FC<CreateAnalyticsFormProps> = props => {
const { disabled, isAdvancedEditorEnabled, isModalVisible } = props.state;
@ -52,10 +52,10 @@ export const CreateAnalyticsButton: FC<CreateAnalyticsFormProps> = props => {
<Fragment>
{button}
{isModalVisible && (
<CreateAnalyticsModal {...props}>
<CreateAnalyticsFlyout {...props}>
{isAdvancedEditorEnabled === false && <CreateAnalyticsForm {...props} />}
{isAdvancedEditorEnabled === true && <CreateAnalyticsAdvancedEditor {...props} />}
</CreateAnalyticsModal>
</CreateAnalyticsFlyout>
)}
</Fragment>
);

View file

@ -0,0 +1,3 @@
.mlAnalyticsCreateFlyout__footerButton {
float: right;
}

View file

@ -8,7 +8,7 @@ import { mount } from 'enzyme';
import React from 'react';
import { mountHook } from '../../../../../../../../../test_utils/enzyme_helpers';
import { CreateAnalyticsModal } from './create_analytics_modal';
import { CreateAnalyticsFlyout } from './create_analytics_flyout';
import { KibanaContext } from '../../../../../contexts/kibana';
import { kibanaContextValueMock } from '../../../../../contexts/kibana/__mocks__/kibana_context_value';
@ -34,12 +34,14 @@ jest.mock('react', () => {
return { ...r, memo: (x: any) => x };
});
describe('Data Frame Analytics: <CreateAnalyticsModal />', () => {
describe('Data Frame Analytics: <CreateAnalyticsFlyout />', () => {
test('Minimal initialization', () => {
const { getLastHookValue } = getMountedHook();
const props = getLastHookValue();
const wrapper = mount(<CreateAnalyticsModal {...props} />);
const wrapper = mount(<CreateAnalyticsFlyout {...props} />);
expect(wrapper.find('EuiModalHeaderTitle').text()).toBe('Create analytics job');
expect(wrapper.find('[data-test-subj="mlDataFrameAnalyticsFlyoutHeaderTitle"]').text()).toBe(
'Create analytics job'
);
});
});

View file

@ -0,0 +1,86 @@
/*
* 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 React, { FC } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiFlyoutFooter,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form';
export const CreateAnalyticsFlyout: FC<CreateAnalyticsFormProps> = ({
actions,
children,
state,
}) => {
const { closeModal, createAnalyticsJob, startAnalyticsJob } = actions;
const { isJobCreated, isJobStarted, isModalButtonDisabled, isValid } = state;
return (
<EuiFlyout size="s" onClose={closeModal}>
<EuiFlyoutHeader>
<EuiTitle>
<h2 data-test-subj="mlDataFrameAnalyticsFlyoutHeaderTitle">
{i18n.translate('xpack.ml.dataframe.analytics.create.flyoutHeaderTitle', {
defaultMessage: 'Create analytics job',
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>{children}</EuiFlyoutBody>
<EuiFlyoutFooter>
{(!isJobCreated || !isJobStarted) && (
<EuiButtonEmpty onClick={closeModal}>
{i18n.translate('xpack.ml.dataframe.analytics.create.flyoutCancelButton', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
)}
{!isJobCreated && !isJobStarted && (
<EuiButton
className="mlAnalyticsCreateFlyout__footerButton"
disabled={!isValid || isModalButtonDisabled}
onClick={createAnalyticsJob}
fill
>
{i18n.translate('xpack.ml.dataframe.analytics.create.flyoutCreateButton', {
defaultMessage: 'Create',
})}
</EuiButton>
)}
{isJobCreated && !isJobStarted && (
<EuiButton
className="mlAnalyticsCreateFlyout__footerButton"
disabled={isModalButtonDisabled}
onClick={startAnalyticsJob}
fill
>
{i18n.translate('xpack.ml.dataframe.analytics.create.flyoutStartButton', {
defaultMessage: 'Start',
})}
</EuiButton>
)}
{isJobCreated && isJobStarted && (
<EuiButton onClick={closeModal} fill>
{i18n.translate('xpack.ml.dataframe.analytics.create.flyoutCloseButton', {
defaultMessage: 'Close',
})}
</EuiButton>
)}
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { CreateAnalyticsFlyout } from './create_analytics_flyout';

View file

@ -38,17 +38,24 @@ describe('Data Frame Analytics: <CreateAnalyticsForm />', () => {
test('Minimal initialization', () => {
const { getLastHookValue } = getMountedHook();
const props = getLastHookValue();
const wrapper = mount(<CreateAnalyticsForm {...props} />);
const wrapper = mount(
<KibanaContext.Provider value={kibanaContextValueMock}>
<CreateAnalyticsForm {...props} />
</KibanaContext.Provider>
);
const euiFormRows = wrapper.find('EuiFormRow');
expect(euiFormRows.length).toBe(5);
expect(euiFormRows.length).toBe(6);
const row1 = euiFormRows.at(0);
expect(row1.find('label').text()).toBe('Job type');
expect(row1.find('EuiText').text()).toBe('Outlier detection');
expect(row1.find('EuiLink').text()).toBe('advanced editor');
const options = row1.find('option');
expect(options.at(0).props().value).toBe('');
expect(options.at(1).props().value).toBe('outlier_detection');
expect(options.at(2).props().value).toBe('regression');
const row2 = euiFormRows.at(1);
expect(row2.find('label').text()).toBe('Job ID');
expect(row2.find('label').text()).toBe('Enable advanced editor');
});
});

View file

@ -4,47 +4,67 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC } from 'react';
import React, { Fragment, FC, useEffect } from 'react';
import {
EuiCallOut,
EuiComboBox,
EuiForm,
EuiFieldText,
EuiFormRow,
EuiLink,
EuiSpacer,
EuiRange,
EuiSwitch,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { metadata } from 'ui/metadata';
import { INDEX_PATTERN_ILLEGAL_CHARACTERS } from 'ui/index_patterns';
import { IndexPattern, INDEX_PATTERN_ILLEGAL_CHARACTERS } from 'ui/index_patterns';
import { Field, EVENT_RATE_FIELD_ID } from '../../../../../../common/types/fields';
import { newJobCapsService } from '../../../../../services/new_job_capabilities_service';
import { useKibanaContext } from '../../../../../contexts/kibana';
import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form';
import { JOB_TYPES } from '../../hooks/use_create_analytics_form/state';
import { JOB_ID_MAX_LENGTH } from '../../../../../../common/constants/validation';
import { Messages } from './messages';
import { JobType } from './job_type';
// based on code used by `ui/index_patterns` internally
// remove the space character from the list of illegal characters
INDEX_PATTERN_ILLEGAL_CHARACTERS.pop();
const characterList = INDEX_PATTERN_ILLEGAL_CHARACTERS.join(', ');
const NUMERICAL_FIELD_TYPES = new Set([
'long',
'integer',
'short',
'byte',
'double',
'float',
'half_float',
'scaled_float',
]);
export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, state }) => {
const { setFormState } = actions;
const kibanaContext = useKibanaContext();
const {
form,
indexPatternsMap,
indexPatternsWithNumericFields,
indexPatternTitles,
isAdvancedEditorEnabled,
isJobCreated,
requestMessages,
} = state;
const {
createIndexPattern,
dependentVariable,
dependentVariableFetchFail,
dependentVariableOptions,
destinationIndex,
destinationIndexNameEmpty,
destinationIndexNameExists,
@ -55,54 +75,78 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, sta
jobIdExists,
jobIdValid,
jobIdInvalidMaxLength,
jobType,
loadingDepFieldOptions,
sourceIndex,
sourceIndexNameEmpty,
sourceIndexNameValid,
trainingPercent,
} = form;
const loadDependentFieldOptions = async () => {
setFormState({ loadingDepFieldOptions: true, dependentVariable: '' });
try {
const indexPattern: IndexPattern = await kibanaContext.indexPatterns.get(
indexPatternsMap[sourceIndex]
);
if (indexPattern !== undefined) {
await newJobCapsService.initializeFromIndexPattern(indexPattern);
// Get fields and filter for numeric
const { fields } = newJobCapsService;
const options: Array<{ label: string }> = [];
fields.forEach((field: Field) => {
if (NUMERICAL_FIELD_TYPES.has(field.type) && field.id !== EVENT_RATE_FIELD_ID) {
options.push({ label: field.id });
}
});
setFormState({
dependentVariableOptions: options,
loadingDepFieldOptions: false,
dependentVariableFetchFail: false,
});
}
} catch (e) {
// TODO: ensure error messages show up correctly
setFormState({ loadingDepFieldOptions: false, dependentVariableFetchFail: true });
}
};
useEffect(() => {
if (jobType === JOB_TYPES.REGRESSION && sourceIndexNameEmpty === false) {
loadDependentFieldOptions();
}
}, [sourceIndex, jobType, sourceIndexNameEmpty]);
return (
<EuiForm className="mlDataFrameAnalyticsCreateForm">
{requestMessages.map((requestMessage, i) => (
<Fragment key={i}>
<EuiCallOut
title={requestMessage.message}
color={requestMessage.error !== undefined ? 'danger' : 'primary'}
iconType={requestMessage.error !== undefined ? 'alert' : 'checkInCircleFilled'}
size="s"
>
{requestMessage.error !== undefined ? <p>{requestMessage.error}</p> : null}
</EuiCallOut>
<EuiSpacer size="s" />
</Fragment>
))}
<Messages messages={requestMessages} />
{!isJobCreated && (
<Fragment>
<JobType type={jobType} setFormState={setFormState} />
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.analytics.create.jobTypeLabel', {
defaultMessage: 'Job type',
})}
helpText={
<FormattedMessage
id="xpack.ml.dataframe.analytics.create.jobTypeHelpText"
defaultMessage="Outlier detection jobs require a source index that is mapped as a table-like data structure and will only analyze numeric and boolean fields. Please use the {advancedEditorButton} to apply custom options such as the model memory limit and analysis type. You cannot switch back to this form from the advanced editor."
values={{
advancedEditorButton: (
<EuiLink onClick={actions.switchToAdvancedEditor}>
<FormattedMessage
id="xpack.ml.dataframe.analytics.create.switchToAdvancedEditorButton"
defaultMessage="advanced editor"
/>
</EuiLink>
),
}}
/>
}
helpText={i18n.translate(
'xpack.ml.dataframe.analytics.create.enableAdvancedEditorHelpText',
{
defaultMessage: 'You cannot switch back to this form from the advanced editor.',
}
)}
>
<EuiText>
{i18n.translate('xpack.ml.dataframe.analytics.create.outlierDetectionText', {
defaultMessage: 'Outlier detection',
})}
</EuiText>
<EuiSwitch
disabled={jobType === undefined}
compressed={true}
name="mlDataFrameAnalyticsEnableAdvancedEditor"
label={i18n.translate(
'xpack.ml.dataframe.analytics.create.enableAdvancedEditorSwitch',
{
defaultMessage: 'Enable advanced editor',
}
)}
checked={isAdvancedEditorEnabled}
onChange={actions.switchToAdvancedEditor}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.analytics.create.jobIdLabel', {
@ -157,6 +201,7 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, sta
isInvalid={(!jobIdEmpty && !jobIdValid) || jobIdExists}
/>
</EuiFormRow>
{/* TODO: Does the source index message below apply for regression jobs as well? Same for all validation messages below */}
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.analytics.create.sourceIndexLabel', {
defaultMessage: 'Source index',
@ -267,12 +312,79 @@ export const CreateAnalyticsForm: FC<CreateAnalyticsFormProps> = ({ actions, sta
isInvalid={!destinationIndexNameEmpty && !destinationIndexNameValid}
/>
</EuiFormRow>
{jobType === JOB_TYPES.REGRESSION && (
<Fragment>
<EuiFormRow
label={i18n.translate(
'xpack.ml.dataframe.analytics.create.dependentVariableLabel',
{
defaultMessage: 'Dependent variable',
}
)}
error={
dependentVariableFetchFail === true && [
<Fragment>
{i18n.translate(
'xpack.ml.dataframe.analytics.create.dependentVariableOptionsFetchError',
{
defaultMessage:
'There was a problem fetching fields. Please refresh the page and try again.',
}
)}
</Fragment>,
]
}
>
<EuiComboBox
aria-label={i18n.translate(
'xpack.ml.dataframe.analytics.create.dependentVariableInputAriaLabel',
{
defaultMessage: 'Enter field to be used as dependent variable.',
}
)}
placeholder={i18n.translate(
'xpack.ml.dataframe.analytics.create.dependentVariablePlaceholder',
{
defaultMessage: 'dependent variable',
}
)}
isDisabled={isJobCreated}
isLoading={loadingDepFieldOptions}
singleSelection={true}
options={dependentVariableOptions}
selectedOptions={dependentVariable ? [{ label: dependentVariable }] : []}
onChange={selectedOptions =>
setFormState({ dependentVariable: selectedOptions[0].label || '' })
}
isClearable={false}
isInvalid={dependentVariable === ''}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.analytics.create.trainingPercentLabel', {
defaultMessage: 'Training percent',
})}
>
<EuiRange
min={0}
max={100}
step={1}
showLabels
showRange
showValue
value={trainingPercent}
// @ts-ignore Property 'value' does not exist on type 'EventTarget' | (EventTarget & HTMLInputElement)
onChange={e => setFormState({ trainingPercent: e.target.value })}
/>
</EuiFormRow>
</Fragment>
)}
<EuiFormRow
isInvalid={createIndexPattern && destinationIndexPatternTitleExists}
error={
createIndexPattern &&
destinationIndexPatternTitleExists && [
i18n.translate('xpack.ml.dataframe.analytics.create.indexPatternTitleError', {
i18n.translate('xpack.ml.dataframe.analytics.create.indexPatternExistsError', {
defaultMessage: 'An index pattern with this title already exists.',
}),
]

View file

@ -0,0 +1,64 @@
/*
* 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 React, { Fragment, FC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiSelect } from '@elastic/eui';
import { AnalyticsJobType, JOB_TYPES } from '../../hooks/use_create_analytics_form/state';
interface Props {
type: AnalyticsJobType;
setFormState: any; // TODO update type
}
export const JobType: FC<Props> = ({ type, setFormState }) => {
const outlierHelpText = i18n.translate(
'xpack.ml.dataframe.analytics.create.outlierDetectionHelpText',
{
defaultMessage:
'Outlier detection jobs require a source index that is mapped as a table-like data structure and will only analyze numeric and boolean fields. Please use the advanced editor to apply custom options such as the model memory limit and analysis type.',
}
);
const regressionHelpText = i18n.translate(
'xpack.ml.dataframe.analytics.create.outlierRegressionHelpText',
{
defaultMessage:
'Regression jobs will only analyze numeric fields. Please use the advanced editor to apply custom options such as the model memory limit and prediction field name.',
}
);
const helpText = {
outlier_detection: outlierHelpText,
regression: regressionHelpText,
};
return (
<Fragment>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.analytics.create.jobTypeLabel', {
defaultMessage: 'Job type',
})}
helpText={type !== undefined ? helpText[type] : ''}
>
<EuiSelect
options={Object.values(JOB_TYPES).map(jobType => ({
value: jobType,
text: jobType.replace(/_/g, ' '),
}))}
value={type}
hasNoInitialSelection={true}
onChange={e => {
const value = e.target.value as AnalyticsJobType;
setFormState({ jobType: value });
}}
/>
</EuiFormRow>
</Fragment>
);
};

View file

@ -0,0 +1,30 @@
/*
* 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 React, { Fragment, FC } from 'react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { FormMessage } from '../../hooks/use_create_analytics_form/state'; // State
interface Props {
messages: any; // TODO: fix --> something like State['requestMessages'];
}
export const Messages: FC<Props> = ({ messages }) =>
messages.map((requestMessage: FormMessage, i: number) => (
<Fragment key={i}>
<EuiCallOut
title={requestMessage.message}
color={requestMessage.error !== undefined ? 'danger' : 'primary'}
iconType={requestMessage.error !== undefined ? 'alert' : 'checkInCircleFilled'}
size="s"
>
{requestMessage.error !== undefined ? <p>{requestMessage.error}</p> : null}
</EuiCallOut>
<EuiSpacer size="s" />
</Fragment>
));

View file

@ -1,91 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiOverlayMask,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form';
export const CreateAnalyticsModal: FC<CreateAnalyticsFormProps> = ({
actions,
children,
state,
}) => {
const { closeModal, createAnalyticsJob, startAnalyticsJob } = actions;
const {
isAdvancedEditorEnabled,
isJobCreated,
isJobStarted,
isModalButtonDisabled,
isValid,
} = state;
const width = isAdvancedEditorEnabled ? '640px' : '450px';
return (
<EuiOverlayMask>
<EuiModal onClose={closeModal} style={{ width }}>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('xpack.ml.dataframe.analytics.create.modalHeaderTitle', {
defaultMessage: 'Create analytics job',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>{children}</EuiModalBody>
<EuiModalFooter>
{(!isJobCreated || !isJobStarted) && (
<EuiButtonEmpty onClick={closeModal}>
{i18n.translate('xpack.ml.dataframe.analytics.create.modalCancelButton', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
)}
{!isJobCreated && !isJobStarted && (
<EuiButton
disabled={!isValid || isModalButtonDisabled}
onClick={createAnalyticsJob}
fill
>
{i18n.translate('xpack.ml.dataframe.analytics.create.modalCreateButton', {
defaultMessage: 'Create',
})}
</EuiButton>
)}
{isJobCreated && !isJobStarted && (
<EuiButton disabled={isModalButtonDisabled} onClick={startAnalyticsJob} fill>
{i18n.translate('xpack.ml.dataframe.analytics.create.modalStartButton', {
defaultMessage: 'Start',
})}
</EuiButton>
)}
{isJobCreated && isJobStarted && (
<EuiButton onClick={closeModal} fill>
{i18n.translate('xpack.ml.dataframe.analytics.create.modalCloseButton', {
defaultMessage: 'Close',
})}
</EuiButton>
)}
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
};

View file

@ -4,4 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { useCreateAnalyticsForm, CreateAnalyticsFormProps } from './use_create_analytics_form';
export {
useCreateAnalyticsForm,
CreateAnalyticsFormProps,
getErrorMessage,
} from './use_create_analytics_form';

View file

@ -9,21 +9,32 @@ import { checkPermission } from '../../../../../privilege/check_privilege';
import { DataFrameAnalyticsId, DataFrameAnalyticsConfig } from '../../../../common';
const ANALYTICS_DETAULT_MODEL_MEMORY_LIMIT = '50mb';
const OUTLIER_DETECTION_DEFAULT_MODEL_MEMORY_LIMIT = '50mb';
const REGRESSION_DEFAULT_MODEL_MEMORY_LIMIT = '100mb';
export type EsIndexName = string;
export type DependentVariable = string;
export type IndexPatternTitle = string;
export type AnalyticsJobType = JOB_TYPES | undefined;
export interface FormMessage {
error?: string;
message: string;
}
export enum JOB_TYPES {
OUTLIER_DETECTION = 'outlier_detection',
REGRESSION = 'regression',
}
export interface State {
advancedEditorMessages: FormMessage[];
advancedEditorRawString: string;
form: {
createIndexPattern: boolean;
dependentVariable: DependentVariable;
dependentVariableFetchFail: boolean;
dependentVariableOptions: Array<{ label: DependentVariable }> | [];
destinationIndex: EsIndexName;
destinationIndexNameExists: boolean;
destinationIndexNameEmpty: boolean;
@ -34,12 +45,16 @@ export interface State {
jobIdEmpty: boolean;
jobIdInvalidMaxLength: boolean;
jobIdValid: boolean;
jobType: AnalyticsJobType;
loadingDepFieldOptions: boolean;
sourceIndex: EsIndexName;
sourceIndexNameEmpty: boolean;
sourceIndexNameValid: boolean;
trainingPercent: number;
};
disabled: boolean;
indexNames: EsIndexName[];
indexPatternsMap: any; // TODO: update type
indexPatternTitles: IndexPatternTitle[];
indexPatternsWithNumericFields: IndexPatternTitle[];
isAdvancedEditorEnabled: boolean;
@ -58,6 +73,9 @@ export const getInitialState = (): State => ({
advancedEditorRawString: '',
form: {
createIndexPattern: false,
dependentVariable: '',
dependentVariableFetchFail: false,
dependentVariableOptions: [],
destinationIndex: '',
destinationIndexNameExists: false,
destinationIndexNameEmpty: true,
@ -68,15 +86,19 @@ export const getInitialState = (): State => ({
jobIdEmpty: true,
jobIdInvalidMaxLength: false,
jobIdValid: false,
jobType: undefined,
loadingDepFieldOptions: false,
sourceIndex: '',
sourceIndexNameEmpty: true,
sourceIndexNameValid: false,
trainingPercent: 80,
},
jobConfig: {},
disabled:
!checkPermission('canCreateDataFrameAnalytics') ||
!checkPermission('canStartStopDataFrameAnalytics'),
indexNames: [],
indexPatternsMap: {},
indexPatternTitles: [],
indexPatternsWithNumericFields: [],
isAdvancedEditorEnabled: false,
@ -92,7 +114,12 @@ export const getInitialState = (): State => ({
export const getJobConfigFromFormState = (
formState: State['form']
): DeepPartial<DataFrameAnalyticsConfig> => {
return {
const modelMemoryLimit =
formState.jobType === JOB_TYPES.REGRESSION
? REGRESSION_DEFAULT_MODEL_MEMORY_LIMIT
: OUTLIER_DETECTION_DEFAULT_MODEL_MEMORY_LIMIT;
const jobConfig: DeepPartial<DataFrameAnalyticsConfig> = {
source: {
// If a Kibana index patterns includes commas, we need to split
// the into an array of indices to be in the correct format for
@ -110,6 +137,17 @@ export const getJobConfigFromFormState = (
analysis: {
outlier_detection: {},
},
model_memory_limit: ANALYTICS_DETAULT_MODEL_MEMORY_LIMIT,
model_memory_limit: modelMemoryLimit,
};
if (formState.jobType === JOB_TYPES.REGRESSION) {
jobConfig.analysis = {
regression: {
dependent_variable: formState.dependentVariable,
training_percent: formState.trainingPercent,
},
};
}
return jobConfig;
};

View file

@ -69,6 +69,7 @@ export const useCreateAnalyticsForm = () => {
const setIndexPatternTitles = (payload: {
indexPatternTitles: IndexPatternTitle[];
indexPatternsWithNumericFields: IndexPatternTitle[];
indexPatternsMap: any; // TODO: update this type
}) => dispatch({ type: ACTION.SET_INDEX_PATTERN_TITLES, payload });
const setIsJobCreated = (isJobCreated: boolean) =>
@ -235,6 +236,7 @@ export const useCreateAnalyticsForm = () => {
// able to identify outliers if there are no numeric fields present.
const ids = await kibanaContext.indexPatterns.getIds(true);
const indexPatternsWithNumericFields: IndexPatternTitle[] = [];
const indexPatternsMap = {}; // TODO: add type, add to state to keep track of
ids
.filter(f => !!f)
.forEach(async id => {
@ -246,9 +248,15 @@ export const useCreateAnalyticsForm = () => {
.includes('number')
) {
indexPatternsWithNumericFields.push(indexPattern.title);
// @ts-ignore TODO: fix this type
indexPatternsMap[indexPattern.title] = id;
}
});
setIndexPatternTitles({ indexPatternTitles, indexPatternsWithNumericFields });
setIndexPatternTitles({
indexPatternTitles,
indexPatternsWithNumericFields,
indexPatternsMap,
});
} catch (e) {
addRequestMessage({
error: getErrorMessage(e),

View file

@ -40,6 +40,13 @@ export const dataFrameAnalytics = {
data: analyticsConfig
});
},
evaluateDataFrameAnalytics(evaluateConfig) {
return http({
url: `${basePath}/data_frame/_evaluate`,
method: 'POST',
data: evaluateConfig
});
},
deleteDataFrameAnalytics(analyticsId) {
return http({
url: `${basePath}/data_frame/analytics/${analyticsId}`,

View file

@ -45,6 +45,7 @@ declare interface Ml {
getDataFrameAnalytics(analyticsId?: string): Promise<any>;
getDataFrameAnalyticsStats(analyticsId?: string): Promise<any>;
createDataFrameAnalytics(analyticsId: string, analyticsConfig: any): Promise<any>;
evaluateDataFrameAnalytics(evaluateConfig: any): Promise<any>;
deleteDataFrameAnalytics(analyticsId: string): Promise<any>;
startDataFrameAnalytics(analyticsId: string): Promise<any>;
stopDataFrameAnalytics(

View file

@ -160,6 +160,16 @@ export const elasticsearchJsPlugin = (Client, config, components) => { // eslint
method: 'PUT'
});
ml.evaluateDataFrameAnalytics = ca({
urls: [
{
fmt: '/_ml/data_frame/_evaluate',
}
],
needBody: true,
method: 'POST'
});
ml.deleteDataFrameAnalytics = ca({
urls: [
{

View file

@ -78,6 +78,19 @@ export function dataFrameAnalyticsRoutes({ commonRouteConfig, elasticsearchPlugi
}
});
route({
method: 'POST',
path: '/api/ml/data_frame/_evaluate',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
return callWithRequest('ml.evaluateDataFrameAnalytics', { body: request.payload })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
route({
method: 'DELETE',
path: '/api/ml/data_frame/analytics/{analyticsId}',

View file

@ -7266,27 +7266,18 @@
"xpack.ml.dataframe.analytics.create.errorGettingDataFrameIndexNames": "获取现有索引名称时发生错误:",
"xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "获取现有索引模式标题时发生错误:",
"xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "启动数据帧分析作业时发生错误:",
"xpack.ml.dataframe.analytics.create.indexPatternTitleError": "具有此名称的索引模式已存在。",
"xpack.ml.dataframe.analytics.create.jobIdExistsError": "已存在具有此 ID 的分析作业。",
"xpack.ml.dataframe.analytics.create.jobIdInputAriaLabel": "选择唯一的分析作业 ID。",
"xpack.ml.dataframe.analytics.create.jobIdInvalidError": "只能包含小写字母数字字符a-z 和 0-9、连字符和下划线并且必须以字母数字字符开头和结尾。",
"xpack.ml.dataframe.analytics.create.jobIdLabel": "作业 ID",
"xpack.ml.dataframe.analytics.create.jobIdPlaceholder": "作业 ID",
"xpack.ml.dataframe.analytics.create.jobTypeHelpText": "离群值检测作业需要映射为类表数据结构的源索引,将仅分析数值和布尔值字段。请使用“{advancedEditorButton}”应用定制选项,如模型内存限制和分析类型。您不能从高级编辑器切换回到此表单。",
"xpack.ml.dataframe.analytics.create.jobTypeLabel": "作业类型",
"xpack.ml.dataframe.analytics.create.modalCancelButton": "取消",
"xpack.ml.dataframe.analytics.create.modalCloseButton": "关闭",
"xpack.ml.dataframe.analytics.create.modalCreateButton": "创建",
"xpack.ml.dataframe.analytics.create.modalHeaderTitle": "创建分析作业",
"xpack.ml.dataframe.analytics.create.modalStartButton": "开始",
"xpack.ml.dataframe.analytics.create.outlierDetectionText": "离群值检测",
"xpack.ml.dataframe.analytics.create.sourceIndexHelpText": "此索引模式不包含任何数值类型字段。分析作业可能无法提供任何离群值。",
"xpack.ml.dataframe.analytics.create.sourceIndexInputAriaLabel": "源索引模式或搜索。",
"xpack.ml.dataframe.analytics.create.sourceIndexInvalidError": "源索引名称无效,其不能包含空格或以下字符:{characterList}",
"xpack.ml.dataframe.analytics.create.sourceIndexLabel": "源索引",
"xpack.ml.dataframe.analytics.create.sourceIndexPlaceholder": "选择源索引模式或已保存搜索。",
"xpack.ml.dataframe.analytics.create.startDataFrameAnalyticsSuccessMessage": "分析作业 {jobId} 已启动。",
"xpack.ml.dataframe.analytics.create.switchToAdvancedEditorButton": "高级编辑器",
"xpack.ml.dataframe.analytics.exploration.experimentalBadgeLabel": "实验性",
"xpack.ml.dataframe.analytics.exploration.experimentalBadgeTooltipContent": "数据帧分析为实验功能。我们很乐意听取您的反馈意见。",
"xpack.ml.dataframe.analytics.exploration.fieldSelection": "已选择 {selectedFieldsLength, number} 个{docFieldsCount, plural, one {字段} other {字段}},共 {docFieldsCount, number} 个",