diff --git a/x-pack/plugins/cross_cluster_replication/common/services/utils.js b/x-pack/plugins/cross_cluster_replication/common/services/utils.js index b8f245bfaefb4d..83154c7e95caca 100644 --- a/x-pack/plugins/cross_cluster_replication/common/services/utils.js +++ b/x-pack/plugins/cross_cluster_replication/common/services/utils.js @@ -15,3 +15,15 @@ export const wait = (time = 1000) => (data) => { setTimeout(() => resolve(data), time); }); }; + +/** + * Utility to remove empty fields ("") from a request body + */ +export const removeEmptyFields = (body) => ( + Object.entries(body).reduce((acc, [key, value]) => { + if (value !== '') { + acc[key] = value; + } + return acc; + }, {}) +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/__snapshots__/follower_index_form.test.js.snap b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap similarity index 100% rename from x-pack/plugins/cross_cluster_replication/public/app/components/__snapshots__/follower_index_form.test.js.snap rename to x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js new file mode 100644 index 00000000000000..da0dfe854ba3a5 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js @@ -0,0 +1,232 @@ +/* + * 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 from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { byteUnitsUrl, timeUnitsUrl } from '../../services/documentation_links'; + +const byteUnitsHelpText = ( + + + + ) }} + /> +); + +const timeUnitsHelpText = ( + + + + ) }} + /> +); + +export const advancedSettingsFields = [ + { + field: 'maxReadRequestOperationCount', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountTitle', { + defaultMessage: 'Max read request operation count' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountDescription', { + defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.' + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountLabel', { + defaultMessage: 'Max read request operation count (optional)' + } + ), + type: 'number', + }, { + field: 'maxOutstandingReadRequests', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle', { + defaultMessage: 'Max outstanding read requests' + } + ), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', { + defaultMessage: 'The maximum number of outstanding read requests from the remote cluster.' + }), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsLabel', { + defaultMessage: 'Max outstanding read requests (optional)' + } + ), + type: 'number', + }, { + field: 'maxReadRequestSize', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle', { + defaultMessage: 'Max read request size' + } + ), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', { + defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster.' + }), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeLabel', { + defaultMessage: 'Max read request size (optional)' + } + ), + helpText: byteUnitsHelpText, + }, { + field: 'maxWriteRequestOperationCount', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle', { + defaultMessage: 'Max write request operation count' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountDescription', { + defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.' + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountLabel', { + defaultMessage: 'Max write request operation count (optional)' + } + ), + type: 'number', + }, { + field: 'maxWriteRequestSize', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle', { + defaultMessage: 'Max write request size' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeDescription', { + defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.' + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeLabel', { + defaultMessage: 'Max write request size (optional)' + } + ), + helpText: byteUnitsHelpText, + }, { + field: 'maxOutstandingWriteRequests', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle', { + defaultMessage: 'Max outstanding write requests' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsDescription', { + defaultMessage: 'The maximum number of outstanding write requests on the follower.' + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsLabel', { + defaultMessage: 'Max outstanding write requests (optional)' + } + ), + type: 'number', + }, { + field: 'maxWriteBufferCount', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle', { + defaultMessage: 'Max write buffer count' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountDescription', { + defaultMessage: `The maximum number of operations that can be queued for writing; when this + limit is reached, reads from the remote cluster will be deferred until the number of queued + operations goes below the limit.` + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountLabel', { + defaultMessage: 'Max write buffer count (optional)' + } + ), + type: 'number', + }, { + field: 'maxWriteBufferSize', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle', { + defaultMessage: 'Max write buffer size' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeDescription', { + defaultMessage: `The maximum total bytes of operations that can be queued for writing; when + this limit is reached, reads from the remote cluster will be deferred until the total bytes + of queued operations goes below the limit.` + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeLabel', { + defaultMessage: 'Max write buffer size (optional)' + } + ), + helpText: byteUnitsHelpText, + }, { + field: 'maxRetryDelay', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle', { + defaultMessage: 'Max retry delay' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayDescription', { + defaultMessage: `The maximum time to wait before retrying an operation that failed exceptionally; + an exponential backoff strategy is employed when retrying.` + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayLabel', { + defaultMessage: 'Max retry delay (optional)' + } + ), + helpText: timeUnitsHelpText, + }, { + field: 'readPollTimeout', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle', { + defaultMessage: 'Read poll timeout' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutDescription', { + defaultMessage: `The maximum time to wait for new operations on the remote cluster when the + follower index is synchronized with the leader index; when the timeout has elapsed, the + poll for operations will return to the follower so that it can update some statistics, and + then the follower will immediately attempt to read from the leader again.` + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutLabel', { + defaultMessage: 'Read poll timeout (optional)' + } + ), + helpText: timeUnitsHelpText, + }, +]; + +export const emptyAdvancedSettings = advancedSettingsFields.reduce((obj, advancedSetting) => { + return { ...obj, [advancedSetting.field]: '' }; +}, {}); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js similarity index 52% rename from x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js rename to x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index c06856f7306955..db9258414b32c2 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -6,8 +6,11 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { debounce } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; +import { fatalError } from 'ui/notify'; import { EuiButton, @@ -19,25 +22,35 @@ import { EuiFlexItem, EuiForm, EuiFormRow, + EuiHorizontalRule, EuiLoadingKibana, EuiLoadingSpinner, EuiOverlayMask, EuiSpacer, + EuiSuperSelect, EuiText, EuiTitle, - EuiSuperSelect, } from '@elastic/eui'; -import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; - -import routing from '../services/routing'; -import { API_STATUS } from '../constants'; -import { SectionError } from './section_error'; -import { validateFollowerIndex } from '../services/follower_index_validators'; -import { loadIndices } from '../services/api'; +import { indexNameValidator, leaderIndexValidator } from '../../services/input_validation'; +import routing from '../../services/routing'; +import { loadIndices } from '../../services/api'; +import { API_STATUS } from '../../constants'; +import { SectionError } from '../section_error'; +import { FormEntryRow } from '../form_entry_row'; +import { advancedSettingsFields, emptyAdvancedSettings } from './advanced_settings_fields'; const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); +const fieldToValidatorMap = advancedSettingsFields.reduce((map, advancedSetting) => { + const { field, validator } = advancedSetting; + map[field] = validator; + return map; +}, { + 'name': indexNameValidator, + 'leaderIndex': leaderIndexValidator, +}); + const getFirstConnectedCluster = (clusters) => { for (let i = 0; i < clusters.length; i++) { if (clusters[i].isConnected) { @@ -49,8 +62,9 @@ const getFirstConnectedCluster = (clusters) => { const getEmptyFollowerIndex = (remoteClusters) => ({ name: '', - remoteCluster: getFirstConnectedCluster(remoteClusters).name, + remoteCluster: remoteClusters ? getFirstConnectedCluster(remoteClusters).name : '', leaderIndex: '', + ...emptyAdvancedSettings, }); /** @@ -92,29 +106,110 @@ export const FollowerIndexForm = injectI18n( const followerIndex = isNew ? getEmptyFollowerIndex(this.props.remoteClusters) : { + ...getEmptyFollowerIndex(), ...this.props.followerIndex, }; + const fieldsErrors = this.getFieldsErrors(followerIndex); + this.state = { + isNew, followerIndex, - fieldsErrors: validateFollowerIndex(followerIndex), + fieldsErrors, areErrorsVisible: false, - isNew, + areAdvancedSettingsVisible: false, + isValidatingIndexName: false, }; this.validateIndexName = debounce(this.validateIndexName, 500); } onFieldsChange = (fields) => { - const errors = validateFollowerIndex(fields); this.setState(updateFields(fields)); - this.setState(updateFormErrors(errors)); + + const newFields = { + ...this.state.fields, + ...fields, + }; + + this.setState(updateFormErrors(this.getFieldsErrors(newFields))); if (this.props.apiError) { this.props.clearApiError(); } }; + getFieldsErrors = (newFields) => { + return Object.keys(newFields).reduce((errors, field) => { + const validator = fieldToValidatorMap[field]; + const value = newFields[field]; + + if (validator) { + const error = validator(value); + errors[field] = error; + } + + return errors; + }, {}); + }; + + onIndexNameChange = ({ name }) => { + this.onFieldsChange({ name }); + + if (!name || !name.trim()) { + this.setState({ + isValidatingIndexName: false, + }); + + return; + } + + this.setState({ + isValidatingIndexName: true, + }); + + this.validateIndexName(name); + }; + + validateIndexName = async (name) => { + try { + const indices = await loadIndices(); + const doesExist = indices.some(index => index.name === name); + if (doesExist) { + const error = { + message: ( + + ), + alwaysVisible: true, + }; + + this.setState(updateFormErrors({ name: error })); + } + + this.setState({ + isValidatingIndexName: false, + }); + } catch (error) { + // Expect an error in the shape provided by Angular's $http service. + if (error && error.data) { + // All validation does is check for a name collision, so we can just let the user attempt + // to save the follower index and get an error back from the API. + this.setState({ + isValidatingIndexName: false, + }); + } + + // This error isn't an HTTP error, so let the fatal error screen tell the user something + // unexpected happened. + fatalError(error, i18n.translate('xpack.crossClusterReplication.followerIndexForm.indexNameValidationFatalErrorTitle', { + defaultMessage: 'Follower Index Forn index name validation', + })); + } + }; + onClusterChange = (remoteCluster) => { this.onFieldsChange({ remoteCluster }); }; @@ -123,8 +218,39 @@ export const FollowerIndexForm = injectI18n( return this.state.followerIndex; }; + toggleAdvancedSettings = () => { + this.setState(({ areAdvancedSettingsVisible, cachedAdvancedSettings }) => { + // Hide settings, clear fields, and create cache. + if (areAdvancedSettingsVisible) { + const fields = this.getFields(); + + const newCachedAdvancedSettings = advancedSettingsFields.reduce((cache, { field }) => { + const value = fields[field]; + if (value !== '') { + cache[field] = value; + } + return cache; + }, {}); + + this.onFieldsChange(emptyAdvancedSettings); + + return { + areAdvancedSettingsVisible: false, + cachedAdvancedSettings: newCachedAdvancedSettings, + }; + } + + // Show settings and restore fields from the cache. + this.onFieldsChange(cachedAdvancedSettings); + return { + areAdvancedSettingsVisible: true, + cachedAdvancedSettings: {}, + }; + }); + } + isFormValid() { - return Object.values(this.state.fieldsErrors).every(error => error === null); + return Object.values(this.state.fieldsErrors).every(error => error === undefined); } sendForm = () => { @@ -145,33 +271,6 @@ export const FollowerIndexForm = injectI18n( routing.navigate('/follower_indices'); }; - onIndexNameChange = (name) => { - this.onFieldsChange({ name }); - this.validateIndexName(name); - } - - validateIndexName = async (name) => { - if (!name || !name.trim) { - return; - } - - const { intl } = this.props; - - try { - const indices = await loadIndices(); - const doesExist = indices.some(index => index.name === name); - if (doesExist) { - const message = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexForm.indexAlreadyExistError', - defaultMessage: 'An index with the same name already exists.' - }); - this.setState(updateFormErrors({ name: { message, alwaysVisible: true } })); - } - } catch (err) { - // Silently fail... - } - } - /** * Secctions Renders */ @@ -202,72 +301,65 @@ export const FollowerIndexForm = injectI18n( renderForm = () => { const { - followerIndex: { - name, - remoteCluster, - leaderIndex, - }, + followerIndex, isNew, areErrorsVisible, + areAdvancedSettingsVisible, fieldsErrors, + isValidatingIndexName, } = this.state; /** * Follower index name */ - const renderFollowerIndexName = () => { - const hasError = !!fieldsErrors.name; - const isInvalid = hasError && (fieldsErrors.name.alwaysVisible || areErrorsVisible); - return ( - -

- -

- - )} - description={( + const indexNameHelpText = ( + + {isValidatingIndexName && ( +

- )} - fullWidth - > - - )} - helpText={( - {indexNameIllegalCharacters} }} - /> - )} - error={fieldsErrors.name && fieldsErrors.name.message} - isInvalid={isInvalid} - fullWidth - > - this.onIndexNameChange(e.target.value)} - fullWidth - disabled={!isNew} + id="xpack.crossClusterReplication.followerIndexForm.indexNameValidatingLabel" + defaultMessage="Checking availability..." /> - - - ); - }; +

