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 19703b8283a8c..8ae8dd60c2fa6 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/app.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/app.js
@@ -49,7 +49,7 @@ export class App extends Component {
return (
-
+
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_delete_provider.js
new file mode 100644
index 0000000000000..4941df861eb51
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_delete_provider.js
@@ -0,0 +1,119 @@
+/*
+ * 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 { deleteFollowerIndex } 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();
+ };
+
+ deleteFollowerIndex = (id) => {
+ this.setState({ isModalOpen: true, ids: arrify(id) });
+ };
+
+ onConfirm = () => {
+ // this.props.deleteFollowerIndex(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.deleteFollowerIndex.confirmModal.deleteSingleTitle',
+ defaultMessage: 'Remove follower index \'{name}\'?',
+ }, { name: ids[0] })
+ : intl.formatMessage({
+ id: 'xpack.crossClusterReplication.deleteFollowerIndex.confirmModal.deleteMultipleTitle',
+ defaultMessage: 'Remove {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.deleteFollowerIndex)}
+ {isModalOpen && this.renderModal()}
+
+ );
+ }
+}
+
+const mapDispatchToProps = (/*dispatch*/) => ({
+ // deleteFollowerIndex: (id) => dispatch(deleteFollowerIndex(id)),
+});
+
+export const FollowerIndexDeleteProvider = 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 5b4a7b407eda0..1e717eb8ea22d 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,3 +12,5 @@ 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';
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/constants/sections.js b/x-pack/plugins/cross_cluster_replication/public/app/constants/sections.js
index 0b2eb94de9b12..aaeface2d3329 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/constants/sections.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/constants/sections.js
@@ -6,6 +6,6 @@
export const SECTIONS = {
AUTO_FOLLOW_PATTERN: 'autoFollowPattern',
- INDEX_FOLLOWER: 'indexFollower',
+ FOLLOWER_INDEX: 'followerIndex',
REMOTE_CLUSTER: 'remoteCluster'
};
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js
index 1a9966896db5c..91baccb42b9fe 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js
@@ -116,7 +116,7 @@ export const AutoFollowPatternTable = injectI18n(
{
render: ({ name }) => {
const label = i18n.translate(
- 'xpack.crossClusterReplication.autofollowPatternList.table.actionDeleteDescription',
+ 'xpack.crossClusterReplication.autoFollowPatternList.table.actionDeleteDescription',
{
defaultMessage: 'Delete auto-follow pattern',
}
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js
new file mode 100644
index 0000000000000..6efb6ad1efbe7
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js
@@ -0,0 +1,23 @@
+/*
+ * 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 { DetailPanel as DetailPanelView } from './detail_panel';
+
+import { getSelectedFollowerIndex, getSelectedFollowerIndexId, getApiStatus, } from '../../../../../store/selectors';
+import { SECTIONS } from '../../../../../constants';
+
+const scope = SECTIONS.FOLLOWER_INDEX;
+
+const mapStateToProps = (state) => ({
+ followerIndexId: getSelectedFollowerIndexId('detail')(state),
+ followerIndex: getSelectedFollowerIndex('detail')(state),
+ apiStatus: getApiStatus(scope)(state),
+});
+
+export const DetailPanel = connect(
+ mapStateToProps,
+)(DetailPanelView);
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
new file mode 100644
index 0000000000000..43c052fcd8094
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js
@@ -0,0 +1,306 @@
+/*
+ * 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, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
+import { getIndexListUri } from '../../../../../../../../index_management/public/services/navigation';
+
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiCodeEditor,
+ EuiDescriptionList,
+ EuiDescriptionListDescription,
+ EuiDescriptionListTitle,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutFooter,
+ EuiFlyoutHeader,
+ EuiIcon,
+ EuiLink,
+ EuiLoadingSpinner,
+ EuiSpacer,
+ EuiText,
+ EuiTextColor,
+ EuiTitle,
+} from '@elastic/eui';
+
+import 'brace/theme/textmate';
+
+import {
+ FollowerIndexDeleteProvider,
+} from '../../../../../components';
+
+import { API_STATUS } from '../../../../../constants';
+// import routing from '../../../../../services/routing';
+
+export class DetailPanelUi extends Component {
+ static propTypes = {
+ apiStatus: PropTypes.string,
+ followerIndexId: PropTypes.string,
+ followerIndex: PropTypes.object,
+ closeDetailPanel: PropTypes.func.isRequired,
+ }
+
+ renderFollowerIndex() {
+ const {
+ followerIndex: {
+ name,
+ remoteCluster,
+ leaderIndex,
+ shards,
+ },
+ } = this.props;
+
+ const indexManagementUri = getIndexListUri(`name:${name}`);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {remoteCluster}
+
+
+
+
+
+
+
+
+
+
+
+ {leaderIndex}
+
+
+
+
+
+
+
+
+
+
+ {shards.map((shard, i) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+ }
+
+ renderContent() {
+ const {
+ apiStatus,
+ followerIndex,
+ } = this.props;
+
+ if (apiStatus === API_STATUS.LOADING) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (!followerIndex) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return this.renderFollowerIndex();
+ }
+
+ renderFooter() {
+ const {
+ followerIndex,
+ closeDetailPanel,
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+ {followerIndex && (
+
+
+
+
+ {(deleteFollowerIndex) => (
+ deleteFollowerIndex(followerIndex.name)}
+ >
+
+
+ )}
+
+
+
+
+ {
+ // routing.navigate(encodeURI(`/follower_indices/edit/${encodeURIComponent(followerIndex.name)}`));
+ }}
+ >
+
+
+
+
+
+ )}
+
+
+ );
+ }
+
+ render() {
+ const { followerIndexId, closeDetailPanel } = this.props;
+
+ return (
+
+
+
+
+ {followerIndexId}
+
+
+
+ {this.renderContent()}
+ {this.renderFooter()}
+
+ );
+ }
+}
+
+export const DetailPanel = injectI18n(DetailPanelUi);
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js
new file mode 100644
index 0000000000000..c27bbd8ea830f
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/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 { DetailPanel } from './detail_panel.container';
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
new file mode 100644
index 0000000000000..d522f47559374
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js
@@ -0,0 +1,27 @@
+/*
+ * 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 { selectDetailFollowerIndex } from '../../../../../store/actions';
+// 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 mapDispatchToProps = (dispatch) => ({
+ selectFollowerIndex: (name) => dispatch(selectDetailFollowerIndex(name)),
+});
+
+export const FollowerIndicesTable = connect(
+ null,
+ 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
new file mode 100644
index 0000000000000..d782211d9e3bb
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js
@@ -0,0 +1,233 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
+import {
+ EuiButton,
+ EuiButtonIcon,
+ EuiInMemoryTable,
+ EuiLink,
+ // EuiLoadingKibana,
+ EuiToolTip,
+ // EuiOverlayMask,
+} from '@elastic/eui';
+// import { API_STATUS } from '../../../../../constants';
+import { FollowerIndexDeleteProvider } from '../../../../../components';
+// import routing from '../../../../../services/routing';
+
+export const FollowerIndicesTable = injectI18n(
+ class extends PureComponent {
+ static propTypes = {
+ followerIndices: PropTypes.array,
+ selectFollowerIndex: PropTypes.func.isRequired,
+ }
+
+ state = {
+ selectedItems: [],
+ }
+
+ onSearch = ({ query }) => {
+ const { text } = query;
+ const normalizedSearchText = text.toLowerCase();
+ this.setState({
+ queryText: normalizedSearchText,
+ });
+ };
+
+ getFilteredIndices = () => {
+ const { followerIndices } = this.props;
+ const { queryText } = this.state;
+
+ if(queryText) {
+ return followerIndices.filter(followerIndex => {
+ const { name, shards } = followerIndex;
+
+ const inName = name.toLowerCase().includes(queryText);
+ const inRemoteCluster = shards[0].remoteCluster.toLowerCase().includes(queryText);
+ const inLeaderIndex = shards[0].leaderIndex.toLowerCase().includes(queryText);
+
+ return inName || inRemoteCluster || inLeaderIndex;
+ });
+ }
+
+ return followerIndices.slice(0);
+ };
+
+ getTableColumns() {
+ const { intl, selectFollowerIndex } = this.props;
+
+ return [{
+ field: 'name',
+ name: intl.formatMessage({
+ id: 'xpack.crossClusterReplication.followerIndexList.table.nameColumnTitle',
+ defaultMessage: 'Name',
+ }),
+ sortable: true,
+ truncateText: false,
+ render: (name) => {
+ return (
+ selectFollowerIndex(name)}>
+ {name}
+
+ );
+ }
+ }, {
+ field: 'remoteCluster',
+ name: intl.formatMessage({
+ id: 'xpack.crossClusterReplication.followerIndexList.table.clusterColumnTitle',
+ defaultMessage: 'Cluster',
+ }),
+ truncateText: true,
+ sortable: true,
+ }, {
+ field: 'leaderIndex',
+ name: intl.formatMessage({
+ id: 'xpack.crossClusterReplication.followerIndexList.table.leaderIndexColumnTitle',
+ defaultMessage: 'Leader index',
+ }),
+ truncateText: true,
+ sortable: true,
+ }, {
+ name: intl.formatMessage({
+ id: 'xpack.crossClusterReplication.followerIndexList.table.actionsColumnTitle',
+ defaultMessage: 'Actions',
+ }),
+ actions: [
+ {
+ render: ({ name }) => {
+ const label = i18n.translate(
+ 'xpack.crossClusterReplication.followerIndexList.table.actionDeleteDescription',
+ {
+ defaultMessage: 'Delete follower index',
+ }
+ );
+
+ return (
+
+
+ {(deleteFollowerIndex) => (
+ deleteFollowerIndex(name)}
+ />
+ )}
+
+
+ );
+ },
+ },
+ {
+ render: ({ /*name*/ }) => {
+ const label = i18n.translate('xpack.crossClusterReplication.followerIndexList.table.actionEditDescription', {
+ defaultMessage: 'Edit follower index',
+ });
+
+ return (
+
+
+
+ );
+ },
+ },
+ ],
+ width: '100px',
+ }];
+ }
+
+ renderLoading = () => {
+ // const { apiStatusDelete } = this.props;
+ //
+ // if (apiStatusDelete === API_STATUS.DELETING) {
+ // return (
+ //
+ //
+ //
+ // );
+ // }
+ return null;
+ };
+
+ render() {
+ const {
+ selectedItems,
+ } = this.state;
+
+ const sorting = {
+ sort: {
+ field: 'name',
+ direction: 'asc',
+ }
+ };
+
+ const pagination = {
+ initialPageSize: 20,
+ pageSizeOptions: [10, 20, 50]
+ };
+
+ const selection = {
+ onSelectionChange: (selectedItems) => this.setState({ selectedItems })
+ };
+
+ const search = {
+ toolsLeft: selectedItems.length ? (
+
+ {(deleteFollowerIndex) => (
+ deleteFollowerIndex(selectedItems.map(({ name }) => name))}
+ >
+
+
+ )}
+
+ ) : undefined,
+ onChange: this.onSearch,
+ box: {
+ incremental: true,
+ },
+ };
+
+ return (
+
+
+ {this.renderLoading()}
+
+ );
+ }
+ }
+);
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js
new file mode 100644
index 0000000000000..8ea9cd98336c3
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/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 { FollowerIndicesTable } from './follower_indices_table.container';
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js
new file mode 100644
index 0000000000000..d81a62e17a4b7
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js
@@ -0,0 +1,8 @@
+/*
+ * 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 { FollowerIndicesTable } from './follower_indices_table';
+export { DetailPanel } from './detail_panel';
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js
new file mode 100644
index 0000000000000..2baf716e7b90a
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js
@@ -0,0 +1,40 @@
+/*
+ * 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 {
+ getListFollowerIndices,
+ getSelectedFollowerIndexId,
+ getApiStatus,
+ getApiError,
+ isApiAuthorized,
+} from '../../../store/selectors';
+import {
+ loadFollowerIndices, selectDetailFollowerIndex,
+} from '../../../store/actions';
+import { FollowerIndicesList as FollowerIndicesListView } from './follower_indices_list';
+
+const scope = SECTIONS.FOLLOWER_INDEX;
+
+const mapStateToProps = (state) => ({
+ followerIndices: getListFollowerIndices(state),
+ followerIndexId: getSelectedFollowerIndexId('detail')(state),
+ apiStatus: getApiStatus(scope)(state),
+ apiError: getApiError(scope)(state),
+ isAuthorized: isApiAuthorized(scope)(state),
+});
+
+const mapDispatchToProps = dispatch => ({
+ loadFollowerIndices: (inBackground) => dispatch(loadFollowerIndices(inBackground)),
+ selectFollowerIndex: (id) => dispatch(selectDetailFollowerIndex(id)),
+});
+
+export const FollowerIndicesList = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(FollowerIndicesListView);
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
new file mode 100644
index 0000000000000..bd6fbdd3812ef
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js
@@ -0,0 +1,176 @@
+/*
+ * 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 { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
+
+// import routing from '../../../services/routing';
+import { extractQueryParams } from '../../../services/query_params';
+import { API_STATUS } from '../../../constants';
+import { SectionLoading, SectionError } from '../../../components';
+import { FollowerIndicesTable, DetailPanel } from './components';
+
+const REFRESH_RATE_MS = 30000;
+
+const getQueryParamName = ({ location: { search } }) => {
+ const { name } = extractQueryParams(search);
+ return name ? decodeURIComponent(name) : null;
+};
+
+export const FollowerIndicesList = injectI18n(
+ class extends PureComponent {
+ static propTypes = {
+ loadFollowerIndices: PropTypes.func,
+ selectFollowerIndex: PropTypes.func,
+ followerIndices: PropTypes.array,
+ apiStatus: PropTypes.string,
+ apiError: PropTypes.object,
+ }
+
+ static getDerivedStateFromProps({ followerIndexId }, { lastFollowerIndexId }) {
+ if (followerIndexId !== lastFollowerIndexId) {
+ return {
+ lastFollowerIndexId: followerIndexId,
+ isDetailPanelOpen: !!followerIndexId,
+ };
+ }
+ return null;
+ }
+
+ state = {
+ lastFollowerIndexId: null,
+ isDetailPanelOpen: false,
+ };
+
+ componentDidMount() {
+ const { loadFollowerIndices, selectFollowerIndex, history } = this.props;
+
+ loadFollowerIndices();
+
+ // Select the pattern in the URL query params
+ selectFollowerIndex(getQueryParamName(history));
+
+ // Interval to load follower indices in the background passing "true" to the fetch method
+ this.interval = setInterval(() => loadFollowerIndices(true), REFRESH_RATE_MS);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const { history } = this.props;
+ const { lastFollowerIndexId } = this.state;
+
+ /**
+ * Each time our state is updated (through getDerivedStateFromProps())
+ * we persist the follower index id to query params for deep linking
+ */
+ if (lastFollowerIndexId !== prevState.lastFollowerIndexId) {
+ if(!lastFollowerIndexId) {
+ history.replace({
+ search: '',
+ });
+ } else {
+ history.replace({
+ search: `?name=${encodeURIComponent(lastFollowerIndexId)}`,
+ });
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.interval);
+ }
+
+ renderEmpty() {
+ return (
+
+
+
+ )}
+ body={
+
+
+
+
+
+ }
+ actions={
+
+
+
+ }
+ />
+ );
+ }
+
+ renderList() {
+ const {
+ selectFollowerIndex,
+ followerIndices,
+ apiStatus,
+ } = this.props;
+
+ const { isDetailPanelOpen } = this.state;
+
+ if (apiStatus === API_STATUS.LOADING) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {isDetailPanelOpen && selectFollowerIndex(null)} />}
+
+ );
+ }
+
+ render() {
+ const { followerIndices, apiStatus, apiError, isAuthorized, intl } = this.props;
+
+ if (!isAuthorized) {
+ return null;
+ }
+
+ if (apiStatus === API_STATUS.IDLE && !followerIndices.length) {
+ return this.renderEmpty();
+ }
+
+ if (apiError) {
+ const title = intl.formatMessage({
+ id: 'xpack.crossClusterReplication.followerIndexList.loadingErrorTitle',
+ defaultMessage: 'Error loading follower indices',
+ });
+ return ;
+ }
+
+ return this.renderList();
+ }
+ }
+);
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js
new file mode 100644
index 0000000000000..08c799176f297
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/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 { FollowerIndicesList } from './follower_indices_list.container';
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.container.js
index b5e897f93fa58..0ae54ddc611a6 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.container.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.container.js
@@ -7,12 +7,14 @@
import { connect } from 'react-redux';
import { SECTIONS } from '../../constants';
-import { getListAutoFollowPatterns, isApiAuthorized } from '../../store/selectors';
+import { getListAutoFollowPatterns, getListFollowerIndices, isApiAuthorized } from '../../store/selectors';
import { CrossClusterReplicationHome as CrossClusterReplicationHomeView } from './home';
const mapStateToProps = (state) => ({
autoFollowPatterns: getListAutoFollowPatterns(state),
- isAutoFollowApiAuthorized: isApiAuthorized(SECTIONS.AUTO_FOLLOW_PATTERN)(state)
+ isAutoFollowApiAuthorized: isApiAuthorized(SECTIONS.AUTO_FOLLOW_PATTERN)(state),
+ followerIndices: getListFollowerIndices(state),
+ isFollowerIndexApiAuthorized: isApiAuthorized(SECTIONS.FOLLOWER_INDEX)(state),
});
export const CrossClusterReplicationHome = connect(
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 91b972599b67e..51b34c1b2769f 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
@@ -20,6 +20,8 @@ import {
EuiPageBody,
EuiPageContent,
EuiSpacer,
+ EuiTab,
+ EuiTabs,
EuiText,
EuiTitle,
} from '@elastic/eui';
@@ -27,83 +29,141 @@ import {
import { listBreadcrumb } from '../../services/breadcrumbs';
import routing from '../../services/routing';
import { AutoFollowPatternList } from './auto_follow_pattern_list';
+import { FollowerIndicesList } from './follower_indices_list';
import { SectionUnauthorized } from '../../components';
export const CrossClusterReplicationHome = injectI18n(
class extends PureComponent {
static propTypes = {
autoFollowPatterns: PropTypes.array,
+ isAutoFollowApiAuthorized: PropTypes.bool,
+ followerIndices: PropTypes.array,
+ isFollowerIndexApiAuthorized: PropTypes.bool,
}
state = {
- sectionActive: 'auto-follow'
+ activeSection: 'follower_indices'
}
+ tabs = [{
+ id: 'follower_indices',
+ name: (
+
+ )
+ }, {
+ id: 'auto_follow_patterns',
+ name: (
+
+ )
+ }]
+
componentDidMount() {
chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb ]);
}
+ static getDerivedStateFromProps(props) {
+ const { match: { params: { section } } } = props;
+ return {
+ activeSection: section
+ };
+ }
+
+ onSectionChange = (section) => {
+ routing.navigate(`/${section}`);
+ }
+
getHeaderSection() {
- const { isAutoFollowApiAuthorized, autoFollowPatterns } = this.props;
+ if(this.state.activeSection === 'follower_indices') {
+ const { isFollowerIndexApiAuthorized, followerIndices } = this.props;
- // We want to show the title when the user isn't authorized.
- if (isAutoFollowApiAuthorized && !autoFollowPatterns.length) {
- return null;
- }
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ // We want to show the title when the user isn't authorized.
+ if (isFollowerIndexApiAuthorized && !followerIndices.length) {
+ return null;
+ }
-
-
-
-
-
-
-
-
- {isAutoFollowApiAuthorized && (
-
-
-
- )}
-
-
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {isFollowerIndexApiAuthorized && (
+
+
+
+ )}
+
+
+
+
+
+ );
+ } else {
+ const { isAutoFollowApiAuthorized, autoFollowPatterns } = this.props;
-
-
- );
+ // We want to show the title when the user isn't authorized.
+ if (isAutoFollowApiAuthorized && !autoFollowPatterns.length) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {isAutoFollowApiAuthorized && (
+
+
+
+ )}
+
+
+
+
+
+ );
+ }
}
getUnauthorizedSection() {
@@ -125,9 +185,36 @@ export const CrossClusterReplicationHome = injectI18n(
+
+
+
+
+
+
+
+
+
+ {this.tabs.map(tab => (
+ this.onSectionChange(tab.id)}
+ isSelected={tab.id === this.state.activeSection}
+ key={tab.id}
+ >
+ {tab.name}
+
+ ))}
+
+
+
+
{this.getHeaderSection()}
{this.getUnauthorizedSection()}
+
+
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 f6d4a8f084b73..5528bb5b50251 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
@@ -48,3 +48,11 @@ export const deleteAutoFollowPattern = (id) => {
return httpClient.delete(`${apiPrefix}/auto_follow_patterns/${ids}`).then(extractData);
};
+
+export const loadFollowerIndices = () => (
+ httpClient.get(`${apiPrefix}/follower_indices`).then(extractData)
+);
+
+export const getFollowerIndex = (id) => (
+ httpClient.get(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`).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 c018e7b44ebcc..678393c4683ce 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
@@ -18,3 +18,8 @@ export const AUTO_FOLLOW_PATTERN_GET = 'AUTO_FOLLOW_PATTERN_GET';
export const AUTO_FOLLOW_PATTERN_CREATE = 'AUTO_FOLLOW_PATTERN_CREATE';
export const AUTO_FOLLOW_PATTERN_UPDATE = 'AUTO_FOLLOW_PATTERN_UPDATE';
export const AUTO_FOLLOW_PATTERN_DELETE = 'AUTO_FOLLOW_PATTERN_DELETE';
+
+// Follower index
+export const FOLLOWER_INDEX_SELECT_DETAIL = 'FOLLOWER_INDEX_SELECT_DETAIL';
+export const FOLLOWER_INDEX_LOAD = 'FOLLOWER_INDEX_LOAD';
+export const FOLLOWER_INDEX_GET = 'AUTO_FOLLOW_PATTERN_GET';
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
new file mode 100644
index 0000000000000..1f6b3ffcb1a86
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js
@@ -0,0 +1,38 @@
+/*
+ * 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 { SECTIONS, API_STATUS } from '../../constants';
+import {
+ loadFollowerIndices as loadFollowerIndicesRequest,
+ getFollowerIndex as getFollowerIndexRequest,
+} from '../../services/api';
+import * as t from '../action_types';
+import { sendApiRequest } from './api';
+
+const { FOLLOWER_INDEX: scope } = SECTIONS;
+
+export const selectDetailFollowerIndex = (id) => ({
+ type: t.FOLLOWER_INDEX_SELECT_DETAIL,
+ payload: id
+});
+
+export const loadFollowerIndices = (isUpdating = false) =>
+ sendApiRequest({
+ label: t.FOLLOWER_INDEX_LOAD,
+ scope,
+ status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING,
+ handler: async () => (
+ await loadFollowerIndicesRequest()
+ ),
+ });
+
+export const getFollowerIndex = (id) =>
+ sendApiRequest({
+ label: t.FOLLOWER_INDEX_GET,
+ scope,
+ handler: async () => (
+ await getFollowerIndexRequest(id)
+ )
+ });
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/index.js
index d45c328f5d830..79e81fb3b727a 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/index.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/index.js
@@ -5,5 +5,5 @@
*/
export * from './auto_follow_pattern';
-
+export * from './follower_index';
export * from './api';
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.js
index 55570deb4b7a6..f32a1862078a4 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.js
@@ -10,11 +10,11 @@ import * as t from '../action_types';
export const initialState = {
status: {
[SECTIONS.AUTO_FOLLOW_PATTERN]: API_STATUS.IDLE,
- [SECTIONS.INDEX_FOLLOWER]: API_STATUS.IDLE,
+ [SECTIONS.FOLLOWER_INDEX]: API_STATUS.IDLE,
},
error: {
[SECTIONS.AUTO_FOLLOW_PATTERN]: null,
- [SECTIONS.INDEX_FOLLOWER]: null,
+ [SECTIONS.FOLLOWER_INDEX]: 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
new file mode 100644
index 0000000000000..352b600a35d59
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js
@@ -0,0 +1,38 @@
+/*
+ * 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 * as t from '../action_types';
+import { arrayToObject } from '../../services/utils';
+
+const initialState = {
+ byId: {},
+ selectedDetailId: null,
+ selectedEditId: null,
+};
+
+const success = action => `${action}_SUCCESS`;
+
+const parseFollowerIndex = (followerIndex) => {
+ // Extract remote cluster and leader index from follower index shard information
+ const { remoteCluster, leaderIndex } = followerIndex.shards[0];
+
+ return { ...followerIndex, remoteCluster, leaderIndex };
+};
+export const reducer = (state = initialState, action) => {
+ switch (action.type) {
+ case success(t.FOLLOWER_INDEX_LOAD): {
+ return { ...state, byId: arrayToObject(action.payload.indices.map(parseFollowerIndex), 'name') };
+ }
+ case success(t.FOLLOWER_INDEX_GET): {
+ return { ...state, byId: { ...state.byId, [action.payload.name]: parseFollowerIndex(action.payload) } };
+ }
+ case t.FOLLOWER_INDEX_SELECT_DETAIL: {
+ return { ...state, selectedDetailId: action.payload };
+ }
+ default:
+ return state;
+ }
+};
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/index.js
index b34d824c3f278..51a6a6eb6b901 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/index.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/index.js
@@ -7,8 +7,10 @@
import { combineReducers } from 'redux';
import { reducer as api } from './api';
import { reducer as autoFollowPattern } from './auto_follow_pattern';
+import { reducer as followerIndex } from './follower_index';
export const ccr = combineReducers({
autoFollowPattern,
+ followerIndex,
api,
});
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 52e638cc0c513..346be3d3c33d5 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
@@ -33,3 +33,19 @@ export const getSelectedAutoFollowPattern = (view = 'detail') => createSelector(
return autoFollowPatternsState.byId[autoFollowPatternsState[propId]];
});
export const getListAutoFollowPatterns = createSelector(getAutoFollowPatterns, (autoFollowPatterns) => objectToArray(autoFollowPatterns));
+
+// Follower index
+export const getFollowerIndexState = (state) => state.followerIndex;
+export const getFollowerIndices = createSelector(getFollowerIndexState, (followerIndexState) => followerIndexState.byId);
+export const getSelectedFollowerIndexId = (view = 'detail') => createSelector(getFollowerIndexState, (followerIndexState) => (
+ view === 'detail' ? followerIndexState.selectedDetailId : followerIndexState.selectedEditId
+));
+export const getSelectedFollowerIndex = (view = 'detail') => createSelector(getFollowerIndexState, (followerIndexState) => {
+ const propId = view === 'detail' ? 'selectedDetailId' : 'selectedEditId';
+
+ if(!followerIndexState[propId]) {
+ return null;
+ }
+ return followerIndexState.byId[followerIndexState[propId]];
+});
+export const getListFollowerIndices = createSelector(getFollowerIndices, (followerIndices) => objectToArray(followerIndices));
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 8c03769f6ec14..2e6cdb37629b6 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
@@ -72,6 +72,20 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
method: 'GET'
});
+ ccr.followerIndex = ca({
+ urls: [
+ {
+ fmt: '/<%=id%>/_ccr/stats',
+ req: {
+ id: {
+ type: 'string'
+ }
+ }
+ }
+ ],
+ method: 'GET'
+ });
+
ccr.saveFollowerIndex = ca({
urls: [
{
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 239d1f664eeff..6ab97e6e7118a 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
@@ -8,6 +8,7 @@ import { callWithRequestFactory } from '../../lib/call_with_request_factory';
import { isEsErrorFactory } from '../../lib/is_es_error_factory';
import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers';
import {
+ deserializeFollowerIndex,
deserializeListFollowerIndices,
serializeFollowerIndex,
} from '../../lib/follower_index_serialization';
@@ -44,6 +45,35 @@ export const registerFollowerIndexRoutes = (server) => {
},
});
+
+ /**
+ * Returns a single follower index pattern
+ */
+ server.route({
+ path: `${API_BASE_PATH}/follower_indices/{id}`,
+ method: 'GET',
+ config: {
+ pre: [ licensePreRouting ]
+ },
+ handler: async (request) => {
+ const callWithRequest = callWithRequestFactory(server, request);
+ const { id } = request.params;
+
+ try {
+ const response = await callWithRequest('ccr.followerIndex', { id });
+ const followerIndex = response.indices[0];
+
+ return deserializeFollowerIndex(followerIndex);
+ } catch(err) {
+ if (isEsError(err)) {
+ throw wrapEsError(err);
+ }
+ throw wrapUnknownError(err);
+ }
+ },
+ });
+
+
/**
* Create a follower index
*/
diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js
index 2fdb50ad7a96c..95b6ea5f650aa 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
@@ -32,7 +32,8 @@ const registerHandlers = () => {
const HANDLER_INDEX_TO_ACTION = {
0: 'list',
- 1: 'create',
+ 1: 'get',
+ 2: 'create',
};
const server = {
@@ -104,6 +105,22 @@ describe('[CCR API Routes] Follower Index', () => {
});
});
+ describe('get()', () => {
+ beforeEach(() => {
+ routeHandler = routeHandlers.get;
+ });
+
+ it('should return a single resource even though ES return an array with 1 item', async () => {
+ const followerIndex = getFollowerIndexMock();
+ const esResponse = { indices: [followerIndex] };
+
+ setHttpRequestResponse(null, esResponse);
+
+ const response = await routeHandler({ params: { id: 1 } });
+ expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS);
+ });
+ });
+
describe('create()', () => {
beforeEach(() => {
resetHttpRequestResponses();