diff --git a/x-pack/plugins/cross_cluster_replication/public/app/app.js b/x-pack/plugins/cross_cluster_replication/public/app/app.js index 9f43d1788258f..0663b6fae8c35 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/app.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/app.js @@ -16,6 +16,7 @@ import { AutoFollowPatternAdd, AutoFollowPatternEdit, FollowerIndexAdd, + FollowerIndexEdit, } from './sections'; export class App extends Component { @@ -54,6 +55,7 @@ export class App extends Component { + diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js index f9f9169482106..4d8c913a0b06a 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js @@ -26,32 +26,26 @@ import { EuiSpacer, EuiText, EuiTitle, - EuiSuperSelect, } from '@elastic/eui'; import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/index_patterns'; import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; import routing from '../services/routing'; +import { extractQueryParams } from '../services/query_params'; +import { getRemoteClusterName } from '../services/get_remote_cluster_name'; import { API_STATUS } from '../constants'; -import { SectionError, AutoFollowPatternIndicesPreview } from './'; +import { SectionError } from './section_error'; +import { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview'; +import { RemoteClustersFormField } from './remote_clusters_form_field'; import { validateAutoFollowPattern, validateLeaderIndexPattern } from '../services/auto_follow_pattern_validators'; const indexPatternIllegalCharacters = INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.join(' '); const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); -const getFirstConnectedCluster = (clusters) => { - for (let i = 0; i < clusters.length; i++) { - if (clusters[i].isConnected) { - return clusters[i]; - } - } - return {}; -}; - -const getEmptyAutoFollowPattern = (remoteClusters) => ({ +const getEmptyAutoFollowPattern = (remoteClusterName = '') => ({ name: '', - remoteCluster: getFirstConnectedCluster(remoteClusters).name, + remoteCluster: remoteClusterName, leaderIndexPatterns: [], followIndexPatternPrefix: '', followIndexPatternSuffix: '', @@ -70,16 +64,19 @@ export class AutoFollowPatternFormUI extends PureComponent { autoFollowPattern: PropTypes.object, apiError: PropTypes.object, apiStatus: PropTypes.string.isRequired, - remoteClusters: PropTypes.array.isRequired, + currentUrl: PropTypes.string.isRequired, + remoteClusters: PropTypes.array, } constructor(props) { super(props); const isNew = this.props.autoFollowPattern === undefined; - + const { route: { location: { search } } } = routing.reactRouter; + const queryParams = extractQueryParams(search); + const remoteClusterName = getRemoteClusterName(this.props.remoteClusters, queryParams.cluster); const autoFollowPattern = isNew - ? getEmptyAutoFollowPattern(this.props.remoteClusters) + ? getEmptyAutoFollowPattern(remoteClusterName) : { ...this.props.autoFollowPattern, }; @@ -101,9 +98,11 @@ export class AutoFollowPatternFormUI extends PureComponent { })); const errors = validateAutoFollowPattern(fields); - this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); + this.onFieldsErrorChange(errors); }; + onFieldsErrorChange = (errors) => this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); + onClusterChange = (remoteCluster) => { this.onFieldsChange({ remoteCluster }); }; @@ -169,8 +168,8 @@ export class AutoFollowPatternFormUI extends PureComponent { this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); } else { - this.setState(({ fieldsErrors, autoFollowPattern }) => { - const errors = validateAutoFollowPattern(autoFollowPattern); + this.setState(({ fieldsErrors, autoFollowPattern: { leaderIndexPatterns } }) => { + const errors = validateAutoFollowPattern({ leaderIndexPatterns }); return updateFormErrors(errors, fieldsErrors); }); } @@ -187,7 +186,7 @@ export class AutoFollowPatternFormUI extends PureComponent { }; isFormValid() { - return Object.values(this.state.fieldsErrors).every(error => error === null); + return Object.values(this.state.fieldsErrors).every(error => error === undefined || error === null); } sendForm = () => { @@ -293,12 +292,24 @@ export class AutoFollowPatternFormUI extends PureComponent { * Remote Cluster */ const renderRemoteClusterField = () => { - const remoteClustersOptions = this.props.remoteClusters.map(({ name, isConnected }) => ({ - value: name, - inputDisplay: isConnected ? name : `${name} (not connected)`, - disabled: !isConnected, - 'data-test-subj': `option-${name}` - })); + const { remoteClusters, currentUrl } = this.props; + + const errorMessages = { + noClusterFound: () => (), + remoteClusterNotConnectedNotEditable: () => (), + remoteClusterDoesNotExist: () => () + }; return ( - - )} - fullWidth - > - - { isNew && ( - - )} - { !isNew && ( - - )} - - + this.onFieldsErrorChange({ remoteCluster: error })} + errorMessages={errorMessages} + /> ); }; @@ -435,9 +430,9 @@ export class AutoFollowPatternFormUI extends PureComponent { }; /** - * Auto-follow pattern + * Auto-follow pattern prefix/suffix */ - const renderAutoFollowPattern = () => { + const renderAutoFollowPatternPrefixSuffix = () => { const isPrefixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternPrefix; const isSuffixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternSuffix; @@ -625,7 +620,7 @@ export class AutoFollowPatternFormUI extends PureComponent { {renderAutoFollowPatternName()} {renderRemoteClusterField()} {renderLeaderIndexPatterns()} - {renderAutoFollowPattern()} + {renderAutoFollowPatternPrefixSuffix()} {renderFormErrorWarning()} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index db9258414b32c..0017e30e3cb42 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -17,17 +17,14 @@ import { EuiButtonEmpty, EuiCallOut, EuiDescribedFormGroup, - EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiForm, - EuiFormRow, EuiHorizontalRule, EuiLoadingKibana, EuiLoadingSpinner, EuiOverlayMask, EuiSpacer, - EuiSuperSelect, EuiText, EuiTitle, } from '@elastic/eui'; @@ -39,6 +36,9 @@ import { API_STATUS } from '../../constants'; import { SectionError } from '../section_error'; import { FormEntryRow } from '../form_entry_row'; import { advancedSettingsFields, emptyAdvancedSettings } from './advanced_settings_fields'; +import { extractQueryParams } from '../../services/query_params'; +import { getRemoteClusterName } from '../../services/get_remote_cluster_name'; +import { RemoteClustersFormField } from '../remote_clusters_form_field'; const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); @@ -51,18 +51,9 @@ const fieldToValidatorMap = advancedSettingsFields.reduce((map, advancedSetting) 'leaderIndex': leaderIndexValidator, }); -const getFirstConnectedCluster = (clusters) => { - for (let i = 0; i < clusters.length; i++) { - if (clusters[i].isConnected) { - return clusters[i]; - } - } - return {}; -}; - -const getEmptyFollowerIndex = (remoteClusters) => ({ +const getEmptyFollowerIndex = (remoteClusterName = '') => ({ name: '', - remoteCluster: remoteClusters ? getFirstConnectedCluster(remoteClusters).name : '', + remoteCluster: remoteClusterName, leaderIndex: '', ...emptyAdvancedSettings, }); @@ -95,16 +86,18 @@ export const FollowerIndexForm = injectI18n( followerIndex: PropTypes.object, apiError: PropTypes.object, apiStatus: PropTypes.string.isRequired, - remoteClusters: PropTypes.array.isRequired, + remoteClusters: PropTypes.array, } constructor(props) { super(props); const isNew = this.props.followerIndex === undefined; - + const { route: { location: { search } } } = routing.reactRouter; + const queryParams = extractQueryParams(search); + const remoteClusterName = getRemoteClusterName(this.props.remoteClusters, queryParams.cluster); const followerIndex = isNew - ? getEmptyFollowerIndex(this.props.remoteClusters) + ? getEmptyFollowerIndex(remoteClusterName) : { ...getEmptyFollowerIndex(), ...this.props.followerIndex, @@ -117,7 +110,7 @@ export const FollowerIndexForm = injectI18n( followerIndex, fieldsErrors, areErrorsVisible: false, - areAdvancedSettingsVisible: false, + areAdvancedSettingsVisible: isNew ? false : true, isValidatingIndexName: false, }; @@ -250,7 +243,7 @@ export const FollowerIndexForm = injectI18n( } isFormValid() { - return Object.values(this.state.fieldsErrors).every(error => error === undefined); + return Object.values(this.state.fieldsErrors).every(error => error === undefined || error === null); } sendForm = () => { @@ -272,7 +265,7 @@ export const FollowerIndexForm = injectI18n( }; /** - * Secctions Renders + * Sections Renders */ renderApiErrors() { const { apiError, intl } = this.props; @@ -365,12 +358,24 @@ export const FollowerIndexForm = injectI18n( * Remote Cluster */ const renderRemoteClusterField = () => { - const remoteClustersOptions = this.props.remoteClusters.map(({ name, isConnected }) => ({ - value: name, - inputDisplay: isConnected ? name : `${name} (not connected)`, - disabled: !isConnected, - 'data-test-subj': `option-${name}` - })); + const { remoteClusters, currentUrl } = this.props; + + const errorMessages = { + noClusterFound: () => (), + remoteClusterNotConnectedNotEditable: () => (), + remoteClusterDoesNotExist: () => () + }; return ( - - )} - fullWidth - > - - { isNew && ( - - )} - { !isNew && ( - - )} - - + { + this.setState(updateFormErrors({ remoteCluster: error })); + }} + errorMessages={errorMessages} + /> ); }; @@ -477,72 +467,76 @@ export const FollowerIndexForm = injectI18n( /> ); - const renderAdvancedSettings = () => ( - - + const renderAdvancedSettings = () => { + const { isNew } = this.state; - -

- -

- - )} - description={( + return ( + + + +

+ +

+ + )} + description={( + +

+ +

+ {isNew ? ( + + {toggleAdvancedSettingButtonLabel} + + ) : null} +
+ )} + fullWidth + > + {/* Avoid missing `children` warning */} +
+ + {areAdvancedSettingsVisible && ( -

- -

- - - { toggleAdvancedSettingButtonLabel } - + + {advancedSettingsFields.map((advancedSetting) => { + const { field, title, description, label, helpText } = advancedSetting; + return ( + +

{title}

+ + )} + description={description} + label={label} + helpText={helpText} + areErrorsVisible={areErrorsVisible} + onValueUpdate={this.onFieldsChange} + /> + ); + })}
)} - 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 @@ -557,6 +551,7 @@ export const FollowerIndexForm = injectI18n( return ( + (), + remoteClusterNotConnectedEditable: () => (), +}; + +export const RemoteClustersFormField = injectI18n( + class extends PureComponent { + errorMessages = { + ...errorMessages, + ...this.props.errorMessages + } + + componentDidMount() { + const { selected, onError } = this.props; + const { error } = this.validateRemoteCluster(selected); + + onError(error); + } + + validateRemoteCluster(clusterName) { + const { remoteClusters } = this.props; + const remoteCluster = remoteClusters.find(c => c.name === clusterName); + + return remoteCluster && remoteCluster.isConnected + ? { error: null } + : { error: { message: ( + + ) } }; + } + + onRemoteClusterChange = (cluster) => { + const { onChange, onError } = this.props; + const { error } = this.validateRemoteCluster(cluster); + onChange(cluster); + onError(error); + }; + + renderNotEditable = () => { + const { areErrorsVisible } = this.props; + const errorMessage = this.renderErrorMessage(); + + return ( + + + { areErrorsVisible && Boolean(errorMessage) ? this.renderValidRemoteClusterRequired() : null } + { errorMessage } + + ); + }; + + renderValidRemoteClusterRequired = () => ( + + + + ); + + renderDropdown = () => { + const { remoteClusters, selected, currentUrl, areErrorsVisible } = this.props; + const hasClusters = Boolean(remoteClusters.length); + const remoteClustersOptions = hasClusters ? remoteClusters.map(({ name, isConnected }) => ({ + value: name, + text: isConnected ? name : ( + + ), + 'data-test-subj': `option-${name}` + })) : []; + const errorMessage = this.renderErrorMessage(); + + return ( + + { this.onRemoteClusterChange(e.target.value); }} + hasNoInitialSelection={!hasClusters} + isInvalid={areErrorsVisible && Boolean(errorMessage)} + /> + { areErrorsVisible && Boolean(errorMessage) ? this.renderValidRemoteClusterRequired() : null } + { errorMessage } + + + +
{/* Break out of EuiFormRow's flexbox layout */} + + + +
+
+
+ ); + }; + + renderNoClusterFound = () => { + const { intl, currentUrl } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.forms.emptyRemoteClustersCallOutTitle', + defaultMessage: 'No remote cluster found' + }); + + return ( + + +

+ { this.errorMessages.noClusterFound() } +

+ + + + +
+
+ ); + }; + + renderCurrentRemoteClusterNotConnected = (name, fatal) => { + const { intl, isEditable, currentUrl } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.forms.remoteClusterConnectionErrorTitle', + defaultMessage: `The remote cluster '{name}' is not connected` + }, { name }); + + return ( + +

+ { isEditable && this.errorMessages.remoteClusterNotConnectedEditable()} + { !isEditable && this.errorMessages.remoteClusterNotConnectedNotEditable()} +

+ + + +
+ ); + }; + + renderRemoteClusterDoesNotExist = (name) => { + const { intl, currentUrl } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.forms.remoteClusterNotFoundTitle', + defaultMessage: `The remote cluster '{name}' was not found`, + }, { name }); + + return ( + +

+ { this.errorMessages.remoteClusterDoesNotExist() } +

+ + + +
+ ); + } + + renderErrorMessage = () => { + const { selected, remoteClusters, isEditable } = this.props; + const remoteCluster = remoteClusters.find(c => c.name === selected); + const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected; + let error; + + if (isEditable) { + /* Create */ + const hasClusters = Boolean(remoteClusters.length); + if (hasClusters && !isSelectedRemoteClusterConnected) { + error = this.renderCurrentRemoteClusterNotConnected(selected); + } else if (!hasClusters) { + error = this.renderNoClusterFound(); + } + } else { + /* Edit */ + const doesExists = !!remoteCluster; + if (!doesExists) { + error = this.renderRemoteClusterDoesNotExist(selected); + } else if (!isSelectedRemoteClusterConnected) { + error = this.renderCurrentRemoteClusterNotConnected(selected, true); + } + } + + return error ? ( + + + {error} + + ) : null; + } + + render() { + const { remoteClusters, selected, isEditable, areErrorsVisible } = this.props; + const remoteCluster = remoteClusters.find(c => c.name === selected); + const hasClusters = Boolean(remoteClusters.length); + const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected; + const isInvalid = areErrorsVisible && (!hasClusters || !isSelectedRemoteClusterConnected); + let field; + + if(isEditable) { + if(hasClusters) { + field = this.renderDropdown(); + } else { + field = this.renderErrorMessage(); + } + } else { + field = this.renderNotEditable(); + } + + return ( + + )} + isInvalid={isInvalid} + fullWidth + > + + {field} + + + ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js index 1e819cac60ecd..3a30e5f1b036f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import chrome from 'ui/chrome'; @@ -12,13 +12,9 @@ import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageContent, - EuiButton, - EuiCallOut, } from '@elastic/eui'; import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; -import routing from '../../services/routing'; -import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, @@ -44,80 +40,8 @@ export const AutoFollowPatternAdd = injectI18n( this.props.clearApiError(); } - renderEmptyClusters() { - const { intl, match: { url: currentUrl } } = this.props; - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.emptyRemoteClustersCallOutTitle', - defaultMessage: 'No remote cluster found' - }); - - return ( - - -

- -

- - - - -
-
- ); - } - - renderNoConnectedCluster() { - const { intl } = this.props; - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.noRemoteClustersConnectedCallOutTitle', - defaultMessage: 'Remote cluster connection error' - }); - - return ( - - -

- -

- - - -
-
- ); - } - render() { - const { saveAutoFollowPattern, apiStatus, apiError, intl } = this.props; + const { saveAutoFollowPattern, apiStatus, apiError, intl, match: { url: currentUrl } } = this.props; return ( @@ -151,18 +75,11 @@ export const AutoFollowPatternAdd = injectI18n( return ; } - if (!remoteClusters.length) { - return this.renderEmptyClusters(); - } - - if (remoteClusters.every(cluster => cluster.isConnected === false)) { - return this.renderNoConnectedCluster(); - } - return ( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js index 3f8c8331f4909..e0be41afe439d 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js @@ -19,8 +19,14 @@ import { AutoFollowPatternEdit as AutoFollowPatternEditView } from './auto_follo const scope = SECTIONS.AUTO_FOLLOW_PATTERN; const mapStateToProps = (state) => ({ - apiStatus: getApiStatus(scope)(state), - apiError: getApiError(scope)(state), + apiStatus: { + get: getApiStatus(`${scope}-get`)(state), + save: getApiStatus(`${scope}-save`)(state), + }, + apiError: { + get: getApiError(`${scope}-get`)(state), + save: getApiError(`${scope}-save`)(state), + }, autoFollowPatternId: getSelectedAutoFollowPatternId('edit')(state), autoFollowPattern: getSelectedAutoFollowPattern('edit')(state), }); @@ -29,7 +35,10 @@ const mapDispatchToProps = dispatch => ({ getAutoFollowPattern: (id) => dispatch(getAutoFollowPattern(id)), selectAutoFollowPattern: (id) => dispatch(selectEditAutoFollowPattern(id)), saveAutoFollowPattern: (id, autoFollowPattern) => dispatch(saveAutoFollowPattern(id, autoFollowPattern, true)), - clearApiError: () => dispatch(clearApiError(scope)), + clearApiError: () => { + dispatch(clearApiError(`${scope}-get`)); + dispatch(clearApiError(`${scope}-save`)); + }, }); export const AutoFollowPatternEdit = connect( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index 2e7f1cf89909e..64b6cd56a2495 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -11,19 +11,14 @@ import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { - EuiPage, - EuiPageBody, EuiPageContent, - EuiSpacer, - EuiButton, - EuiCallOut, + EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; -import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, @@ -40,8 +35,8 @@ export const AutoFollowPatternEdit = injectI18n( selectAutoFollowPattern: PropTypes.func.isRequired, saveAutoFollowPattern: PropTypes.func.isRequired, clearApiError: PropTypes.func.isRequired, - apiError: PropTypes.object, - apiStatus: PropTypes.string.isRequired, + apiError: PropTypes.object.isRequired, + apiStatus: PropTypes.object.isRequired, autoFollowPattern: PropTypes.object, autoFollowPatternId: PropTypes.string, } @@ -50,6 +45,7 @@ export const AutoFollowPatternEdit = injectI18n( if (lastAutoFollowPatternId !== autoFollowPatternId) { return { lastAutoFollowPatternId: autoFollowPatternId }; } + return null; } state = { lastAutoFollowPatternId: undefined } @@ -65,8 +61,8 @@ export const AutoFollowPatternEdit = injectI18n( componentDidUpdate(prevProps, prevState) { const { autoFollowPattern, getAutoFollowPattern } = this.props; + // Fetch the auto-follow pattern on the server if we don't have it (i.e. page reload) if (!autoFollowPattern && prevState.lastAutoFollowPatternId !== this.state.lastAutoFollowPatternId) { - // Fetch the auto-follow pattern on the server getAutoFollowPattern(this.state.lastAutoFollowPatternId); } } @@ -75,29 +71,36 @@ export const AutoFollowPatternEdit = injectI18n( this.props.clearApiError(); } - renderApiError(error) { - const { intl } = this.props; + renderGetAutoFollowPatternError(error) { + const { intl, match: { params: { id: name } } } = this.props; const title = intl.formatMessage({ id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorTitle', defaultMessage: 'Error loading auto-follow pattern', }); + const errorMessage = error.status === 404 ? { + data: { + error: intl.formatMessage({ + id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorMessage', + defaultMessage: `The auto-follow pattern '{name}' does not exist.`, + }, { name }) + } + } : error; return ( - - - + + - - + @@ -115,110 +118,64 @@ export const AutoFollowPatternEdit = injectI18n( ); } - renderMissingCluster({ name, remoteCluster }) { - const { intl } = this.props; - - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.emptyRemoteClustersTitle', - defaultMessage: 'Remote cluster missing' - }); + render() { + const { saveAutoFollowPattern, apiStatus, apiError, autoFollowPattern, intl, match: { url: currentUrl } } = this.props; return ( - - -

- - -

- + + - -
-
- ); - } + )} + /> - render() { - const { saveAutoFollowPattern, apiStatus, apiError, autoFollowPattern, intl } = this.props; + {apiStatus.get === API_STATUS.LOADING && this.renderLoadingAutoFollowPattern()} - return ( - - - - - )} - /> - {apiStatus === API_STATUS.LOADING && this.renderLoadingAutoFollowPattern()} - - {apiError && this.renderApiError(apiError)} - - {autoFollowPattern && ( - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - - - - ); - } - - if (error) { - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClustersErrorTitle', - defaultMessage: 'Error loading remote clusters', - }); - return ; - } - - const autoFollowPatternCluster = remoteClusters.find(cluster => cluster.name === autoFollowPattern.remoteCluster); - - if (!autoFollowPatternCluster || !autoFollowPatternCluster.isConnected) { - return this.renderMissingCluster(autoFollowPattern); - } - - return ( - + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + - ); - }} - - )} - - - + + ); + } + + if (error) { + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClustersErrorTitle', + defaultMessage: 'Error loading remote clusters', + }); + return ; + } + + return ( + + ); + }} + + )} +
); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js index cf2efcb98432a..d63ae84b0bf6b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js @@ -14,13 +14,13 @@ import { FollowerIndexAdd as FollowerIndexAddView } from './follower_index_add'; const scope = SECTIONS.FOLLOWER_INDEX; const mapStateToProps = (state) => ({ - apiStatus: getApiStatus(scope)(state), - apiError: getApiError(scope)(state), + apiStatus: getApiStatus(`${scope}-save`)(state), + apiError: getApiError(`${scope}-save`)(state), }); const mapDispatchToProps = dispatch => ({ saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex)), - clearApiError: () => dispatch(clearApiError(scope)), + clearApiError: () => dispatch(clearApiError(`${scope}-save`)), }); export const FollowerIndexAdd = connect( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js index 44bd15b8cbf96..2e12804d37f40 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js @@ -4,23 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { - EuiPage, - EuiPageBody, EuiPageContent, - EuiButton, - EuiCallOut, } from '@elastic/eui'; import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; -import routing from '../../services/routing'; -import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; import { FollowerIndexForm, FollowerIndexPageTitle, @@ -46,140 +40,57 @@ export const FollowerIndexAdd = injectI18n( this.props.clearApiError(); } - renderEmptyClusters() { - const { intl, match: { url: currentUrl } } = this.props; - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexCreateForm.emptyRemoteClustersCallOutTitle', - defaultMessage: 'No remote cluster found' - }); - - return ( - - -

- -

- - - - -
-
- ); - } - - renderNoConnectedCluster() { - const { intl } = this.props; - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexCreateForm.noRemoteClustersConnectedCallOutTitle', - defaultMessage: 'Remote cluster connection error' - }); - - return ( - - -

- -

- - - -
-
- ); - } - render() { - const { saveFollowerIndex, clearApiError, apiStatus, apiError, intl } = this.props; + const { saveFollowerIndex, clearApiError, apiStatus, apiError, intl, match: { url: currentUrl } } = this.props; return ( - - - - - )} + + - - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - - - - ); - } - - if (error) { - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexCreateForm.loadingRemoteClustersErrorTitle', - defaultMessage: 'Error loading remote clusters', - }); - return ; - } - - if (!remoteClusters.length) { - return this.renderEmptyClusters(); - } - - if (remoteClusters.every(cluster => cluster.isConnected === false)) { - return this.renderNoConnectedCluster(); - } - - return ( - + + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + - ); - }} - - - - + + ); + } + + if (error) { + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexCreateForm.loadingRemoteClustersErrorTitle', + defaultMessage: 'Error loading remote clusters', + }); + return ; + } + + return ( + + ); + }} + + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js new file mode 100644 index 0000000000000..84e03cf4a8043 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js @@ -0,0 +1,52 @@ +/* + * 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 { connect } from 'react-redux'; + +import { SECTIONS } from '../../constants'; +import { + getApiStatus, + getApiError, + getSelectedFollowerIndexId, + getSelectedFollowerIndex, +} from '../../store/selectors'; +import { + saveFollowerIndex, + clearApiError, + getFollowerIndex, + selectEditFollowerIndex, +} from '../../store/actions'; +import { FollowerIndexEdit as FollowerIndexEditView } from './follower_index_edit'; + +const scope = SECTIONS.FOLLOWER_INDEX; + +const mapStateToProps = (state) => ({ + apiStatus: { + get: getApiStatus(`${scope}-get`)(state), + save: getApiStatus(`${scope}-save`)(state), + }, + apiError: { + get: getApiError(`${scope}-get`)(state), + save: getApiError(`${scope}-save`)(state), + }, + followerIndexId: getSelectedFollowerIndexId('edit')(state), + followerIndex: getSelectedFollowerIndex('edit')(state), +}); + +const mapDispatchToProps = dispatch => ({ + getFollowerIndex: (id) => dispatch(getFollowerIndex(id)), + selectFollowerIndex: (id) => dispatch(selectEditFollowerIndex(id)), + saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex, true)), + clearApiError: () => { + dispatch(clearApiError(`${scope}-get`)); + dispatch(clearApiError(`${scope}-save`)); + }, +}); + +export const FollowerIndexEdit = connect( + mapStateToProps, + mapDispatchToProps +)(FollowerIndexEditView); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js new file mode 100644 index 0000000000000..d800634e969cd --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -0,0 +1,257 @@ +/* + * 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, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import chrome from 'ui/chrome'; +import { MANAGEMENT_BREADCRUMB } from 'ui/management'; + +import { + EuiPageContent, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiOverlayMask, + EuiConfirmModal, +} from '@elastic/eui'; + +import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; +import routing from '../../services/routing'; +import { + FollowerIndexForm, + FollowerIndexPageTitle, + SectionLoading, + SectionError, + RemoteClustersProvider, +} from '../../components'; +import { API_STATUS } from '../../constants'; + +export const FollowerIndexEdit = injectI18n( + class extends PureComponent { + static propTypes = { + getFollowerIndex: PropTypes.func.isRequired, + selectFollowerIndex: PropTypes.func.isRequired, + saveFollowerIndex: PropTypes.func.isRequired, + clearApiError: PropTypes.func.isRequired, + apiError: PropTypes.object.isRequired, + apiStatus: PropTypes.object.isRequired, + followerIndex: PropTypes.object, + followerIndexId: PropTypes.string, + } + + static getDerivedStateFromProps({ followerIndexId }, { lastFollowerIndexId }) { + if (lastFollowerIndexId !== followerIndexId) { + return { lastFollowerIndexId: followerIndexId }; + } + return null; + } + + state = { + lastFollowerIndexId: undefined, + showConfirmModal: false, + } + + componentDidMount() { + const { match: { params: { id } }, selectFollowerIndex } = this.props; + let decodedId; + try { + // When we navigate through the router (history.push) we need to decode both the uri and the id + decodedId = decodeURI(id); + decodedId = decodeURIComponent(decodedId); + } catch (e) { + // This is a page load. I guess that AngularJS router does already a decodeURI so it is not + // necessary in this case. + decodedId = decodeURIComponent(id); + } + + selectFollowerIndex(decodedId); + + chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb ]); + } + + componentDidUpdate(prevProps, prevState) { + const { followerIndex, getFollowerIndex } = this.props; + // Fetch the follower index on the server if we don't have it (i.e. page reload) + if (!followerIndex && prevState.lastFollowerIndexId !== this.state.lastFollowerIndexId) { + getFollowerIndex(this.state.lastFollowerIndexId); + } + } + + componentWillUnmount() { + this.props.clearApiError(); + } + + saveFollowerIndex = (name, followerIndex) => { + this.editedFollowerIndexPayload = { name, followerIndex }; + this.showConfirmModal(); + } + + confirmSaveFollowerIhdex = () => { + const { name, followerIndex } = this.editedFollowerIndexPayload; + this.props.saveFollowerIndex(name, followerIndex); + this.closeConfirmModal(); + } + + showConfirmModal = () => this.setState({ showConfirmModal: true }); + + closeConfirmModal = () => this.setState({ showConfirmModal: false }); + + renderLoadingFollowerIndex() { + return ( + + + + ); + } + + renderGetFollowerIndexError(error) { + const { intl, match: { params: { id: name } } } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorTitle', + defaultMessage: 'Error loading follower index', + }); + const errorMessage = error.status === 404 ? { + data: { + error: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorMessage', + defaultMessage: `The follower index '{name}' does not exist.`, + }, { name }) + } + } : error; + + return ( + + + + + + + + + + + ); + } + + renderConfirmModal = () => { + const { followerIndexId, intl } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.title', + defaultMessage: 'Update follower index \'{id}\'?', + }, { id: followerIndexId }); + + return ( + + +

+ +

+
+
+ ); + } + + render() { + const { + clearApiError, + apiStatus, + apiError, + followerIndex, + match: { url: currentUrl } + } = this.props; + + const { showConfirmModal } = this.state; + + /* remove non-editable properties */ + const { shards, ...rest } = followerIndex || {}; // eslint-disable-line no-unused-vars + + return ( + + + )} + /> + + {apiStatus.get === API_STATUS.LOADING && this.renderLoadingFollowerIndex()} + + {apiError.get && this.renderGetFollowerIndexError(apiError.get)} + { followerIndex && ( + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + + + ); + } + + if (error) { + remoteClusters = []; + } + + return ( + + ); + }} + + ) } + + { showConfirmModal && this.renderConfirmModal() } + + ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js new file mode 100644 index 0000000000000..7bc01ebd874e8 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/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 { FollowerIndexEdit } from './follower_index_edit.container'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js index df0c569f1e085..99e2d4083e96e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js @@ -335,9 +335,7 @@ export class DetailPanelUi extends Component { { - routing.navigate(encodeURI(`/auto_follow_patterns/edit/${encodeURIComponent(autoFollowPattern.name)}`)); - }} + href={routing.getAutoFollowPatternPath(autoFollowPattern.name)} > { + const uri = routing.getFollowerIndexPath(id, '/edit', false); + routing.navigate(uri); + } + render() { const { followerIndices } = this.props; const followerIndicesLength = followerIndices.length; @@ -140,6 +145,18 @@ export class ContextMenuUi extends Component { ) : null } + { followerIndexNames.length === 1 && ( + this.editFollowerIndex(followerIndexNames[0])} + > + + + ) } + {(unfollowLeaderIndex) => ( { + const uri = routing.getFollowerIndexPath(id, '/edit', false); + routing.navigate(uri); + } + getFilteredIndices = () => { const { followerIndices } = this.props; const { queryText } = this.state; @@ -64,6 +69,100 @@ export const FollowerIndicesTable = injectI18n( getTableColumns() { const { intl, selectFollowerIndex } = this.props; + const actions = [ + /* Pause or resume follower index */ + { + render: ({ name, shards }) => { + const isPaused = !shards || !shards.length; + const label = isPaused + ? intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionResumeDescription', + defaultMessage: 'Resume follower index', + }) + : intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionPauseDescription', + defaultMessage: 'Pause follower index', + }); + + return isPaused ? ( + + {(resumeFollowerIndex) => ( + resumeFollowerIndex(name)}> + + {label} + + )} + + ) : ( + + {(pauseFollowerIndex) => ( + pauseFollowerIndex(name)}> + + {label} + + )} + + ); + }, + }, + /* Edit follower index */ + { + render: ({ name }) => { + const label = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionEditDescription', + defaultMessage: 'Edit follower index', + }); + + return ( + this.editFollowerIndex(name)}> + + {label} + + ); + }, + }, + /* Unfollow leader index */ + { + render: ({ name }) => { + const label = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionUnfollowDescription', + defaultMessage: 'Unfollow leader index', + }); + + return ( + + {(unfollowLeaderIndex) => ( + unfollowLeaderIndex(name)}> + + {label} + + )} + + ); + }, + }, + ]; + return [{ field: 'name', name: intl.formatMessage({ @@ -121,86 +220,7 @@ export const FollowerIndicesTable = injectI18n( id: 'xpack.crossClusterReplication.followerIndexList.table.actionsColumnTitle', defaultMessage: 'Actions', }), - actions: [ - { - render: ({ name, shards }) => { - const isPaused = !shards || !shards.length; - const label = isPaused ? ( - - ) : ( - - ); - - return isPaused ? ( - - - {(resumeFollowerIndex) => ( - resumeFollowerIndex(name)} - /> - )} - - - ) : ( - - - {(pauseFollowerIndex) => ( - pauseFollowerIndex(name)} - /> - )} - - - ); - }, - }, - { - render: ({ name }) => { - const label = ( - - ); - - return ( - - - {(unfollowLeaderIndex) => ( - unfollowLeaderIndex(name)} - /> - )} - - - ); - }, - }, - ], + actions, width: '100px', }]; } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js index 5a72353730d24..510812426265f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js @@ -8,3 +8,4 @@ export { CrossClusterReplicationHome } from './home'; export { AutoFollowPatternAdd } from './auto_follow_pattern_add'; export { AutoFollowPatternEdit } from './auto_follow_pattern_edit'; export { FollowerIndexAdd } from './follower_index_add'; +export { FollowerIndexEdit } from './follower_index_edit'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js index 4fa43d19f552e..d122b1fbf17ff 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js @@ -89,6 +89,10 @@ export const unfollowLeaderIndex = (id) => { return httpClient.put(`${apiPrefix}/follower_indices/${ids}/unfollow`).then(extractData); }; +export const updateFollowerIndex = (id, followerIndex) => ( + httpClient.put(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`, followerIndex).then(extractData) +); + /* Stats */ export const loadAutoFollowStats = () => ( httpClient.get(`${apiPrefixIndexManagement}/stats/auto-follow`).then(extractData) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js new file mode 100644 index 0000000000000..942eaa9feb6f0 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js @@ -0,0 +1,22 @@ +/* + * 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. + */ + +const getFirstConnectedCluster = (clusters) => { + for (let i = 0; i < clusters.length; i++) { + if (clusters[i].isConnected) { + return clusters[i]; + } + } + + // No cluster connected, we return the first one in the list + return clusters.length ? clusters[0] : {}; +}; + +export const getRemoteClusterName = (remoteClusters, selected) => { + return selected && remoteClusters.some(c => c.name === selected) + ? selected + : getFirstConnectedCluster(remoteClusters).name; +}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js b/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js index fee74529f3fe0..eb6e0d10a6d7f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js @@ -16,12 +16,12 @@ const isModifiedEvent = event => !!(event.metaKey || event.altKey || event.ctrlK const isLeftClickEvent = event => event.button === 0; -const queryParamsFromObject = params => { +const queryParamsFromObject = (params, encodeParams = false) => { if (!params) { return; } - const paramsStr = stringify(params, '&', '=', { + const paramsStr = stringify(params, '&', '=', encodeParams ? {} : { encodeURIComponent: (val) => val, // Don't encode special chars }); return `?${paramsStr}`; @@ -42,8 +42,8 @@ class Routing { * * @param {*} to URL to navigate to */ - getRouterLinkProps(to, base = BASE_PATH, params = {}) { - const search = queryParamsFromObject(params) || ''; + getRouterLinkProps(to, base = BASE_PATH, params = {}, encodeParams = false) { + const search = queryParamsFromObject(params, encodeParams) || ''; const location = typeof to === "string" ? createLocation(base + to + search, null, null, this._reactRouter.history.location) : to; @@ -71,8 +71,8 @@ class Routing { return { href, onClick }; } - navigate(route = '/home', app = APPS.CCR_APP, params) { - const search = queryParamsFromObject(params); + navigate(route = '/home', app = APPS.CCR_APP, params, encodeParams = false) { + const search = queryParamsFromObject(params, encodeParams); this._reactRouter.history.push({ pathname: encodeURI(appToBasePathMap[app] + route), @@ -84,8 +84,10 @@ class Routing { return encodeURI(`#${BASE_PATH}/auto_follow_patterns${section}/${encodeURIComponent(name)}`); }; - getFollowerIndexPath = (name, section = '/edit') => { - return encodeURI(`#${BASE_PATH}/follower_indices${section}/${encodeURIComponent(name)}`); + getFollowerIndexPath = (name, section = '/edit', withBase = true) => { + return withBase + ? encodeURI(`#${BASE_PATH}/follower_indices${section}/${encodeURIComponent(name)}`) + : encodeURI(`/follower_indices${section}/${encodeURIComponent(name)}`); }; get reactRouter() { diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js b/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js index 24d8c901af112..3a9a9c33bafc2 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js @@ -21,8 +21,9 @@ export const AUTO_FOLLOW_PATTERN_DELETE = 'AUTO_FOLLOW_PATTERN_DELETE'; // Follower index export const FOLLOWER_INDEX_SELECT_DETAIL = 'FOLLOWER_INDEX_SELECT_DETAIL'; +export const FOLLOWER_INDEX_SELECT_EDIT = 'FOLLOWER_INDEX_SELECT_EDIT'; export const FOLLOWER_INDEX_LOAD = 'FOLLOWER_INDEX_LOAD'; -export const FOLLOWER_INDEX_GET = 'AUTO_FOLLOW_PATTERN_GET'; +export const FOLLOWER_INDEX_GET = 'FOLLOWER_INDEX_GET'; export const FOLLOWER_INDEX_CREATE = 'FOLLOWER_INDEX_CREATE'; export const FOLLOWER_INDEX_PAUSE = 'FOLLOWER_INDEX_PAUSE'; export const FOLLOWER_INDEX_RESUME = 'FOLLOWER_INDEX_RESUME'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js index 2bd3141f11155..3636befdc9bf7 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js @@ -43,7 +43,7 @@ export const loadAutoFollowPatterns = (isUpdating = false) => export const getAutoFollowPattern = (id) => sendApiRequest({ label: t.AUTO_FOLLOW_PATTERN_GET, - scope, + scope: `${scope}-get`, handler: async () => ( await getAutoFollowPatternRequest(id) ) @@ -53,7 +53,7 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false) sendApiRequest({ label: isUpdating ? t.AUTO_FOLLOW_PATTERN_UPDATE : t.AUTO_FOLLOW_PATTERN_CREATE, status: API_STATUS.SAVING, - scope, + scope: `${scope}-save`, handler: async () => { if (isUpdating) { return await updateAutoFollowPatternRequest(id, autoFollowPattern); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js index 16a1b9a7fb74e..78233e9305e4f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js @@ -14,6 +14,7 @@ import { pauseFollowerIndex as pauseFollowerIndexRequest, resumeFollowerIndex as resumeFollowerIndexRequest, unfollowLeaderIndex as unfollowLeaderIndexRequest, + updateFollowerIndex as updateFollowerIndexRequest, } from '../../services/api'; import * as t from '../action_types'; import { sendApiRequest } from './api'; @@ -26,6 +27,11 @@ export const selectDetailFollowerIndex = (id) => ({ payload: id }); +export const selectEditFollowerIndex = (id) => ({ + type: t.FOLLOWER_INDEX_SELECT_EDIT, + payload: id +}); + export const loadFollowerIndices = (isUpdating = false) => sendApiRequest({ label: t.FOLLOWER_INDEX_LOAD, @@ -39,25 +45,33 @@ export const loadFollowerIndices = (isUpdating = false) => export const getFollowerIndex = (id) => sendApiRequest({ label: t.FOLLOWER_INDEX_GET, - scope, + scope: `${scope}-get`, handler: async () => ( await getFollowerIndexRequest(id) ) }); -export const saveFollowerIndex = (name, followerIndex) => ( +export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => ( sendApiRequest({ label: t.FOLLOWER_INDEX_CREATE, status: API_STATUS.SAVING, - scope, - handler: async () => ( - await createFollowerIndexRequest({ name, ...followerIndex }) - ), + scope: `${scope}-save`, + handler: async () => { + if (isUpdating) { + return await updateFollowerIndexRequest(name, followerIndex); + } + return await createFollowerIndexRequest({ name, ...followerIndex }); + }, onSuccess() { - const successMessage = i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successNotificationTitle', { - defaultMessage: `Added follower index '{name}'`, - values: { name }, - }); + const successMessage = isUpdating + ? i18n.translate('xpack.crossClusterReplication.followerIndex.updateAction.successNotificationTitle', { + defaultMessage: `Follower index '{name}' updated successfully`, + values: { name }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successNotificationTitle', { + defaultMessage: `Added follower index '{name}'`, + values: { name }, + }); toastNotifications.addSuccess(successMessage); routing.navigate(`/follower_indices`, undefined, { diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js index 07905dbfef025..a7a9fea1e8eb5 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js @@ -32,6 +32,9 @@ export const reducer = (state = initialState, action) => { case t.FOLLOWER_INDEX_SELECT_DETAIL: { return { ...state, selectedDetailId: action.payload }; } + case t.FOLLOWER_INDEX_SELECT_EDIT: { + return { ...state, selectedEditId: action.payload }; + } case success(t.FOLLOWER_INDEX_UNFOLLOW): { const byId = { ...state.byId }; const { itemsUnfollowed } = action.payload; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js index b08eda8bc5a8b..8b0d4f18b21cd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js @@ -6,10 +6,11 @@ import { createSelector } from 'reselect'; import { objectToArray } from '../../services/utils'; +import { API_STATUS } from '../../constants'; // Api export const getApiState = (state) => state.api; -export const getApiStatus = (scope) => createSelector(getApiState, (apiState) => apiState.status[scope]); +export const getApiStatus = (scope) => createSelector(getApiState, (apiState) => apiState.status[scope] || API_STATUS.IDLE); export const getApiError = (scope) => createSelector(getApiState, (apiState) => apiState.error[scope]); export const isApiAuthorized = (scope) => createSelector(getApiError(scope), (error) => { if (!error) { diff --git a/x-pack/plugins/cross_cluster_replication/public/register_routes.js b/x-pack/plugins/cross_cluster_replication/public/register_routes.js index 5030cae7e94c7..6ab2229f0e6eb 100644 --- a/x-pack/plugins/cross_cluster_replication/public/register_routes.js +++ b/x-pack/plugins/cross_cluster_replication/public/register_routes.js @@ -20,7 +20,7 @@ if (chrome.getInjected('ccrUiEnabled')) { const unmountReactApp = () => elem && unmountComponentAtNode(elem); - routes.when(`${BASE_PATH}/:section?/:view?/:id?`, { + routes.when(`${BASE_PATH}/:section?/:subsection?/:view?/:id?`, { template: template, controllerAs: 'ccr', controller: class CrossClusterReplicationController { diff --git a/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js b/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js index f166b12ed60f4..716e4954c69b1 100644 --- a/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js +++ b/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js @@ -126,6 +126,7 @@ export const elasticsearchJsPlugin = (Client, config, components) => { } } ], + needBody: true, method: 'POST' }); diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js index 4c6c34ce68479..3f8e149659ae6 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js @@ -20,7 +20,7 @@ export const registerAutoFollowPatternRoutes = (server) => { const licensePreRouting = licensePreRoutingFactory(server); /** - * Returns a list of all Auto follow patterns + * Returns a list of all auto-follow patterns */ server.route({ path: `${API_BASE_PATH}/auto_follow_patterns`, @@ -114,7 +114,7 @@ export const registerAutoFollowPatternRoutes = (server) => { }); /** - * Returns a single Auto follow pattern + * Returns a single auto-follow pattern */ server.route({ path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, @@ -141,7 +141,7 @@ export const registerAutoFollowPatternRoutes = (server) => { }); /** - * Delete an auto follow pattern + * Delete an auto-follow pattern */ server.route({ path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, 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 93c4aa0f48649..6cd70dddbc6ee 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 @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import Boom from 'boom'; import { callWithRequestFactory } from '../../lib/call_with_request_factory'; import { isEsErrorFactory } from '../../lib/is_es_error_factory'; import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; @@ -11,6 +12,7 @@ import { deserializeFollowerIndex, deserializeListFollowerIndices, serializeFollowerIndex, + serializeAdvancedSettings, } from '../../lib/follower_index_serialization'; import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory'; import { API_BASE_PATH } from '../../../common/constants'; @@ -21,7 +23,7 @@ export const registerFollowerIndexRoutes = (server) => { const licensePreRouting = licensePreRoutingFactory(server); /** - * Returns a list of all Follower indices + * Returns a list of all follower indices */ server.route({ path: `${API_BASE_PATH}/follower_indices`, @@ -46,7 +48,6 @@ export const registerFollowerIndexRoutes = (server) => { }, }); - /** * Returns a single follower index pattern */ @@ -64,6 +65,11 @@ export const registerFollowerIndexRoutes = (server) => { const response = await callWithRequest('ccr.followerIndexStats', { id }); const followerIndex = response.indices[0]; + if (!followerIndex) { + const error = Boom.notFound(`The follower index "${id}" does not exist.`); + throw(error); + } + return deserializeFollowerIndex(followerIndex); } catch(err) { if (isEsError(err)) { @@ -74,7 +80,6 @@ export const registerFollowerIndexRoutes = (server) => { }, }); - /** * Create a follower index */ @@ -100,6 +105,35 @@ export const registerFollowerIndexRoutes = (server) => { }, }); + /** + * Edit a follower index + */ + server.route({ + path: `${API_BASE_PATH}/follower_indices/{id}`, + method: 'PUT', + config: { + pre: [ licensePreRouting ] + }, + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const { id: _id } = request.params; + const body = removeEmptyFields(serializeAdvancedSettings(request.payload)); + + // We need to first pause the follower and then resume it passing the advanced settings + try { + // Pause follower + await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); + + // Resume follower + return await callWithRequest('ccr.resumeFollowerIndex', { id: _id, body }); + } catch(err) { + if (isEsError(err)) { + throw wrapEsError(err); + } + throw wrapUnknownError(err); + } + }, + }); /** * Pauses a follower index diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js index cbf51074fed26..f4b34c6c30637 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js @@ -34,9 +34,10 @@ const registerHandlers = () => { 0: 'list', 1: 'get', 2: 'create', - 3: 'pause', - 4: 'resume', - 5: 'unfollow', + 3: 'edit', + 4: 'pause', + 5: 'resume', + 6: 'unfollow', }; const server = { diff --git a/x-pack/plugins/remote_clusters/public/index.scss b/x-pack/plugins/remote_clusters/public/index.scss index b25832255cece..f1bbc7941c608 100644 --- a/x-pack/plugins/remote_clusters/public/index.scss +++ b/x-pack/plugins/remote_clusters/public/index.scss @@ -10,14 +10,6 @@ // remoteClustersChart__legend--small // remoteClustersChart__legend-isLoading -/** - * 1. Override EUI styles. - */ -.remoteClusterAddPage { - max-width: 1000px !important; /* 1 */ - width: 100% !important; /* 1 */ -} - /** * 1. Override EuiFormRow styles. Otherwise the switch will jump around when toggled on and off, * as the 'Reset to defaults' link is added to and removed from the DOM. diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js index 4187bfdd82fba..69684ec234f1f 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js @@ -4,20 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { - EuiPage, - EuiPageBody, EuiPageContent, } from '@elastic/eui'; import { CRUD_APP_BASE_PATH } from '../../constants'; -import { listBreadcrumb, addBreadcrumb } from '../../services'; +import { listBreadcrumb, addBreadcrumb, getRouter, redirect, extractQueryParams } from '../../services'; import { RemoteClusterPageTitle, RemoteClusterForm } from '../components'; export const RemoteClusterAdd = injectI18n( @@ -44,40 +42,41 @@ export const RemoteClusterAdd = injectI18n( }; cancel = () => { - const { history } = this.props; - history.push(CRUD_APP_BASE_PATH); + const { history, route: { location: { search } } } = getRouter(); + const { redirect: redirectUrl } = extractQueryParams(search); + + if (redirectUrl) { + const decodedRedirect = decodeURIComponent(redirectUrl); + redirect(decodedRedirect); + } else { + history.push(CRUD_APP_BASE_PATH); + } }; render() { const { isAddingCluster, addClusterError } = this.props; return ( - - - - - - )} - /> + + + )} + /> - - - - - + + ); } } diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js index b0b3f65e5c99a..c4c40374b42b2 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js @@ -12,12 +12,10 @@ import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiLoadingSpinner, - EuiPage, - EuiPageBody, EuiPageContent, EuiSpacer, EuiText, @@ -25,7 +23,7 @@ import { } from '@elastic/eui'; import { CRUD_APP_BASE_PATH } from '../../constants'; -import { buildListBreadcrumb, editBreadcrumb } from '../../services'; +import { buildListBreadcrumb, editBreadcrumb, extractQueryParams, getRouter, getRouterLinkProps, redirect } from '../../services'; import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components'; const disabledFields = { @@ -85,13 +83,25 @@ export const RemoteClusterEdit = injectI18n( }; cancel = () => { - const { history, openDetailPanel } = this.props; + const { openDetailPanel } = this.props; const { clusterName } = this.state; - history.push(CRUD_APP_BASE_PATH); - openDetailPanel(clusterName); + const { history, route: { location: { search } } } = getRouter(); + const { redirect: redirectUrl } = extractQueryParams(search); + + if (redirectUrl) { + const decodedRedirect = decodeURIComponent(redirectUrl); + redirect(decodedRedirect); + } else { + history.push(CRUD_APP_BASE_PATH); + openDetailPanel(clusterName); + } }; renderContent() { + const { + clusterName, + } = this.state; + const { isLoading, cluster, @@ -126,26 +136,39 @@ export const RemoteClusterEdit = injectI18n( if (!cluster) { return ( - - - - - - - - + + + )} + color="danger" + iconType="alert" + > + + + + + + - - - - + + + +
); } @@ -184,33 +207,22 @@ export const RemoteClusterEdit = injectI18n( } render() { - const { - clusterName, - } = this.state; - return ( - - - - - - )} - /> + + + )} + /> - {this.renderContent()} - - - - + {this.renderContent()} + ); } } diff --git a/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js b/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js index eace258fd083d..5506bc75108c3 100644 --- a/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js +++ b/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js @@ -94,7 +94,7 @@ export const addCluster = (cluster) => async (dispatch) => { })); const decodedRedirect = decodeURIComponent(redirectUrl); - redirect(decodedRedirect); + redirect(`${decodedRedirect}?cluster=${cluster.name}`); } else { // This will open the new job in the detail panel. Note that we're *not* showing a success toast // here, because it would partially obscure the detail panel. diff --git a/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js b/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js index 2d0b61e6dad01..f209c575498f7 100644 --- a/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js +++ b/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js @@ -5,13 +5,14 @@ */ import { i18n } from '@kbn/i18n'; -import { fatalError } from 'ui/notify'; +import { fatalError, toastNotifications } from 'ui/notify'; import { CRUD_APP_BASE_PATH } from '../../constants'; import { loadClusters } from './load_clusters'; import { - editCluster as sendEditClusterRequest, + editCluster as sendEditClusterRequest, extractQueryParams, getRouter, + redirect, } from '../../services'; import { @@ -66,12 +67,26 @@ export const editCluster = (cluster) => async (dispatch) => { type: EDIT_CLUSTER_SUCCESS, }); - // This will open the new job in the detail panel. Note that we're *not* showing a success toast - // here, because it would partially obscure the detail panel. - getRouter().history.push({ - pathname: `${CRUD_APP_BASE_PATH}/list`, - search: `?cluster=${cluster.name}`, - }); + const { history, route: { location: { search } } } = getRouter(); + const { redirect: redirectUrl } = extractQueryParams(search); + + if (redirectUrl) { + // A toast is only needed if we're leaving the app. + toastNotifications.addSuccess(i18n.translate('xpack.remoteClusters.editAction.successTitle', { + defaultMessage: `Edited remote cluster '{name}'`, + values: { name: cluster.name }, + })); + + const decodedRedirect = decodeURIComponent(redirectUrl); + redirect(`${decodedRedirect}?cluster=${cluster.name}`); + } else { + // This will open the edited cluster in the detail panel. Note that we're *not* showing a success toast + // here, because it would partially obscure the detail panel. + history.push({ + pathname: `${CRUD_APP_BASE_PATH}/list`, + search: `?cluster=${cluster.name}`, + }); + } }; export const startEditingCluster = ({ clusterName }) => (dispatch) => {