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);