[ML] Data Frames: Fix form validation (#40427)

Fixes the form validation of transform id and destination index name.

- In addition to checking against existing transform ids, the input is now validated according to the data frame API docs
- The destination index input field is now validated against the index name limitations found in Elasticsearch's documentation. Since these rules are too much to be added as a helper text, a link to the docs is part of the helper text.
- If the destination index name matches an existing index, the Next button is no longer disabled, but the helper text for the input field states that creating this transform will modify the existing destination index.
This commit is contained in:
Walter Rafelsberger 2019-07-08 14:42:17 +02:00 committed by GitHub
parent 3c7702227d
commit f32a20ca20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 164 additions and 21 deletions

View file

@ -0,0 +1,57 @@
/*
* 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 { isValidIndexName } from './es_utils';
describe('Util: isValidIndexName()', () => {
test('Validation checks.', () => {
// Lowercase only
expect(isValidIndexName('lorem')).toBe(true);
expect(isValidIndexName('loRem')).toBe(false);
// Cannot include \, /, *, ?, ", <, >, |, space character, comma, #, :
expect(isValidIndexName('\\')).toBe(false);
expect(isValidIndexName('/')).toBe(false);
expect(isValidIndexName('*')).toBe(false);
expect(isValidIndexName('?')).toBe(false);
expect(isValidIndexName('"')).toBe(false);
expect(isValidIndexName('<')).toBe(false);
expect(isValidIndexName('>')).toBe(false);
expect(isValidIndexName('|')).toBe(false);
expect(isValidIndexName(' ')).toBe(false);
expect(isValidIndexName(',')).toBe(false);
expect(isValidIndexName('#')).toBe(false);
// Cannot start with -, _, +
expect(isValidIndexName('lorem-ipsum')).toBe(true);
expect(isValidIndexName('lorem_ipsum')).toBe(true);
expect(isValidIndexName('lorem+ipsum')).toBe(true);
expect(isValidIndexName('lorem-')).toBe(true);
expect(isValidIndexName('lorem_')).toBe(true);
expect(isValidIndexName('lorem+')).toBe(true);
expect(isValidIndexName('-lorem')).toBe(false);
expect(isValidIndexName('_lorem')).toBe(false);
expect(isValidIndexName('+lorem')).toBe(false);
// Cannot be . or ..
expect(isValidIndexName('lorem.ipsum')).toBe(true);
expect(isValidIndexName('lorem.')).toBe(true);
expect(isValidIndexName('.lorem')).toBe(true);
expect(isValidIndexName('lorem..ipsum')).toBe(true);
expect(isValidIndexName('lorem..')).toBe(true);
expect(isValidIndexName('..lorem')).toBe(true);
expect(isValidIndexName('.')).toBe(false);
expect(isValidIndexName('..')).toBe(false);
// Cannot be longer than 255 bytes (note it is bytes,
// so multi-byte characters will count towards the 255 limit faster)
expect(isValidIndexName('a'.repeat(255))).toBe(true);
expect(isValidIndexName('a'.repeat(256))).toBe(false);
// multi-byte character test
// because jest doesn't have TextEncoder this will still be true
expect(isValidIndexName('あ'.repeat(255))).toBe(true);
});
});

View file

@ -0,0 +1,41 @@
/*
* 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.
*/
interface WindowWithTextEncoder extends Window {
TextEncoder: any;
}
const windowWithTextEncoder = window as WindowWithTextEncoder;
function isValidIndexNameLength(indexName: string) {
if (
windowWithTextEncoder.TextEncoder &&
new windowWithTextEncoder.TextEncoder('utf-8').encode(indexName).length > 255
) {
return false;
}
// If TextEncoder is not available just check for string.length
return indexName.length <= 255;
}
// rules taken from
// https://github.com/elastic/elasticsearch/blob/master/docs/reference/indices/create-index.asciidoc
export function isValidIndexName(indexName: string) {
return (
// Lowercase only
indexName === indexName.toLowerCase() &&
// Cannot include \, /, *, ?, ", <, >, |, space character, comma, #, :
/^[^\*\\/\?"<>|\s,#:]+$/.test(indexName) &&
// Cannot start with -, _, +
/^[^-_\+]+$/.test(indexName.charAt(0)) &&
// Cannot be . or ..
(indexName !== '.' && indexName !== '..') &&
// Cannot be longer than 255 bytes (note it is bytes,
// so multi-byte characters will count towards the 255 limit faster)
isValidIndexNameLength(indexName)
);
}

View file

@ -7,9 +7,14 @@
import React, { Fragment, SFC, useContext, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { metadata } from 'ui/metadata';
import { toastNotifications } from 'ui/notify';
import { EuiSwitch, EuiFieldText, EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui';
import { EuiLink, EuiSwitch, EuiFieldText, EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui';
// @ts-ignore
import { isJobIdValid } from '../../../../common/util/job_utils';
import { isValidIndexName } from '../../../../common/util/es_utils';
import { ml } from '../../../services/ml_api_service';
@ -124,13 +129,20 @@ export const JobDetailsForm: SFC<Props> = React.memo(({ overrides = {}, onChange
}, []);
const jobIdExists = jobIds.some(id => jobId === id);
const jobIdEmpty = jobId === '';
const jobIdValid = isJobIdValid(jobId);
const indexNameExists = indexNames.some(name => destinationIndex === name);
const indexNameEmpty = destinationIndex === '';
const indexNameValid = isValidIndexName(destinationIndex);
const indexPatternTitleExists = indexPatternTitles.some(name => destinationIndex === name);
const valid =
jobId !== '' &&
destinationIndex !== '' &&
!jobIdEmpty &&
jobIdValid &&
!jobIdExists &&
!indexNameExists &&
!indexNameEmpty &&
indexNameValid &&
(!indexPatternTitleExists || !createIndexPattern) &&
(!isContinuousModeAvailable || (isContinuousModeAvailable && isContinuousModeDelayValid));
@ -164,14 +176,24 @@ export const JobDetailsForm: SFC<Props> = React.memo(({ overrides = {}, onChange
label={i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobIdLabel', {
defaultMessage: 'Transform id',
})}
isInvalid={jobIdExists}
error={
jobIdExists && [
i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobIdError', {
defaultMessage: 'A transform with this id already exists.',
}),
]
}
isInvalid={(!jobIdEmpty && !jobIdValid) || jobIdExists}
error={[
...(!jobIdEmpty && !jobIdValid
? [
i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobIdInvalidError', {
defaultMessage:
'Must contain lowercase alphanumeric characters (a-z and 0-9), hyphens, and underscores only and must start and end with alphanumeric characters.',
}),
]
: []),
...(jobIdExists
? [
i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobIdExistsError', {
defaultMessage: 'A transform with this id already exists.',
}),
]
: []),
]}
>
<EuiFieldText
placeholder="transform id"
@ -180,13 +202,16 @@ export const JobDetailsForm: SFC<Props> = React.memo(({ overrides = {}, onChange
aria-label={i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobIdInputAriaLabel', {
defaultMessage: 'Choose a unique transform id.',
})}
isInvalid={jobIdExists}
isInvalid={(!jobIdEmpty && !jobIdValid) || jobIdExists}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobDescriptionLabel', {
defaultMessage: 'Transform description',
})}
helpText={i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobDescriptionHelpText', {
defaultMessage: 'Optional descriptive text.',
})}
>
<EuiFieldText
placeholder="transform description"
@ -204,12 +229,34 @@ export const JobDetailsForm: SFC<Props> = React.memo(({ overrides = {}, onChange
label={i18n.translate('xpack.ml.dataframe.jobDetailsForm.destinationIndexLabel', {
defaultMessage: 'Destination index',
})}
isInvalid={indexNameExists}
isInvalid={!indexNameEmpty && !indexNameValid}
helpText={
indexNameExists &&
i18n.translate('xpack.ml.dataframe.jobDetailsForm.destinationIndexHelpText', {
defaultMessage:
'An index with this name already exists. Be aware that running this transform will modify this destination index.',
})
}
error={
indexNameExists && [
i18n.translate('xpack.ml.dataframe.jobDetailsForm.destinationIndexError', {
defaultMessage: 'An index with this name already exists.',
}),
!indexNameEmpty &&
!indexNameValid && [
<Fragment>
{i18n.translate('xpack.ml.dataframe.jobDetailsForm.destinationIndexInvalidError', {
defaultMessage: 'Invalid destination index name.',
})}
<br />
<EuiLink
href={`https://www.elastic.co/guide/en/elasticsearch/reference/${metadata.branch}/indices-create-index.html#indices-create-index`}
target="_blank"
>
{i18n.translate(
'xpack.ml.dataframe.definePivotForm.destinationIndexInvalidErrorLink',
{
defaultMessage: 'Learn more about index name limitations.',
}
)}
</EuiLink>
</Fragment>,
]
}
>
@ -223,7 +270,7 @@ export const JobDetailsForm: SFC<Props> = React.memo(({ overrides = {}, onChange
defaultMessage: 'Choose a unique destination index name.',
}
)}
isInvalid={indexNameExists}
isInvalid={!indexNameEmpty && !indexNameValid}
/>
</EuiFormRow>
<EuiFormRow

View file

@ -6219,7 +6219,6 @@
"xpack.ml.dataframe.jobDetailsForm.errorGettingDataFrameJobsList": "既存のデータフレームジョブIDの取得中にエラーが発生しました{error}",
"xpack.ml.dataframe.jobDetailsForm.errorGettingIndexPatternTitles": "既存のインデックスパターンのタイトルの取得中にエラーが発生しました: {error}",
"xpack.ml.dataframe.jobDetailsForm.indexPatternTitleError": "このタイトルのインデックスパターンが既に存在します。",
"xpack.ml.dataframe.jobDetailsForm.jobIdError": "この ID のジョブが既に存在します。",
"xpack.ml.dataframe.jobDetailsForm.jobIdInputAriaLabel": "固有のジョブ ID を選択してください。",
"xpack.ml.dataframe.jobDetailsForm.jobIdLabel": "ジョブ ID",
"xpack.ml.dataframe.jobDetailsSummary.createIndexPatternMessage": "このジョブの Kibana インデックスパターンが作成されます。",

View file

@ -6219,7 +6219,6 @@
"xpack.ml.dataframe.jobDetailsForm.errorGettingDataFrameJobsList": "获取现有数据帧作业 ID 时发生错误:{error}",
"xpack.ml.dataframe.jobDetailsForm.errorGettingIndexPatternTitles": "获取现有索引名称时发生错误:{error}",
"xpack.ml.dataframe.jobDetailsForm.indexPatternTitleError": "具有此名称的索引模式已存在。",
"xpack.ml.dataframe.jobDetailsForm.jobIdError": "已存在具有此 ID 的作业。",
"xpack.ml.dataframe.jobDetailsForm.jobIdInputAriaLabel": "选择唯一的作业 ID。",
"xpack.ml.dataframe.jobDetailsForm.jobIdLabel": "作业 ID",
"xpack.ml.dataframe.jobDetailsSummary.createIndexPatternMessage": "将为此作业创建 Kibana 索引模式。",