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 && ( + +

+ +

+
    {ids.map(id =>
  • {id}
  • )}
+
+ )} +
+
+ ); + } + + 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 ? ( + +

+ +

+
+ ) : ( + +

+ +

+
    {ids.map(id =>
  • {id}
  • )}
+
+ )} +
+
+ ); + } + + 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'); + }); + }); });