+ )} +

+ {indexNameIllegalCharacters} }} + /> +

+
+ ); + + const indexNameLabel = i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameTitle', { + defaultMessage: 'Name' + } + ); + + const renderFollowerIndexName = () => ( + +

{indexNameLabel}

+ + )} + label={indexNameLabel} + description={i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameDescription', { + defaultMessage: 'A name for the follower index.' + })} + helpText={indexNameHelpText} + isLoading={isValidatingIndexName} + disabled={!isNew} + areErrorsVisible={areErrorsVisible} + onValueUpdate={this.onIndexNameChange} + /> + ); /** * Remote Cluster @@ -284,12 +376,12 @@ export const FollowerIndexForm = injectI18n( -

+

-

+ )} description={( @@ -313,13 +405,14 @@ export const FollowerIndexForm = injectI18n( { isNew && ( )} { !isNew && ( @@ -333,62 +426,123 @@ export const FollowerIndexForm = injectI18n( /** * Leader index */ - const renderLeaderIndex = () => { - const hasError = !!fieldsErrors.leaderIndex; - const isInvalid = hasError && areErrorsVisible; - return ( + const leaderIndexLabel = i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexTitle', { + defaultMessage: 'Leader index' + } + ); + + const renderLeaderIndex = () => ( + +

{leaderIndexLabel}

+ + )} + label={leaderIndexLabel} + description={i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexDescription', { + defaultMessage: 'The leader index you want to replicate from the remote cluster.' + })} + helpText={( + {indexNameIllegalCharacters} }} + /> + )} + disabled={!isNew} + areErrorsVisible={areErrorsVisible} + onValueUpdate={this.onFieldsChange} + /> + ); + + /** + * Advanced settings + */ + + const toggleAdvancedSettingButtonLabel = areAdvancedSettingsVisible + ? ( + + ) : ( + + ); + + const renderAdvancedSettings = () => ( + + + -

+

-

+ )} description={(

+ + + { toggleAdvancedSettingButtonLabel } +
)} fullWidth - > - - )} - helpText={( - {indexNameIllegalCharacters} }} - /> - )} - isInvalid={isInvalid} - error={fieldsErrors.leaderIndex && fieldsErrors.leaderIndex.message} - fullWidth - > - this.onFieldsChange({ leaderIndex: e.target.value })} - fullWidth - /> - -
- ); - }; + /> + + {areAdvancedSettingsVisible && ( + + + + {advancedSettingsFields.map((advancedSetting) => { + const { field, title, description, label, helpText } = advancedSetting; + return ( + +

{title}

+ + )} + description={description} + label={label} + helpText={helpText} + areErrorsVisible={areErrorsVisible} + onValueUpdate={this.onFieldsChange} + /> + ); + })} +
+ )} + + +
+ ); /** * Form Error warning message @@ -403,7 +557,6 @@ export const FollowerIndexForm = injectI18n( return ( - + + ); }; @@ -484,9 +639,11 @@ export const FollowerIndexForm = injectI18n( {renderFollowerIndexName()} {renderRemoteClusterField()} {renderLeaderIndex()} + + {renderAdvancedSettings()} + {renderFormErrorWarning()} - {renderActions()} ); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.test.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js similarity index 100% rename from x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.test.js rename to x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js new file mode 100644 index 00000000000000..265780b620a7cd --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js @@ -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 { FollowerIndexForm } from './follower_index_form'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js new file mode 100644 index 00000000000000..bfc7d8e3c6c7bd --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -0,0 +1,116 @@ +/* + * 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, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; + +import { + EuiFieldText, + EuiFieldNumber, + EuiDescribedFormGroup, + EuiFormRow, +} from '@elastic/eui'; + +/** + * State transitions: fields update + */ +export const updateFields = (newValues) => ({ fields }) => ({ + fields: { + ...fields, + ...newValues, + }, +}); + +export class FormEntryRow extends PureComponent { + static propTypes = { + title: PropTypes.node, + description: PropTypes.node, + label: PropTypes.node, + helpText: PropTypes.node, + type: PropTypes.string, + onValueUpdate: PropTypes.func.isRequired, + field: PropTypes.string.isRequired, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]).isRequired, + isLoading: PropTypes.bool, + error: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.object, + ]), + disabled: PropTypes.bool, + areErrorsVisible: PropTypes.bool.isRequired, + }; + + onFieldChange = (value) => { + const { field, onValueUpdate, type } = this.props; + const isNumber = type === 'number'; + onValueUpdate({ [field]: isNumber ? parseInt(value, 10) : value }); + } + + renderField = (isInvalid) => { + const { value, type, disabled, isLoading } = this.props; + switch (type) { + case 'number': + return ( + this.onFieldChange(e.target.value)} + disabled={disabled === true} + isLoading={isLoading} + fullWidth + /> + ); + default: + return ( + this.onFieldChange(e.target.value)} + disabled={disabled === true} + isLoading={isLoading} + fullWidth + /> + ); + } + } + + render() { + const { + field, + error, + title, + label, + description, + helpText, + areErrorsVisible, + } = this.props; + + const hasError = !!error; + const isInvalid = hasError && (error.alwaysVisible || areErrorsVisible); + + return ( + + + {this.renderField(isInvalid)} + + + ); + } +} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/index.js b/x-pack/plugins/cross_cluster_replication/public/app/components/index.js index 1a5d4a9d1eb3f0..18029cfc5271ff 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/index.js @@ -17,3 +17,4 @@ export { FollowerIndexResumeProvider } from './follower_index_resume_provider'; export { FollowerIndexUnfollowProvider } from './follower_index_unfollow_provider'; export { FollowerIndexForm } from './follower_index_form'; export { FollowerIndexPageTitle } from './follower_index_page_title'; +export { FormEntryRow } from './form_entry_row'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js b/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js index 67e0a9fdc20547..585ca7e0f5cf17 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js @@ -10,3 +10,5 @@ const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LIN export const autoFollowPatternUrl = `${esBase}/ccr-put-auto-follow-pattern.html`; export const followerIndexUrl = `${esBase}/ccr-put-follow.html`; +export const byteUnitsUrl = `${esBase}/common-options.html#byte-units`; +export const timeUnitsUrl = `${esBase}/common-options.html#time-units`; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_validators.js b/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_validators.js deleted file mode 100644 index df31e2f3d1c23f..00000000000000 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_validators.js +++ /dev/null @@ -1,100 +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 from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - indexNameBeginsWithPeriod, - findIllegalCharactersInIndexName, - indexNameContainsSpaces, -} from 'ui/indices'; - -const i18nLabels = { - indexName: i18n.translate( - 'xpack.crossClusterReplication.followerIndex.indexName', - { defaultMessage: 'Name' } - ), - leaderIndex: i18n.translate( - 'xpack.crossClusterReplication.followerIndex.leaderIndex', - { defaultMessage: 'Leader index' } - ) -}; - -const validateIndexName = (name, fieldName) => { - if (!name || !name.trim()) { - // Empty - return { - message: i18n.translate( - 'xpack.crossClusterReplication.followerIndex.indexNameValidation.errorEmpty', - { - defaultMessage: '{name} is required.', - values: { name: fieldName } - } - ) - }; - } else { - // Indices can't begin with a period, because that's reserved for system indices. - if (indexNameBeginsWithPeriod(name)) { - return { - message: i18n.translate('xpack.crossClusterReplication.followerIndex.indexNameValidation.beginsWithPeriod', { - defaultMessage: `The {name} can't begin with a period.`, - values: { name: fieldName.toLowerCase() } - }) - }; - } - - const illegalCharacters = findIllegalCharactersInIndexName(name); - - if (illegalCharacters.length) { - return { - message: {illegalCharacters.join(' ')}, - characterListLength: illegalCharacters.length, - }} - /> - }; - } - - if (indexNameContainsSpaces(name)) { - return { - message: i18n.translate('xpack.crossClusterReplication.followerIndex.indexNameValidation.noEmptySpace', { - defaultMessage: `Spaces are not allowed in the {name}.`, - values: { name: fieldName.toLowerCase() } - }) - }; - } - - return null; - } -}; - -export const validateFollowerIndex = (followerIndex) => { - const errors = {}; - let error = null; - let fieldValue; - - Object.keys(followerIndex).forEach((fieldName) => { - fieldValue = followerIndex[fieldName]; - error = null; - switch (fieldName) { - case 'name': - error = validateIndexName(fieldValue, i18nLabels.indexName); - break; - case 'leaderIndex': - error = validateIndexName(fieldValue, i18nLabels.leaderIndex); - break; - } - errors[fieldName] = error; - }); - - return errors; -}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js new file mode 100644 index 00000000000000..877fc1d5a61052 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js @@ -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 from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; + +const isEmpty = value => { + return !value || !value.trim().length; +}; + +const beginsWithPeriod = value => { + return value[0] === '.'; +}; + +const findIllegalCharacters = value => { + return INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => { + if (value.includes(char)) { + chars.push(char); + } + + return chars; + }, []); +}; + +export const indexNameValidator = (value) => { + if (isEmpty(value)) { + return [( + + )]; + } + + if (beginsWithPeriod(value)) { + return [( + + )]; + } + + const illegalCharacters = findIllegalCharacters(value); + + if (illegalCharacters.length) { + return [( + {illegalCharacters.join(' ')} }} + /> + )]; + } + + return undefined; +}; + +export const leaderIndexValidator = (value) => { + if (isEmpty(value)) { + return [( + + )]; + } + + const illegalCharacters = findIllegalCharacters(value); + + if (illegalCharacters.length) { + return [( + {illegalCharacters.join(' ')} }} + /> + )]; + } + + return undefined; +}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js index 818045919caf7b..43d1da3f242a2e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js @@ -8,6 +8,17 @@ import { reducer, initialState } from './api'; import { API_STATUS } from '../../constants'; import { apiRequestStart, apiRequestEnd, setApiError } from '../actions'; +jest.mock('../../constants', () => ({ + API_STATUS: { + IDLE: 'idle', + LOADING: 'loading', + }, + SECTIONS: { + AUTO_FOLLOW_PATTERN: 'autoFollowPattern', + FOLLOWER_INDEX: 'followerIndex', + } +})); + describe('CCR Api reducers', () => { const scope = 'testSection'; diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap b/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap index 1c20f73287259f..a41a67089d7580 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap +++ b/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap @@ -96,3 +96,20 @@ Object { "writeBufferSizeBytes": "write buffer size in bytes", } `; + +exports[`[CCR] follower index serialization serializeFollowerIndex() serializes object to Elasticsearch follower index object 1`] = ` +Object { + "leader_index": "leader index", + "max_outstanding_read_requests": "foo", + "max_outstanding_write_requests": "foo", + "max_read_request_operation_count": "foo", + "max_read_request_size": "foo", + "max_retry_delay": "foo", + "max_write_buffer_count": "foo", + "max_write_buffer_size": "foo", + "max_write_request_operation_count": "foo", + "max_write_request_size": "foo", + "read_poll_timeout": "foo", + "remote_cluster": "remote cluster", +} +`; diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js index 7b5a0e453b65fe..6d1da8aa6ff3ef 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js +++ b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js @@ -71,7 +71,32 @@ export const deserializeFollowerIndex = ({ index, shards }) => ({ export const deserializeListFollowerIndices = followerIndices => followerIndices.map(deserializeFollowerIndex); -export const serializeFollowerIndex = ({ remoteCluster, leaderIndex }) => ({ - remote_cluster: remoteCluster, - leader_index: leaderIndex, +export const serializeAdvancedSettings = ({ + maxReadRequestOperationCount, + maxOutstandingReadRequests, + maxReadRequestSize, + maxWriteRequestOperationCount, + maxWriteRequestSize, + maxOutstandingWriteRequests, + maxWriteBufferCount, + maxWriteBufferSize, + maxRetryDelay, + readPollTimeout, +}) => ({ + max_read_request_operation_count: maxReadRequestOperationCount, + max_outstanding_read_requests: maxOutstandingReadRequests, + max_read_request_size: maxReadRequestSize, + max_write_request_operation_count: maxWriteRequestOperationCount, + max_write_request_size: maxWriteRequestSize, + max_outstanding_write_requests: maxOutstandingWriteRequests, + max_write_buffer_count: maxWriteBufferCount, + max_write_buffer_size: maxWriteBufferSize, + max_retry_delay: maxRetryDelay, + read_poll_timeout: readPollTimeout, +}); + +export const serializeFollowerIndex = (followerIndex) => ({ + remote_cluster: followerIndex.remoteCluster, + leader_index: followerIndex.leaderIndex, + ...serializeAdvancedSettings(followerIndex) }); diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js index d4b5526c16a6ba..3f08450884dd29 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js +++ b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js @@ -90,14 +90,19 @@ describe('[CCR] follower index serialization', () => { const deserializedFollowerIndex = { remoteCluster: 'remote cluster', leaderIndex: 'leader index', + maxReadRequestOperationCount: 'foo', + maxOutstandingReadRequests: 'foo', + maxReadRequestSize: 'foo', + maxWriteRequestOperationCount: 'foo', + maxWriteRequestSize: 'foo', + maxOutstandingWriteRequests: 'foo', + maxWriteBufferCount: 'foo', + maxWriteBufferSize: 'foo', + maxRetryDelay: 'foo', + readPollTimeout: 'foo', }; - const serializedFollowerIndex = { - remote_cluster: 'remote cluster', - leader_index: 'leader index', - }; - - expect(serializeFollowerIndex(deserializedFollowerIndex)).toEqual(serializedFollowerIndex); + expect(serializeFollowerIndex(deserializedFollowerIndex)).toMatchSnapshot(); }); }); }); diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js index cc7eeaef99514f..93c4aa0f486492 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js @@ -14,6 +14,7 @@ import { } from '../../lib/follower_index_serialization'; import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory'; import { API_BASE_PATH } from '../../../common/constants'; +import { removeEmptyFields } from '../../../common/services/utils'; export const registerFollowerIndexRoutes = (server) => { const isEsError = isEsErrorFactory(server); @@ -86,7 +87,7 @@ export const registerFollowerIndexRoutes = (server) => { handler: async (request) => { const callWithRequest = callWithRequestFactory(server, request); const { name, ...rest } = request.payload; - const body = serializeFollowerIndex(rest); + const body = removeEmptyFields(serializeFollowerIndex(rest)); try { return await callWithRequest('ccr.saveFollowerIndex', { name, body }); diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/__snapshots__/remote_cluster_list.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/__snapshots__/remote_cluster_list.test.js.snap index cc7ca735d17673..0f8537289951f3 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/__snapshots__/remote_cluster_list.test.js.snap +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/__snapshots__/remote_cluster_list.test.js.snap @@ -2,90 +2,86 @@ exports[`RemoteClusterList renders empty state when loading is complete and there are no clusters 1`] = `
+ + + +
+ - - - - + Add your first remote cluster +