From 0ad8b8c33d97ca548fa0a3b99c8e0487df8a7943 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?S=C3=A9bastien=20Loix?=
Date: Fri, 18 Jan 2019 20:07:54 +0100
Subject: [PATCH] [CCR] Advanced settings UI for follower indices (#28267)
* Add client side validation of advanced settings form
* Move form entry row to separate component
* Add server side serialization for advanced settings
* Ignore advanced settings input when that section is hidden.
- Cache and restore input when the section is shown again.
---
.../common/services/utils.js | 12 +
.../follower_index_form.test.js.snap | 0
.../advanced_settings_fields.js | 232 +++++++++
.../follower_index_form.js | 441 ++++++++++++------
.../follower_index_form.test.js | 0
.../components/follower_index_form/index.js | 7 +
.../public/app/components/form_entry_row.js | 116 +++++
.../public/app/components/index.js | 1 +
.../app/services/documentation_links.js | 2 +
.../app/services/follower_index_validators.js | 100 ----
.../public/app/services/input_validation.js | 86 ++++
.../public/app/store/reducers/api.test.js | 11 +
.../follower_index_serialization.test.js.snap | 17 +
.../lib/follower_index_serialization.js | 31 +-
.../lib/follower_index_serialization.test.js | 17 +-
.../server/routes/api/follower_index.js | 3 +-
.../remote_cluster_list.test.js.snap | 132 +++---
17 files changed, 888 insertions(+), 320 deletions(-)
rename x-pack/plugins/cross_cluster_replication/public/app/components/{ => follower_index_form}/__snapshots__/follower_index_form.test.js.snap (100%)
create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js
rename x-pack/plugins/cross_cluster_replication/public/app/components/{ => follower_index_form}/follower_index_form.js (52%)
rename x-pack/plugins/cross_cluster_replication/public/app/components/{ => follower_index_form}/follower_index_form.test.js (100%)
create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js
create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js
delete mode 100644 x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_validators.js
create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js
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 b8f245bfaefb4..83154c7e95cac 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 0000000000000..da0dfe854ba3a
--- /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 c06856f730695..db9258414b32c 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 0000000000000..265780b620a7c
--- /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 0000000000000..bfc7d8e3c6c7b
--- /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 1a5d4a9d1eb3f..18029cfc5271f 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 67e0a9fdc2054..585ca7e0f5cf1 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 df31e2f3d1c23..0000000000000
--- 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 0000000000000..877fc1d5a6105
--- /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 818045919caf7..43d1da3f242a2 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 1c20f73287259..a41a67089d758 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 7b5a0e453b65f..6d1da8aa6ff3e 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 d4b5526c16a6b..3f08450884dd2 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 cc7eeaef99514..93c4aa0f48649 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 cc7ca735d1767..0f8537289951f 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`] = `