diff --git a/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js b/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js index f87d5767fafc2..0a948793e07db 100644 --- a/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js +++ b/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js @@ -8,3 +8,4 @@ export const BASE_PATH = '/management/elasticsearch/cross_cluster_replication'; export const BASE_PATH_REMOTE_CLUSTERS = '/management/elasticsearch/remote_clusters'; export const API_BASE_PATH = '/api/cross_cluster_replication'; export const API_REMOTE_CLUSTERS_BASE_PATH = '/api/remote_clusters'; +export const API_INDEX_MANAGEMENT_BASE_PATH = '/api/index_management'; 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 8ae8dd60c2fa6..9f43d1788258f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/app.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/app.js @@ -14,7 +14,8 @@ import { BASE_PATH } from '../../common/constants'; import { CrossClusterReplicationHome, AutoFollowPatternAdd, - AutoFollowPatternEdit + AutoFollowPatternEdit, + FollowerIndexAdd, } from './sections'; export class App extends Component { @@ -52,6 +53,7 @@ export class App extends Component { + 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/__snapshots__/follower_index_form.test.js.snap new file mode 100644 index 0000000000000..017f565ff9f48 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/__snapshots__/follower_index_form.test.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` state transitions updateFields() should merge new fields value with existing followerIndex 1`] = ` +Object { + "followerIndex": Object { + "leaderIndex": "bar", + "name": "new-name", + }, +} +`; + +exports[` state transitions updateFormErrors() should merge errors with existing fieldsErrors 1`] = ` +Object { + "fieldsErrors": Object { + "leaderIndex": null, + "name": "Some error", + }, +} +`; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js index 10ddd036cb4ad..da0ebbf4b9c13 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js @@ -40,7 +40,7 @@ export const AutoFollowPatternPageTitle = ({ title }) => ( iconType="help" > 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.js new file mode 100644 index 0000000000000..c06856f730695 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js @@ -0,0 +1,520 @@ +/* + * 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 { debounce } from 'lodash'; + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiDescribedFormGroup, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiLoadingKibana, + EuiLoadingSpinner, + EuiOverlayMask, + EuiSpacer, + 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'; + +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 getEmptyFollowerIndex = (remoteClusters) => ({ + name: '', + remoteCluster: getFirstConnectedCluster(remoteClusters).name, + leaderIndex: '', +}); + +/** + * State transitions: fields update + */ +export const updateFields = (fields) => ({ followerIndex }) => ({ + followerIndex: { + ...followerIndex, + ...fields, + }, +}); + +/** + * State transitions: errors update + */ +export const updateFormErrors = (errors) => ({ fieldsErrors }) => ({ + fieldsErrors: { + ...fieldsErrors, + ...errors, + } +}); + +export const FollowerIndexForm = injectI18n( + class extends PureComponent { + static propTypes = { + saveFollowerIndex: PropTypes.func.isRequired, + clearApiError: PropTypes.func.isRequired, + followerIndex: PropTypes.object, + apiError: PropTypes.object, + apiStatus: PropTypes.string.isRequired, + remoteClusters: PropTypes.array.isRequired, + } + + constructor(props) { + super(props); + + const isNew = this.props.followerIndex === undefined; + + const followerIndex = isNew + ? getEmptyFollowerIndex(this.props.remoteClusters) + : { + ...this.props.followerIndex, + }; + + this.state = { + followerIndex, + fieldsErrors: validateFollowerIndex(followerIndex), + areErrorsVisible: false, + isNew, + }; + + this.validateIndexName = debounce(this.validateIndexName, 500); + } + + onFieldsChange = (fields) => { + const errors = validateFollowerIndex(fields); + this.setState(updateFields(fields)); + this.setState(updateFormErrors(errors)); + + if (this.props.apiError) { + this.props.clearApiError(); + } + }; + + onClusterChange = (remoteCluster) => { + this.onFieldsChange({ remoteCluster }); + }; + + getFields = () => { + return this.state.followerIndex; + }; + + isFormValid() { + return Object.values(this.state.fieldsErrors).every(error => error === null); + } + + sendForm = () => { + const isFormValid = this.isFormValid(); + + this.setState({ areErrorsVisible: !isFormValid }); + + if (!isFormValid) { + return; + } + + const { name, ...followerIndex } = this.getFields(); + + this.props.saveFollowerIndex(name, followerIndex); + }; + + cancelForm = () => { + 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 + */ + renderApiErrors() { + const { apiError, intl } = this.props; + + if (apiError) { + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexForm.savingErrorTitle', + defaultMessage: 'Error creating follower index', + }); + const { leaderIndex } = this.state.followerIndex; + const error = apiError.status === 404 + ? { + data: { + message: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexForm.leaderIndexNotFoundError', + defaultMessage: `The leader index '{leaderIndex}' you want to replicate from does not exist.`, + }, { leaderIndex }) + } + } + : apiError; + return ; + } + + return null; + } + + renderForm = () => { + const { + followerIndex: { + name, + remoteCluster, + leaderIndex, + }, + isNew, + areErrorsVisible, + fieldsErrors, + } = this.state; + + /** + * Follower index name + */ + const renderFollowerIndexName = () => { + const hasError = !!fieldsErrors.name; + const isInvalid = hasError && (fieldsErrors.name.alwaysVisible || areErrorsVisible); + + return ( + +

+ +

+ + )} + description={( + + )} + fullWidth + > + + )} + helpText={( + {indexNameIllegalCharacters} }} + /> + )} + error={fieldsErrors.name && fieldsErrors.name.message} + isInvalid={isInvalid} + fullWidth + > + this.onIndexNameChange(e.target.value)} + fullWidth + disabled={!isNew} + /> + +
+ ); + }; + + /** + * 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}` + })); + + return ( + +

+ +

+ + )} + description={( + + )} + fullWidth + > + + )} + fullWidth + > + + { isNew && ( + + )} + { !isNew && ( + + )} + + +
+ ); + }; + + /** + * Leader index + */ + const renderLeaderIndex = () => { + const hasError = !!fieldsErrors.leaderIndex; + const isInvalid = hasError && areErrorsVisible; + + return ( + +

+ +

+ + )} + description={( + +

+ +

+
+ )} + fullWidth + > + + )} + helpText={( + {indexNameIllegalCharacters} }} + /> + )} + isInvalid={isInvalid} + error={fieldsErrors.leaderIndex && fieldsErrors.leaderIndex.message} + fullWidth + > + this.onFieldsChange({ leaderIndex: e.target.value })} + fullWidth + /> + +
+ ); + }; + + /** + * Form Error warning message + */ + const renderFormErrorWarning = () => { + const { areErrorsVisible } = this.state; + const isFormValid = this.isFormValid(); + + if (!areErrorsVisible || isFormValid) { + return null; + } + + return ( + + + + )} + color="danger" + iconType="cross" + /> + + ); + }; + + /** + * Form Actions + */ + const renderActions = () => { + const { apiStatus } = this.props; + const { areErrorsVisible } = this.state; + + if (apiStatus === API_STATUS.SAVING) { + return ( + + + + + + + + + + + + ); + } + + const isSaveDisabled = areErrorsVisible && !this.isFormValid(); + + return ( + + + + + + + + + + + + + + ); + }; + + return ( + + + {renderFollowerIndexName()} + {renderRemoteClusterField()} + {renderLeaderIndex()} + + {renderFormErrorWarning()} + + {renderActions()} + + ); + } + + renderLoading = () => { + const { apiStatus } = this.props; + + if (apiStatus === API_STATUS.SAVING) { + return ( + + + + ); + } + return null; + } + + render() { + return ( + + {this.renderApiErrors()} + {this.renderForm()} + {this.renderLoading()} + + ); + } + } +); + + 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.test.js new file mode 100644 index 0000000000000..74fdf3301bcca --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.test.js @@ -0,0 +1,32 @@ +/* + * 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 { updateFields, updateFormErrors } from './follower_index_form'; + +jest.mock('ui/indices', () => ({ + INDEX_ILLEGAL_CHARACTERS_VISIBLE: [], +})); + +describe(' state transitions', () => { + it('updateFormErrors() should merge errors with existing fieldsErrors', () => { + const errors = { name: 'Some error' }; + const state = { + fieldsErrors: { leaderIndex: null } + }; + const output = updateFormErrors(errors)(state); + expect(output).toMatchSnapshot(); + }); + + it('updateFields() should merge new fields value with existing followerIndex', () => { + const fields = { name: 'new-name' }; + const state = { + followerIndex: { name: 'foo', leaderIndex: 'bar' } + }; + const output = updateFields(fields)(state); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js new file mode 100644 index 0000000000000..a1ec4a44e218a --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiPageContentHeader, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { followerIndexUrl } from '../services/documentation_links'; + +export const FollowerIndexPageTitle = ({ title }) => ( + + + + + + + +

{title}

+
+
+ + + + + + +
+
+
+); + +FollowerIndexPageTitle.propTypes = { + title: PropTypes.node.isRequired, +}; 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 1e717eb8ea22d..5bf4ed3512a57 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 @@ -12,5 +12,6 @@ export { AutoFollowPatternForm } from './auto_follow_pattern_form'; export { AutoFollowPatternDeleteProvider } from './auto_follow_pattern_delete_provider'; export { AutoFollowPatternPageTitle } from './auto_follow_pattern_page_title'; export { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview'; - export { FollowerIndexDeleteProvider } from './follower_index_delete_provider'; +export { FollowerIndexForm } from './follower_index_form'; +export { FollowerIndexPageTitle } from './follower_index_page_title'; 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 new file mode 100644 index 0000000000000..cf2efcb98432a --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js @@ -0,0 +1,29 @@ +/* + * 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 } from '../../store/selectors'; +import { saveFollowerIndex, clearApiError } from '../../store/actions'; +import { FollowerIndexAdd as FollowerIndexAddView } from './follower_index_add'; + +const scope = SECTIONS.FOLLOWER_INDEX; + +const mapStateToProps = (state) => ({ + apiStatus: getApiStatus(scope)(state), + apiError: getApiError(scope)(state), +}); + +const mapDispatchToProps = dispatch => ({ + saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex)), + clearApiError: () => dispatch(clearApiError(scope)), +}); + +export const FollowerIndexAdd = connect( + mapStateToProps, + mapDispatchToProps +)(FollowerIndexAddView); 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 new file mode 100644 index 0000000000000..44bd15b8cbf96 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js @@ -0,0 +1,186 @@ +/* + * 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 { + 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, + RemoteClustersProvider, + SectionLoading, + SectionError, +} from '../../components'; + +export const FollowerIndexAdd = injectI18n( + class extends PureComponent { + static propTypes = { + saveFollowerIndex: PropTypes.func.isRequired, + clearApiError: PropTypes.func.isRequired, + apiError: PropTypes.object, + apiStatus: PropTypes.string.isRequired, + } + + componentDidMount() { + chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb ]); + } + + componentWillUnmount() { + 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; + + 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 ( + + ); + }} + + + + + ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js new file mode 100644 index 0000000000000..f7433f828fbba --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/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 { FollowerIndexAdd } from './follower_index_add.container'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js index 43c052fcd8094..a1fc606808eea 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js @@ -10,7 +10,6 @@ import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { getIndexListUri } from '../../../../../../../../index_management/public/services/navigation'; import { - EuiButton, EuiButtonEmpty, EuiCodeEditor, EuiDescriptionList, @@ -95,8 +94,8 @@ export class DetailPanelUi extends Component { @@ -239,38 +238,20 @@ export class DetailPanelUi extends Component { {followerIndex && ( - - - - {(deleteFollowerIndex) => ( - deleteFollowerIndex(followerIndex.name)} - > - - - )} - - - - - { - // routing.navigate(encodeURI(`/follower_indices/edit/${encodeURIComponent(followerIndex.name)}`)); - }} + + {(deleteFollowerIndex) => ( + deleteFollowerIndex(followerIndex.name)} > - - - + + )} + + )} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js index d782211d9e3bb..5df1c36be4f0f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js @@ -19,7 +19,6 @@ import { } from '@elastic/eui'; // import { API_STATUS } from '../../../../../constants'; import { FollowerIndexDeleteProvider } from '../../../../../components'; -// import routing from '../../../../../services/routing'; export const FollowerIndicesTable = injectI18n( class extends PureComponent { @@ -126,29 +125,7 @@ export const FollowerIndicesTable = injectI18n( ); }, - }, - { - render: ({ /*name*/ }) => { - const label = i18n.translate('xpack.crossClusterReplication.followerIndexList.table.actionEditDescription', { - defaultMessage: 'Edit follower index', - }); - - return ( - - - - ); - }, - }, - ], + }], width: '100px', }]; } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js index bd6fbdd3812ef..b20c375e9d4ac 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js @@ -9,7 +9,7 @@ import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -// import routing from '../../../services/routing'; +import routing from '../../../services/routing'; import { extractQueryParams } from '../../../services/query_params'; import { API_STATUS } from '../../../constants'; import { SectionLoading, SectionError } from '../../../components'; @@ -109,7 +109,7 @@ export const FollowerIndicesList = injectI18n( } actions={ diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js index 51b34c1b2769f..51f862ab11dbc 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js @@ -105,7 +105,7 @@ export const CrossClusterReplicationHome = injectI18n( {isFollowerIndexApiAuthorized && ( 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 1c15088c7b695..5a72353730d24 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 @@ -7,3 +7,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'; 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 428c5eac79ac6..be872d1df3d00 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 @@ -5,24 +5,36 @@ */ import chrome from 'ui/chrome'; -import { API_BASE_PATH, API_REMOTE_CLUSTERS_BASE_PATH } from '../../../common/constants'; +import { + API_BASE_PATH, + API_REMOTE_CLUSTERS_BASE_PATH, + API_INDEX_MANAGEMENT_BASE_PATH, +} from '../../../common/constants'; import { arrify } from '../../../common/services/utils'; const apiPrefix = chrome.addBasePath(API_BASE_PATH); const apiPrefixRemoteClusters = chrome.addBasePath(API_REMOTE_CLUSTERS_BASE_PATH); +const apiPrefixIndexManagement = chrome.addBasePath(API_INDEX_MANAGEMENT_BASE_PATH); // This is an Angular service, which is why we use this provider pattern // to access it within our React app. let httpClient; -export function setHttpClient(client) { +// The deffered AngularJS api allows us to create deferred promise +// to be resolved later. This allows us to cancel in flight Http Requests +// https://docs.angularjs.org/api/ng/service/$q#the-deferred-api +let $q; + +export function setHttpClient(client, $deffered) { httpClient = client; + $q = $deffered; } // --- const extractData = (response) => response.data; +/* Auto Follow Pattern */ export const loadAutoFollowPatterns = () => ( httpClient.get(`${apiPrefix}/auto_follow_patterns`).then(extractData) ); @@ -49,6 +61,7 @@ export const deleteAutoFollowPattern = (id) => { return httpClient.delete(`${apiPrefix}/auto_follow_patterns/${ids}`).then(extractData); }; +/* Follower Index */ export const loadFollowerIndices = () => ( httpClient.get(`${apiPrefix}/follower_indices`).then(extractData) ); @@ -57,6 +70,26 @@ export const getFollowerIndex = (id) => ( httpClient.get(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`).then(extractData) ); +export const createFollowerIndex = (followerIndex) => ( + httpClient.post(`${apiPrefix}/follower_indices`, followerIndex).then(extractData) +); + +/* Stats */ export const loadAutoFollowStats = () => ( - httpClient.get(`${apiPrefix}/stats/auto-follow`).then(extractData) + httpClient.get(`${apiPrefixIndexManagement}/stats/auto-follow`).then(extractData) ); + +/* Indices */ +let canceler = null; +export const loadIndices = () => { + if (canceler) { + // If there is a previous request in flight we cancel it by resolving the canceler + canceler.resolve(); + } + canceler = $q.defer(); + return httpClient.get(`${apiPrefixIndexManagement}/indices`, { timeout: canceler.promise }) + .then((response) => { + canceler = null; + return extractData(response); + }); +}; 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 a63bb87921162..67e0a9fdc2054 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 @@ -9,3 +9,4 @@ import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; export const autoFollowPatternUrl = `${esBase}/ccr-put-auto-follow-pattern.html`; +export const followerIndexUrl = `${esBase}/ccr-put-follow.html`; 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 new file mode 100644 index 0000000000000..df31e2f3d1c23 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_validators.js @@ -0,0 +1,100 @@ +/* + * 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/routing.js b/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js index fd24a8932f532..fee74529f3fe0 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 @@ -84,6 +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)}`); + }; + get reactRouter() { return this._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 42e9e207cd68d..68e9ca9788eff 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 @@ -23,6 +23,8 @@ export const AUTO_FOLLOW_PATTERN_DELETE = 'AUTO_FOLLOW_PATTERN_DELETE'; export const FOLLOWER_INDEX_SELECT_DETAIL = 'FOLLOWER_INDEX_SELECT_DETAIL'; export const FOLLOWER_INDEX_LOAD = 'FOLLOWER_INDEX_LOAD'; export const FOLLOWER_INDEX_GET = 'AUTO_FOLLOW_PATTERN_GET'; +export const FOLLOWER_INDEX_CREATE = 'FOLLOWER_INDEX_CREATE'; +export const FOLLOWER_INDEX_UPDATE = 'FOLLOWER_INDEX_UPDATE'; // Stats export const AUTO_FOLLOW_STATS_LOAD = 'AUTO_FOLLOW_STATS_LOAD'; 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 b041be4e4bea5..2bd3141f11155 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 @@ -62,11 +62,11 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false) }, onSuccess() { const successMessage = isUpdating - ? i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successMultipleNotificationTitle', { + ? i18n.translate('xpack.crossClusterReplication.autoFollowPattern.updateAction.successNotificationTitle', { defaultMessage: `Auto-follow pattern '{name}' updated successfully`, values: { name: id }, }) - : i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successSingleNotificationTitle', { + : i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successNotificationTitle', { defaultMessage: `Added auto-follow pattern '{name}'`, values: { name: id }, }); 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 1f6b3ffcb1a86..7f67849ad6d91 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 @@ -3,10 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; +import { toastNotifications } from 'ui/notify'; +import routing from '../../services/routing'; import { SECTIONS, API_STATUS } from '../../constants'; import { loadFollowerIndices as loadFollowerIndicesRequest, getFollowerIndex as getFollowerIndexRequest, + createFollowerIndex as createFollowerIndexRequest, } from '../../services/api'; import * as t from '../action_types'; import { sendApiRequest } from './api'; @@ -36,3 +40,25 @@ export const getFollowerIndex = (id) => await getFollowerIndexRequest(id) ) }); + +export const saveFollowerIndex = (name, followerIndex) => ( + sendApiRequest({ + label: t.FOLLOWER_INDEX_CREATE, + status: API_STATUS.SAVING, + scope, + handler: async () => ( + await createFollowerIndexRequest({ name, ...followerIndex }) + ), + onSuccess() { + const successMessage = i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successNotificationTitle', { + defaultMessage: `Added follower index '{name}'`, + values: { name }, + }); + + toastNotifications.addSuccess(successMessage); + routing.navigate(`/follower_indices`, undefined, { + pattern: encodeURIComponent(name), + }); + }, + }) +); 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 8c043046a149f..b08eda8bc5a8b 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 @@ -59,5 +59,3 @@ export const getSelectedFollowerIndex = (view = 'detail') => createSelector(getF return followerIndexState.byId[followerIndexState[propId]]; }); export const getListFollowerIndices = createSelector(getFollowerIndices, (followerIndices) => objectToArray(followerIndices)); - - 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 d8084f1d3415c..5030cae7e94c7 100644 --- a/x-pack/plugins/cross_cluster_replication/public/register_routes.js +++ b/x-pack/plugins/cross_cluster_replication/public/register_routes.js @@ -24,7 +24,7 @@ if (chrome.getInjected('ccrUiEnabled')) { template: template, controllerAs: 'ccr', controller: class CrossClusterReplicationController { - constructor($scope, $route, $http) { + constructor($scope, $route, $http, $q) { /** * React-router's does not play well with the angular router. It will cause this controller * to re-execute without the $destroy handler being called. This means that the app will be mounted twice @@ -35,7 +35,7 @@ if (chrome.getInjected('ccrUiEnabled')) { // NOTE: We depend upon Angular's $http service because it's decorated with interceptors, // e.g. to check license status per request. - setHttpClient($http); + setHttpClient($http, $q); $scope.$$postDigest(() => { elem = document.getElementById(CCR_REACT_ROOT);