diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_pause_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_pause_provider.js
new file mode 100644
index 0000000000000..837d77a893f5a
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_pause_provider.js
@@ -0,0 +1,118 @@
+/*
+ * 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 { connect } from 'react-redux';
+import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
+import {
+ EuiConfirmModal,
+ EuiOverlayMask,
+} from '@elastic/eui';
+
+import { pauseFollowerIndex } from '../store/actions';
+import { arrify } from '../../../common/services/utils';
+
+class Provider extends PureComponent {
+ state = {
+ isModalOpen: false,
+ ids: null
+ }
+
+ onMouseOverModal = (event) => {
+ // This component can sometimes be used inside of an EuiToolTip, in which case mousing over
+ // the modal can trigger the tooltip. Stopping propagation prevents this.
+ event.stopPropagation();
+ };
+
+ pauseFollowerIndex = (id) => {
+ this.setState({ isModalOpen: true, ids: arrify(id) });
+ };
+
+ onConfirm = () => {
+ this.props.pauseFollowerIndex(this.state.ids);
+ this.setState({ isModalOpen: false, ids: null });
+ }
+
+ closeConfirmModal = () => {
+ this.setState({
+ isModalOpen: false,
+ });
+ };
+
+ renderModal = () => {
+ const { intl } = this.props;
+ const { ids } = this.state;
+ const isSingle = ids.length === 1;
+ const title = isSingle
+ ? intl.formatMessage({
+ id: 'xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.pauseSingleTitle',
+ defaultMessage: 'Pause follower index \'{name}\'?',
+ }, { name: ids[0] })
+ : intl.formatMessage({
+ id: 'xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.pauseMultipleTitle',
+ defaultMessage: 'Pause {count} follower indices?',
+ }, { count: ids.length });
+
+ return (
+
+ { /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ }
+
+ {!isSingle && (
+
+
+
+
+
+
+ )}
+
+
+ );
+ }
+
+ render() {
+ const { children } = this.props;
+ const { isModalOpen } = this.state;
+
+ return (
+
+ {children(this.pauseFollowerIndex)}
+ {isModalOpen && this.renderModal()}
+
+ );
+ }
+}
+
+const mapDispatchToProps = (dispatch) => ({
+ pauseFollowerIndex: (id) => dispatch(pauseFollowerIndex(id)),
+});
+
+export const FollowerIndexPauseProvider = connect(
+ undefined,
+ mapDispatchToProps
+)(injectI18n(Provider));
+
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_delete_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_resume_provider.js
similarity index 70%
rename from x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_delete_provider.js
rename to x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_resume_provider.js
index 4941df861eb51..d6bdeaca06755 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_delete_provider.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_resume_provider.js
@@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
-
import React, { PureComponent, Fragment } from 'react';
import { connect } from 'react-redux';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
@@ -13,7 +12,7 @@ import {
EuiOverlayMask,
} from '@elastic/eui';
-// import { deleteFollowerIndex } from '../store/actions';
+import { resumeFollowerIndex } from '../store/actions';
import { arrify } from '../../../common/services/utils';
class Provider extends PureComponent {
@@ -28,12 +27,12 @@ class Provider extends PureComponent {
event.stopPropagation();
};
- deleteFollowerIndex = (id) => {
+ resumeFollowerIndex = (id) => {
this.setState({ isModalOpen: true, ids: arrify(id) });
};
onConfirm = () => {
- // this.props.deleteFollowerIndex(this.state.ids);
+ this.props.resumeFollowerIndex(this.state.ids);
this.setState({ isModalOpen: false, ids: null });
}
@@ -49,12 +48,12 @@ class Provider extends PureComponent {
const isSingle = ids.length === 1;
const title = isSingle
? intl.formatMessage({
- id: 'xpack.crossClusterReplication.deleteFollowerIndex.confirmModal.deleteSingleTitle',
- defaultMessage: 'Remove follower index \'{name}\'?',
+ id: 'xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.resumeSingleTitle',
+ defaultMessage: 'Resume follower index \'{name}\'?',
}, { name: ids[0] })
: intl.formatMessage({
- id: 'xpack.crossClusterReplication.deleteFollowerIndex.confirmModal.deleteMultipleTitle',
- defaultMessage: 'Remove {count} follower indices?',
+ id: 'xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.resumeMultipleTitle',
+ defaultMessage: 'Resume {count} follower indices?',
}, { count: ids.length });
return (
@@ -66,15 +65,15 @@ class Provider extends PureComponent {
onConfirm={this.onConfirm}
cancelButtonText={
intl.formatMessage({
- id: 'xpack.crossClusterReplication.deleteFollowerIndex.confirmModal.cancelButtonText',
+ id: 'xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.cancelButtonText',
defaultMessage: 'Cancel',
})
}
- buttonColor="danger"
+ buttonColor="primary"
confirmButtonText={
intl.formatMessage({
- id: 'xpack.crossClusterReplication.deleteFollowerIndex.confirmModal.confirmButtonText',
- defaultMessage: 'Remove',
+ id: 'xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.confirmButtonText',
+ defaultMessage: 'Resume',
})
}
onMouseOver={this.onMouseOverModal}
@@ -83,8 +82,8 @@ class Provider extends PureComponent {
@@ -101,18 +100,18 @@ class Provider extends PureComponent {
return (
- {children(this.deleteFollowerIndex)}
+ {children(this.resumeFollowerIndex)}
{isModalOpen && this.renderModal()}
);
}
}
-const mapDispatchToProps = (/*dispatch*/) => ({
- // deleteFollowerIndex: (id) => dispatch(deleteFollowerIndex(id)),
+const mapDispatchToProps = (dispatch) => ({
+ resumeFollowerIndex: (id) => dispatch(resumeFollowerIndex(id)),
});
-export const FollowerIndexDeleteProvider = connect(
+export const FollowerIndexResumeProvider = connect(
undefined,
mapDispatchToProps
)(injectI18n(Provider));
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_unfollow_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_unfollow_provider.js
new file mode 100644
index 0000000000000..86ac938582062
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_unfollow_provider.js
@@ -0,0 +1,127 @@
+/*
+ * 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 { connect } from 'react-redux';
+import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
+import {
+ EuiConfirmModal,
+ EuiOverlayMask,
+} from '@elastic/eui';
+
+import { unfollowLeaderIndex } from '../store/actions';
+import { arrify } from '../../../common/services/utils';
+
+class Provider extends PureComponent {
+ state = {
+ isModalOpen: false,
+ ids: null
+ }
+
+ onMouseOverModal = (event) => {
+ // This component can sometimes be used inside of an EuiToolTip, in which case mousing over
+ // the modal can trigger the tooltip. Stopping propagation prevents this.
+ event.stopPropagation();
+ };
+
+ unfollowLeaderIndex = (id) => {
+ this.setState({ isModalOpen: true, ids: arrify(id) });
+ };
+
+ onConfirm = () => {
+ this.props.unfollowLeaderIndex(this.state.ids);
+ this.setState({ isModalOpen: false, ids: null });
+ }
+
+ closeConfirmModal = () => {
+ this.setState({
+ isModalOpen: false,
+ });
+ };
+
+ renderModal = () => {
+ const { intl } = this.props;
+ const { ids } = this.state;
+ const isSingle = ids.length === 1;
+ const title = isSingle
+ ? intl.formatMessage({
+ id: 'xpack.crossClusterReplication.unfollowLeaderIndex.confirmModal.unfollowSingleTitle',
+ defaultMessage: 'Unfollow leader index of follower index \'{name}\'?',
+ }, { name: ids[0] })
+ : intl.formatMessage({
+ id: 'xpack.crossClusterReplication.unfollowLeaderIndex.confirmModal.unfollowMultipleTitle',
+ defaultMessage: 'Unfollow leader indices of {count} follower indices?',
+ }, { count: ids.length });
+
+ return (
+
+ { /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ }
+
+ {isSingle ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+ );
+ }
+
+ render() {
+ const { children } = this.props;
+ const { isModalOpen } = this.state;
+
+ return (
+
+ {children(this.unfollowLeaderIndex)}
+ {isModalOpen && this.renderModal()}
+
+ );
+ }
+}
+
+const mapDispatchToProps = (dispatch) => ({
+ unfollowLeaderIndex: (id) => dispatch(unfollowLeaderIndex(id)),
+});
+
+export const FollowerIndexUnfollowProvider = connect(
+ undefined,
+ mapDispatchToProps
+)(injectI18n(Provider));
+
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 5bf4ed3512a57..1a5d4a9d1eb3f 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,6 +12,8 @@ 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 { FollowerIndexPauseProvider } from './follower_index_pause_provider';
+export { FollowerIndexResumeProvider } from './follower_index_resume_provider';
+export { FollowerIndexUnfollowProvider } from './follower_index_unfollow_provider';
export { FollowerIndexForm } from './follower_index_form';
export { FollowerIndexPageTitle } from './follower_index_page_title';
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js
new file mode 100644
index 0000000000000..6b0973d4e5858
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js
@@ -0,0 +1,163 @@
+/*
+ * 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, { Component } from 'react';
+import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
+import PropTypes from 'prop-types';
+
+import {
+ FollowerIndexPauseProvider,
+ FollowerIndexResumeProvider,
+ FollowerIndexUnfollowProvider
+} from '../../../../../components';
+
+import {
+ EuiButton,
+ EuiContextMenuPanel,
+ EuiContextMenuItem,
+ EuiPopover,
+ EuiPopoverTitle,
+} from '@elastic/eui';
+
+export class ContextMenuUi extends Component {
+
+ static propTypes = {
+ iconSide: PropTypes.string,
+ iconType: PropTypes.string,
+ anchorPosition: PropTypes.string,
+ label: PropTypes.node,
+ followerIndices: PropTypes.array.isRequired,
+ }
+
+ state = {
+ isPopoverOpen: false,
+ }
+
+ onButtonClick = () => {
+ this.setState(prevState => ({
+ isPopoverOpen: !prevState.isPopoverOpen
+ }));
+ };
+
+ closePopover = () => {
+ this.setState({
+ isPopoverOpen: false
+ });
+ };
+
+ render() {
+ const { followerIndices } = this.props;
+ const followerIndicesLength = followerIndices.length;
+ const followerIndexNames = followerIndices.map((index) => index.name);
+ const {
+ iconSide = 'right',
+ iconType = 'arrowDown',
+ anchorPosition = 'rightUp',
+ label = (
+
+ ),
+ } = this.props;
+
+
+ const button = (
+
+ {label}
+
+ );
+
+ // TODO: Fix with correct logic when paused status info is available from ES, currently all
+ // follower indices are assumed to be active: https://github.com/elastic/elasticsearch/issues/37127
+ const pausedFollowerIndexNames = followerIndices.filter(({ shards }) => !shards || !shards.length).map((index) => index.name);
+ const activeFollowerIndexNames = followerIndices.filter(({ shards }) => shards && shards.length).map((index) => index.name);
+
+ return (
+
+
+
+
+
+
+ {
+ activeFollowerIndexNames.length ? (
+
+ {(pauseFollowerIndex) => (
+ pauseFollowerIndex(activeFollowerIndexNames)}
+ >
+
+
+ )}
+
+ ) : null
+ }
+
+ {
+ pausedFollowerIndexNames.length ? (
+
+ {(resumeFollowerIndex) => (
+ resumeFollowerIndex(pausedFollowerIndexNames)}
+ >
+
+
+ )}
+
+ ) : null
+ }
+
+
+ {(unfollowLeaderIndex) => (
+ unfollowLeaderIndex(followerIndexNames)}
+ >
+
+
+ )}
+
+
+
+ );
+ }
+}
+
+export const ContextMenu = injectI18n(ContextMenuUi);
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js
new file mode 100644
index 0000000000000..8c2d3743ecfc6
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/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 { ContextMenu } from './context_menu';
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 a1fc606808eea..bf311f271ef22 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
@@ -32,9 +32,7 @@ import {
import 'brace/theme/textmate';
-import {
- FollowerIndexDeleteProvider,
-} from '../../../../../components';
+import { ContextMenu } from '../context_menu';
import { API_STATUS } from '../../../../../constants';
// import routing from '../../../../../services/routing';
@@ -238,20 +236,18 @@ export class DetailPanelUi extends Component {
{followerIndex && (
-
- {(deleteFollowerIndex) => (
- deleteFollowerIndex(followerIndex.name)}
- >
-
-
+
)}
-
-
+ followerIndices={[followerIndex]}
+ />
)}
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js
index d522f47559374..ea6a4c9fd71d1 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js
@@ -6,22 +6,22 @@
import { connect } from 'react-redux';
-// import { SECTIONS } from '../../../../../constants';
+import { SECTIONS } from '../../../../../constants';
import { selectDetailFollowerIndex } from '../../../../../store/actions';
-// import { getApiStatus } from '../../../../../store/selectors';
+import { getApiStatus } from '../../../../../store/selectors';
import { FollowerIndicesTable as FollowerIndicesTableComponent } from './follower_indices_table';
-// const scope = SECTIONS.FOLLOWER_INDEX;
-//
-// const mapStateToProps = (state) => ({
-// // apiStatusDelete: getApiStatus(`${scope}-delete`)(state),
-// });
+const scope = SECTIONS.FOLLOWER_INDEX;
+
+const mapStateToProps = (state) => ({
+ apiStatusDelete: getApiStatus(`${scope}-delete`)(state),
+});
//
const mapDispatchToProps = (dispatch) => ({
selectFollowerIndex: (name) => dispatch(selectDetailFollowerIndex(name)),
});
export const FollowerIndicesTable = connect(
- null,
+ mapStateToProps,
mapDispatchToProps,
)(FollowerIndicesTableComponent);
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 5df1c36be4f0f..d4365761d30b9 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
@@ -6,19 +6,22 @@
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
-import { i18n } from '@kbn/i18n';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
- EuiButton,
EuiButtonIcon,
EuiInMemoryTable,
EuiLink,
- // EuiLoadingKibana,
+ EuiLoadingKibana,
EuiToolTip,
- // EuiOverlayMask,
+ EuiOverlayMask,
} from '@elastic/eui';
-// import { API_STATUS } from '../../../../../constants';
-import { FollowerIndexDeleteProvider } from '../../../../../components';
+import { API_STATUS } from '../../../../../constants';
+import {
+ FollowerIndexPauseProvider,
+ FollowerIndexResumeProvider,
+ FollowerIndexUnfollowProvider
+} from '../../../../../components';
+import { ContextMenu } from '../context_menu';
export const FollowerIndicesTable = injectI18n(
class extends PureComponent {
@@ -76,6 +79,27 @@ export const FollowerIndicesTable = injectI18n(
);
}
+ }, {
+ field: 'shards',
+ name: intl.formatMessage({
+ id: 'xpack.crossClusterReplication.followerIndexList.table.statusColumnTitle',
+ defaultMessage: 'Status',
+ }),
+ truncateText: true,
+ sortable: true,
+ render: (shards) => {
+ return shards && shards.length ? (
+
+ ) : (
+
+ );
+ }
}, {
field: 'remoteCluster',
name: intl.formatMessage({
@@ -98,13 +122,63 @@ export const FollowerIndicesTable = injectI18n(
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 = i18n.translate(
- 'xpack.crossClusterReplication.followerIndexList.table.actionDeleteDescription',
- {
- defaultMessage: 'Delete follower index',
- }
+ const label = (
+
);
return (
@@ -112,34 +186,35 @@ export const FollowerIndicesTable = injectI18n(
content={label}
delay="long"
>
-
- {(deleteFollowerIndex) => (
+
+ {(unfollowLeaderIndex) => (
deleteFollowerIndex(name)}
+ onClick={() => unfollowLeaderIndex(name)}
/>
)}
-
+
);
},
- }],
+ },
+ ],
width: '100px',
}];
}
renderLoading = () => {
- // const { apiStatusDelete } = this.props;
- //
- // if (apiStatusDelete === API_STATUS.DELETING) {
- // return (
- //
- //
- //
- // );
- // }
+ const { apiStatusDelete } = this.props;
+
+ if (apiStatusDelete === API_STATUS.DELETING) {
+ return (
+
+
+
+ );
+ }
return null;
};
@@ -166,23 +241,9 @@ export const FollowerIndicesTable = injectI18n(
const search = {
toolsLeft: selectedItems.length ? (
-
- {(deleteFollowerIndex) => (
- deleteFollowerIndex(selectedItems.map(({ name }) => name))}
- >
-
-
- )}
-
+
) : undefined,
onChange: this.onSearch,
box: {
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 be872d1df3d00..4fa43d19f552e 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
@@ -74,6 +74,21 @@ export const createFollowerIndex = (followerIndex) => (
httpClient.post(`${apiPrefix}/follower_indices`, followerIndex).then(extractData)
);
+export const pauseFollowerIndex = (id) => {
+ const ids = arrify(id).map(_id => encodeURIComponent(_id)).join(',');
+ return httpClient.put(`${apiPrefix}/follower_indices/${ids}/pause`).then(extractData);
+};
+
+export const resumeFollowerIndex = (id) => {
+ const ids = arrify(id).map(_id => encodeURIComponent(_id)).join(',');
+ return httpClient.put(`${apiPrefix}/follower_indices/${ids}/resume`).then(extractData);
+};
+
+export const unfollowLeaderIndex = (id) => {
+ const ids = arrify(id).map(_id => encodeURIComponent(_id)).join(',');
+ return httpClient.put(`${apiPrefix}/follower_indices/${ids}/unfollow`).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/store/action_types.js b/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js
index 68e9ca9788eff..24d8c901af112 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
@@ -24,7 +24,9 @@ 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';
+export const FOLLOWER_INDEX_PAUSE = 'FOLLOWER_INDEX_PAUSE';
+export const FOLLOWER_INDEX_RESUME = 'FOLLOWER_INDEX_RESUME';
+export const FOLLOWER_INDEX_UNFOLLOW = 'FOLLOWER_INDEX_UNFOLLOW';
// Stats
export const AUTO_FOLLOW_STATS_LOAD = 'AUTO_FOLLOW_STATS_LOAD';
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 7f67849ad6d91..16a1b9a7fb74e 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
@@ -11,9 +11,13 @@ import {
loadFollowerIndices as loadFollowerIndicesRequest,
getFollowerIndex as getFollowerIndexRequest,
createFollowerIndex as createFollowerIndexRequest,
+ pauseFollowerIndex as pauseFollowerIndexRequest,
+ resumeFollowerIndex as resumeFollowerIndexRequest,
+ unfollowLeaderIndex as unfollowLeaderIndexRequest,
} from '../../services/api';
import * as t from '../action_types';
import { sendApiRequest } from './api';
+import { getSelectedFollowerIndexId } from '../selectors';
const { FOLLOWER_INDEX: scope } = SECTIONS;
@@ -62,3 +66,164 @@ export const saveFollowerIndex = (name, followerIndex) => (
},
})
);
+
+export const pauseFollowerIndex = (id) => (
+ sendApiRequest({
+ label: t.FOLLOWER_INDEX_PAUSE,
+ status: API_STATUS.SAVING,
+ scope,
+ handler: async () => (
+ pauseFollowerIndexRequest(id)
+ ),
+ onSuccess(response, dispatch, getState) {
+ /**
+ * We can have 1 or more follower index pause operation
+ * that can fail or succeed. We will show 1 toast notification for each.
+ */
+ if (response.errors.length) {
+ const hasMultipleErrors = response.errors.length > 1;
+ const errorMessage = hasMultipleErrors
+ ? i18n.translate('xpack.crossClusterReplication.followerIndex.pauseAction.errorMultipleNotificationTitle', {
+ defaultMessage: `Error pausing {count} follower indices`,
+ values: { count: response.errors.length },
+ })
+ : i18n.translate('xpack.crossClusterReplication.followerIndex.pauseAction.errorSingleNotificationTitle', {
+ defaultMessage: `Error pausing follower index '{name}'`,
+ values: { name: response.errors[0].id },
+ });
+
+ toastNotifications.addDanger(errorMessage);
+ }
+
+ if (response.itemsPaused.length) {
+ const hasMultipleDelete = response.itemsPaused.length > 1;
+
+ const successMessage = hasMultipleDelete
+ ? i18n.translate('xpack.crossClusterReplication.followerIndex.pauseAction.successMultipleNotificationTitle', {
+ defaultMessage: `{count} follower indices were paused`,
+ values: { count: response.itemsPaused.length },
+ })
+ : i18n.translate('xpack.crossClusterReplication.followerIndex.pauseAction.successSingleNotificationTitle', {
+ defaultMessage: `Follower index '{name}' was paused`,
+ values: { name: response.itemsPaused[0] },
+ });
+
+ toastNotifications.addSuccess(successMessage);
+
+ // If we've just paused a follower index we were looking at, we need to close the panel.
+ // TODO: This is temporary because ES currently removes paused followers from the list.
+ // Remove once issue is fixed: https://github.com/elastic/elasticsearch/issues/37127
+ const followerIndexId = getSelectedFollowerIndexId('detail')(getState());
+ if (response.itemsPaused.includes(followerIndexId)) {
+ dispatch(selectDetailFollowerIndex(null));
+ }
+
+ // Refresh list
+ dispatch(loadFollowerIndices(true));
+ }
+ }
+ })
+);
+
+export const resumeFollowerIndex = (id) => (
+ sendApiRequest({
+ label: t.FOLLOWER_INDEX_RESUME,
+ status: API_STATUS.SAVING,
+ scope,
+ handler: async () => (
+ resumeFollowerIndexRequest(id)
+ ),
+ onSuccess(response, dispatch) {
+ /**
+ * We can have 1 or more follower index resume operation
+ * that can fail or succeed. We will show 1 toast notification for each.
+ */
+ if (response.errors.length) {
+ const hasMultipleErrors = response.errors.length > 1;
+ const errorMessage = hasMultipleErrors
+ ? i18n.translate('xpack.crossClusterReplication.followerIndex.resumeAction.errorMultipleNotificationTitle', {
+ defaultMessage: `Error resuming {count} follower indices`,
+ values: { count: response.errors.length },
+ })
+ : i18n.translate('xpack.crossClusterReplication.followerIndex.resumeAction.errorSingleNotificationTitle', {
+ defaultMessage: `Error resuming follower index '{name}'`,
+ values: { name: response.errors[0].id },
+ });
+
+ toastNotifications.addDanger(errorMessage);
+ }
+
+ if (response.itemsResumed.length) {
+ const hasMultipleDelete = response.itemsResumed.length > 1;
+
+ const successMessage = hasMultipleDelete
+ ? i18n.translate('xpack.crossClusterReplication.followerIndex.resumeAction.successMultipleNotificationTitle', {
+ defaultMessage: `{count} follower indices were resumed`,
+ values: { count: response.itemsResumed.length },
+ })
+ : i18n.translate('xpack.crossClusterReplication.followerIndex.resumeAction.successSingleNotificationTitle', {
+ defaultMessage: `Follower index '{name}' was resumed`,
+ values: { name: response.itemsResumed[0] },
+ });
+
+ toastNotifications.addSuccess(successMessage);
+ }
+
+ // Refresh list
+ dispatch(loadFollowerIndices(true));
+ }
+ })
+);
+
+export const unfollowLeaderIndex = (id) => (
+ sendApiRequest({
+ label: t.FOLLOWER_INDEX_UNFOLLOW,
+ status: API_STATUS.DELETING,
+ scope: `${scope}-delete`,
+ handler: async () => (
+ unfollowLeaderIndexRequest(id)
+ ),
+ onSuccess(response, dispatch, getState) {
+ /**
+ * We can have 1 or more follower index unfollow operation
+ * that can fail or succeed. We will show 1 toast notification for each.
+ */
+ if (response.errors.length) {
+ const hasMultipleErrors = response.errors.length > 1;
+ const errorMessage = hasMultipleErrors
+ ? i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.errorMultipleNotificationTitle', {
+ defaultMessage: `Error unfollowing leader index of {count} follower indices`,
+ values: { count: response.errors.length },
+ })
+ : i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.errorSingleNotificationTitle', {
+ defaultMessage: `Error unfollowing leader index of follower index '{name}'`,
+ values: { name: response.errors[0].id },
+ });
+
+ toastNotifications.addDanger(errorMessage);
+ }
+
+ if (response.itemsUnfollowed.length) {
+ const hasMultipleDelete = response.itemsUnfollowed.length > 1;
+
+ const successMessage = hasMultipleDelete
+ ? i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.successMultipleNotificationTitle', {
+ defaultMessage: `Leader indices of {count} follower indices were unfollowed`,
+ values: { count: response.itemsUnfollowed.length },
+ })
+ : i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.successSingleNotificationTitle', {
+ defaultMessage: `Leader index of follower index '{name}' was unfollowed`,
+ values: { name: response.itemsUnfollowed[0] },
+ });
+
+ toastNotifications.addSuccess(successMessage);
+ }
+
+ // If we've just unfollowed a follower index we were looking at, we need to close the panel.
+ const followerIndexId = getSelectedFollowerIndexId('detail')(getState());
+ if (response.itemsUnfollowed.includes(followerIndexId)) {
+ dispatch(selectDetailFollowerIndex(null));
+ }
+ }
+ })
+);
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 352b600a35d59..07905dbfef025 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,12 @@ export const reducer = (state = initialState, action) => {
case t.FOLLOWER_INDEX_SELECT_DETAIL: {
return { ...state, selectedDetailId: action.payload };
}
+ case success(t.FOLLOWER_INDEX_UNFOLLOW): {
+ const byId = { ...state.byId };
+ const { itemsUnfollowed } = action.payload;
+ itemsUnfollowed.forEach(id => delete byId[id]);
+ return { ...state, byId };
+ }
default:
return state;
}
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 55a110292e205..f166b12ed60f4 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
@@ -100,4 +100,46 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
needBody: true,
method: 'PUT'
});
+
+ ccr.pauseFollowerIndex = ca({
+ urls: [
+ {
+ fmt: '/<%=id%>/_ccr/pause_follow',
+ req: {
+ id: {
+ type: 'string'
+ }
+ }
+ }
+ ],
+ method: 'POST'
+ });
+
+ ccr.resumeFollowerIndex = ca({
+ urls: [
+ {
+ fmt: '/<%=id%>/_ccr/resume_follow',
+ req: {
+ id: {
+ type: 'string'
+ }
+ }
+ }
+ ],
+ method: 'POST'
+ });
+
+ ccr.unfollowLeaderIndex = ca({
+ urls: [
+ {
+ fmt: '/<%=id%>/_ccr/unfollow',
+ req: {
+ id: {
+ type: 'string'
+ }
+ }
+ }
+ ],
+ method: 'POST'
+ });
};
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 5328f5fe35db6..cc7eeaef99514 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
@@ -98,4 +98,124 @@ export const registerFollowerIndexRoutes = (server) => {
}
},
});
+
+
+ /**
+ * Pauses a follower index
+ */
+ server.route({
+ path: `${API_BASE_PATH}/follower_indices/{id}/pause`,
+ method: 'PUT',
+ config: {
+ pre: [ licensePreRouting ]
+ },
+ handler: async (request) => {
+ const callWithRequest = callWithRequestFactory(server, request);
+ const { id } = request.params;
+ const ids = id.split(',');
+
+ const itemsPaused = [];
+ const errors = [];
+
+ await Promise.all(ids.map((_id) => (
+ callWithRequest('ccr.pauseFollowerIndex', { id: _id })
+ .then(() => itemsPaused.push(_id))
+ .catch(err => {
+ if (isEsError(err)) {
+ errors.push({ id: _id, error: wrapEsError(err) });
+ } else {
+ errors.push({ id: _id, error: wrapUnknownError(err) });
+ }
+ })
+ )));
+
+ return {
+ itemsPaused,
+ errors
+ };
+ },
+ });
+
+ /**
+ * Resumes a follower index
+ */
+ server.route({
+ path: `${API_BASE_PATH}/follower_indices/{id}/resume`,
+ method: 'PUT',
+ config: {
+ pre: [ licensePreRouting ]
+ },
+ handler: async (request) => {
+ const callWithRequest = callWithRequestFactory(server, request);
+ const { id } = request.params;
+ const ids = id.split(',');
+
+ const itemsResumed = [];
+ const errors = [];
+
+ await Promise.all(ids.map((_id) => (
+ callWithRequest('ccr.resumeFollowerIndex', { id: _id })
+ .then(() => itemsResumed.push(_id))
+ .catch(err => {
+ if (isEsError(err)) {
+ errors.push({ id: _id, error: wrapEsError(err) });
+ } else {
+ errors.push({ id: _id, error: wrapUnknownError(err) });
+ }
+ })
+ )));
+
+ return {
+ itemsResumed,
+ errors
+ };
+ },
+ });
+
+ /**
+ * Unfollow follower index's leader index
+ */
+ server.route({
+ path: `${API_BASE_PATH}/follower_indices/{id}/unfollow`,
+ method: 'PUT',
+ config: {
+ pre: [ licensePreRouting ]
+ },
+ handler: async (request) => {
+
+ const callWithRequest = callWithRequestFactory(server, request);
+ const { id } = request.params;
+ const ids = id.split(',');
+
+ const itemsUnfollowed = [];
+ const errors = [];
+
+ await Promise.all(ids.map(async (_id) => {
+ try {
+ // Pause follower
+ await callWithRequest('ccr.pauseFollowerIndex', { id: _id });
+
+ // Close index
+ await callWithRequest('indices.close', { index: _id });
+
+ // Unfollow leader
+ await callWithRequest('ccr.unfollowLeaderIndex', { id: _id });
+
+ // Push success
+ itemsUnfollowed.push(_id);
+ } catch (err) {
+ if (isEsError(err)) {
+ errors.push({ id: _id, error: wrapEsError(err) });
+ } else {
+ errors.push({ id: _id, error: wrapUnknownError(err) });
+ }
+ }
+ }));
+
+ return {
+ itemsUnfollowed,
+ errors
+ };
+ },
+ });
};
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 95b6ea5f650aa..cbf51074fed26 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,6 +34,9 @@ const registerHandlers = () => {
0: 'list',
1: 'get',
2: 'create',
+ 3: 'pause',
+ 4: 'resume',
+ 5: 'unfollow',
};
const server = {
@@ -62,7 +65,7 @@ let requestResponseQueue = [];
* @param {*} response The response to return
*/
const setHttpRequestResponse = (error, response) => {
- requestResponseQueue.push ({ error, response });
+ requestResponseQueue.push({ error, response });
};
const resetHttpRequestResponses = () => requestResponseQueue = [];
@@ -141,4 +144,133 @@ describe('[CCR API Routes] Follower Index', () => {
expect(response).toEqual({ acknowledge: true });
});
});
+
+ describe('pause()', () => {
+ beforeEach(() => {
+ resetHttpRequestResponses();
+ routeHandler = routeHandlers.pause;
+ });
+
+ it('should pause a single item', async () => {
+ setHttpRequestResponse(null, { acknowledge: true });
+
+ const response = await routeHandler({ params: { id: '1' } });
+
+ expect(response.itemsPaused).toEqual(['1']);
+ expect(response.errors).toEqual([]);
+ });
+
+ it('should accept a list of ids to pause', async () => {
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+
+ const response = await routeHandler({ params: { id: '1,2,3' } });
+
+ expect(response.itemsPaused).toEqual(['1', '2', '3']);
+ });
+
+ it('should catch error and return them in array', async () => {
+ const error = new Error('something went wrong');
+ error.response = '{ "error": {} }';
+
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(error);
+
+ const response = await routeHandler({ params: { id: '1,2' } });
+
+ expect(response.itemsPaused).toEqual(['1']);
+ expect(response.errors[0].id).toEqual('2');
+ });
+ });
+
+ describe('resume()', () => {
+ beforeEach(() => {
+ resetHttpRequestResponses();
+ routeHandler = routeHandlers.resume;
+ });
+
+ it('should resume a single item', async () => {
+ setHttpRequestResponse(null, { acknowledge: true });
+
+ const response = await routeHandler({ params: { id: '1' } });
+
+ expect(response.itemsResumed).toEqual(['1']);
+ expect(response.errors).toEqual([]);
+ });
+
+ it('should accept a list of ids to resume', async () => {
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+
+ const response = await routeHandler({ params: { id: '1,2,3' } });
+
+ expect(response.itemsResumed).toEqual(['1', '2', '3']);
+ });
+
+ it('should catch error and return them in array', async () => {
+ const error = new Error('something went wrong');
+ error.response = '{ "error": {} }';
+
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(error);
+
+ const response = await routeHandler({ params: { id: '1,2' } });
+
+ expect(response.itemsResumed).toEqual(['1']);
+ expect(response.errors[0].id).toEqual('2');
+ });
+ });
+
+ describe('unfollow()', () => {
+ beforeEach(() => {
+ resetHttpRequestResponses();
+ routeHandler = routeHandlers.unfollow;
+ });
+
+ it('should unfollow await single item', async () => {
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+
+ const response = await routeHandler({ params: { id: '1' } });
+
+ expect(response.itemsUnfollowed).toEqual(['1']);
+ expect(response.errors).toEqual([]);
+ });
+
+ it('should accept a list of ids to unfollow', async () => {
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+
+ const response = await routeHandler({ params: { id: '1,2,3' } });
+
+ expect(response.itemsUnfollowed).toEqual(['1', '2', '3']);
+ });
+
+ it('should catch error and return them in array', async () => {
+ const error = new Error('something went wrong');
+ error.response = '{ "error": {} }';
+
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(null, { acknowledge: true });
+ setHttpRequestResponse(error);
+
+ const response = await routeHandler({ params: { id: '1,2' } });
+
+ expect(response.itemsUnfollowed).toEqual(['1']);
+ expect(response.errors[0].id).toEqual('2');
+ });
+ });
});