+
+
+
+
+
+ {this.tabs.map(tab => (
+ this.onSectionChange(tab.id)}
+ isSelected={tab.id === this.state.activeSection}
+ key={tab.id}
+ >
+ {tab.name}
+
+ ))}
+
+
+
+
+
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js
index 1c15088c7b695..510812426265f 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js
@@ -7,3 +7,5 @@
export { CrossClusterReplicationHome } from './home';
export { AutoFollowPatternAdd } from './auto_follow_pattern_add';
export { AutoFollowPatternEdit } from './auto_follow_pattern_edit';
+export { FollowerIndexAdd } from './follower_index_add';
+export { FollowerIndexEdit } from './follower_index_edit';
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 1281d369d679d..32e8cf1460c0d 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js
@@ -5,24 +5,36 @@
*/
import chrome from 'ui/chrome';
-import { API_BASE_PATH, API_REMOTE_CLUSTERS_BASE_PATH } from '../../../common/constants';
+import {
+ API_BASE_PATH,
+ API_REMOTE_CLUSTERS_BASE_PATH,
+ API_INDEX_MANAGEMENT_BASE_PATH,
+} from '../../../common/constants';
import { arrify } from '../../../common/services/utils';
const apiPrefix = chrome.addBasePath(API_BASE_PATH);
const apiPrefixRemoteClusters = chrome.addBasePath(API_REMOTE_CLUSTERS_BASE_PATH);
+const apiPrefixIndexManagement = chrome.addBasePath(API_INDEX_MANAGEMENT_BASE_PATH);
// This is an Angular service, which is why we use this provider pattern
// to access it within our React app.
let httpClient;
-export function setHttpClient(client) {
+// The deffered AngularJS api allows us to create deferred promise
+// to be resolved later. This allows us to cancel in flight Http Requests
+// https://docs.angularjs.org/api/ng/service/$q#the-deferred-api
+let $q;
+
+export function setHttpClient(client, $deffered) {
httpClient = client;
+ $q = $deffered;
}
// ---
const extractData = (response) => response.data;
+/* Auto Follow Pattern */
export const loadAutoFollowPatterns = () => (
httpClient.get(`${apiPrefix}/auto_follow_patterns`).then(extractData)
);
@@ -49,6 +61,58 @@ export const deleteAutoFollowPattern = (id) => {
return httpClient.delete(`${apiPrefix}/auto_follow_patterns/${ids}`).then(extractData);
};
+/* Follower Index */
+export const loadFollowerIndices = () => (
+ httpClient.get(`${apiPrefix}/follower_indices`).then(extractData)
+);
+
+export const getFollowerIndex = (id) => (
+ httpClient.get(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`).then(extractData)
+);
+
+export const createFollowerIndex = (followerIndex) => (
+ httpClient.post(`${apiPrefix}/follower_indices`, followerIndex).then(extractData)
+);
+
+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);
+};
+
+export const updateFollowerIndex = (id, followerIndex) => (
+ httpClient.put(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`, followerIndex).then(extractData)
+);
+
+/* Stats */
export const loadAutoFollowStats = () => (
- httpClient.get(`${apiPrefix}/stats/auto-follow`).then(extractData)
+ httpClient.get(`${apiPrefix}/stats/auto_follow`).then(extractData)
+);
+
+/* Indices */
+let canceler = null;
+export const loadIndices = () => {
+ if (canceler) {
+ // If there is a previous request in flight we cancel it by resolving the canceler
+ canceler.resolve();
+ }
+ canceler = $q.defer();
+ return httpClient.get(`${apiPrefixIndexManagement}/indices`, { timeout: canceler.promise })
+ .then((response) => {
+ canceler = null;
+ return extractData(response);
+ });
+};
+
+export const loadPermissions = () => (
+ httpClient.get(`${apiPrefix}/permissions`).then(extractData)
);
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js b/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js
index a63bb87921162..585ca7e0f5cf1 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js
@@ -9,3 +9,6 @@ import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links';
const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`;
export const autoFollowPatternUrl = `${esBase}/ccr-put-auto-follow-pattern.html`;
+export const followerIndexUrl = `${esBase}/ccr-put-follow.html`;
+export const byteUnitsUrl = `${esBase}/common-options.html#byte-units`;
+export const timeUnitsUrl = `${esBase}/common-options.html#time-units`;
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js b/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js
new file mode 100644
index 0000000000000..e2dc74729b31b
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.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 { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../common/constants';
+
+export const getSettingDefault = (name) => {
+ if(!FOLLOWER_INDEX_ADVANCED_SETTINGS[name]) {
+ throw new Error(`Unknown setting ${name}`);
+ }
+
+ return FOLLOWER_INDEX_ADVANCED_SETTINGS[name];
+};
+
+export const isSettingDefault = (name, value) => {
+ return getSettingDefault(name) === value;
+};
+
+export const areAllSettingsDefault = (settings) => {
+ return Object.keys(FOLLOWER_INDEX_ADVANCED_SETTINGS).every((name) => isSettingDefault(name, settings[name]));
+};
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js
new file mode 100644
index 0000000000000..942eaa9feb6f0
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+
+const getFirstConnectedCluster = (clusters) => {
+ for (let i = 0; i < clusters.length; i++) {
+ if (clusters[i].isConnected) {
+ return clusters[i];
+ }
+ }
+
+ // No cluster connected, we return the first one in the list
+ return clusters.length ? clusters[0] : {};
+};
+
+export const getRemoteClusterName = (remoteClusters, selected) => {
+ return selected && remoteClusters.some(c => c.name === selected)
+ ? selected
+ : getFirstConnectedCluster(remoteClusters).name;
+};
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js
new file mode 100644
index 0000000000000..877fc1d5a6105
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js
@@ -0,0 +1,86 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices';
+
+const isEmpty = value => {
+ return !value || !value.trim().length;
+};
+
+const beginsWithPeriod = value => {
+ return value[0] === '.';
+};
+
+const findIllegalCharacters = value => {
+ return INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => {
+ if (value.includes(char)) {
+ chars.push(char);
+ }
+
+ return chars;
+ }, []);
+};
+
+export const indexNameValidator = (value) => {
+ if (isEmpty(value)) {
+ return [(
+
+ )];
+ }
+
+ if (beginsWithPeriod(value)) {
+ return [(
+
+ )];
+ }
+
+ const illegalCharacters = findIllegalCharacters(value);
+
+ if (illegalCharacters.length) {
+ return [(
+ {illegalCharacters.join(' ')} }}
+ />
+ )];
+ }
+
+ return undefined;
+};
+
+export const leaderIndexValidator = (value) => {
+ if (isEmpty(value)) {
+ return [(
+
+ )];
+ }
+
+ const illegalCharacters = findIllegalCharacters(value);
+
+ if (illegalCharacters.length) {
+ return [(
+ {illegalCharacters.join(' ')} }}
+ />
+ )];
+ }
+
+ return undefined;
+};
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/license.js b/x-pack/plugins/cross_cluster_replication/public/app/services/license.js
new file mode 100644
index 0000000000000..c61a363472149
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/services/license.js
@@ -0,0 +1,15 @@
+/*
+ * 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 let isAvailable;
+export let isActive;
+export let getReason;
+
+export function setLicense(isAvailableCallback, isActiveCallback, getReasonCallback) {
+ isAvailable = isAvailableCallback;
+ isActive = isActiveCallback;
+ getReason = getReasonCallback;
+}
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js b/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js
index 988201fd240f7..eb6e0d10a6d7f 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js
@@ -16,12 +16,12 @@ const isModifiedEvent = event => !!(event.metaKey || event.altKey || event.ctrlK
const isLeftClickEvent = event => event.button === 0;
-const queryParamsFromObject = params => {
+const queryParamsFromObject = (params, encodeParams = false) => {
if (!params) {
return;
}
- const paramsStr = stringify(params, '&', '=', {
+ const paramsStr = stringify(params, '&', '=', encodeParams ? {} : {
encodeURIComponent: (val) => val, // Don't encode special chars
});
return `?${paramsStr}`;
@@ -42,8 +42,8 @@ class Routing {
*
* @param {*} to URL to navigate to
*/
- getRouterLinkProps(to, base = BASE_PATH, params = {}) {
- const search = queryParamsFromObject(params) || '';
+ getRouterLinkProps(to, base = BASE_PATH, params = {}, encodeParams = false) {
+ const search = queryParamsFromObject(params, encodeParams) || '';
const location = typeof to === "string"
? createLocation(base + to + search, null, null, this._reactRouter.history.location)
: to;
@@ -71,8 +71,8 @@ class Routing {
return { href, onClick };
}
- navigate(route = '/home', app = APPS.CCR_APP, params) {
- const search = queryParamsFromObject(params);
+ navigate(route = '/home', app = APPS.CCR_APP, params, encodeParams = false) {
+ const search = queryParamsFromObject(params, encodeParams);
this._reactRouter.history.push({
pathname: encodeURI(appToBasePathMap[app] + route),
@@ -80,6 +80,16 @@ class Routing {
});
}
+ getAutoFollowPatternPath = (name, section = '/edit') => {
+ return encodeURI(`#${BASE_PATH}/auto_follow_patterns${section}/${encodeURIComponent(name)}`);
+ };
+
+ getFollowerIndexPath = (name, section = '/edit', withBase = true) => {
+ return withBase
+ ? encodeURI(`#${BASE_PATH}/follower_indices${section}/${encodeURIComponent(name)}`)
+ : encodeURI(`/follower_indices${section}/${encodeURIComponent(name)}`);
+ };
+
get reactRouter() {
return this._reactRouter;
}
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js b/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js
index 875a582580bb2..3a9a9c33bafc2 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
@@ -11,13 +11,23 @@ export const API_REQUEST_END = 'API_REQUEST_END';
export const API_ERROR_SET = 'API_ERROR_SET';
// Auto Follow Pattern
-export const AUTO_FOLLOW_PATTERN_EDIT = 'AUTO_FOLLOW_PATTERN_EDIT';
+export const AUTO_FOLLOW_PATTERN_SELECT_DETAIL = 'AUTO_FOLLOW_PATTERN_SELECT_DETAIL';
+export const AUTO_FOLLOW_PATTERN_SELECT_EDIT = 'AUTO_FOLLOW_PATTERN_SELECT_EDIT';
export const AUTO_FOLLOW_PATTERN_LOAD = 'AUTO_FOLLOW_PATTERN_LOAD';
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';
-export const AUTO_FOLLOW_PATTERN_DETAIL_PANEL = 'AUTO_FOLLOW_PATTERN_DETAIL_PANEL';
+
+// Follower index
+export const FOLLOWER_INDEX_SELECT_DETAIL = 'FOLLOWER_INDEX_SELECT_DETAIL';
+export const FOLLOWER_INDEX_SELECT_EDIT = 'FOLLOWER_INDEX_SELECT_EDIT';
+export const FOLLOWER_INDEX_LOAD = 'FOLLOWER_INDEX_LOAD';
+export const FOLLOWER_INDEX_GET = 'FOLLOWER_INDEX_GET';
+export const FOLLOWER_INDEX_CREATE = 'FOLLOWER_INDEX_CREATE';
+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/api.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/api.js
index 7b5fa621dddea..0bc4be5c7be04 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/api.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/api.js
@@ -7,15 +7,6 @@
import * as t from '../action_types';
import { API_STATUS } from '../../constants';
-export const sendApiRequest = ({
- label,
- scope,
- status,
- handler,
- onSuccess = () => undefined,
- onError = () => undefined,
-}) => ({ type: t.API, payload: { label, scope, status, handler, onSuccess, onError } });
-
export const apiRequestStart = ({ label, scope, status = API_STATUS.LOADING }) => ({
type: t.API_REQUEST_START,
payload: { label, scope, status },
@@ -29,3 +20,32 @@ export const setApiError = ({ error, scope }) => ({
});
export const clearApiError = scope => ({ type: t.API_ERROR_SET, payload: { error: null, scope } });
+
+export const sendApiRequest = ({
+ label,
+ scope,
+ status,
+ handler,
+ onSuccess = () => undefined,
+ onError = () => undefined,
+}) => async (dispatch, getState) => {
+
+ dispatch(clearApiError(scope));
+ dispatch(apiRequestStart({ label, scope, status }));
+
+ try {
+ const response = await handler(dispatch);
+
+ dispatch(apiRequestEnd({ label, scope }));
+ dispatch({ type: `${label}_SUCCESS`, payload: response });
+
+ onSuccess(response, dispatch, getState);
+
+ } catch (error) {
+ dispatch(apiRequestEnd({ label, scope }));
+ dispatch(setApiError({ error, scope }));
+ dispatch({ type: `${label}_FAILURE`, payload: error });
+
+ onError(error, dispatch, getState);
+ }
+};
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js
index e02085ceb0290..3636befdc9bf7 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js
@@ -16,25 +16,18 @@ import {
import routing from '../../services/routing';
import * as t from '../action_types';
import { sendApiRequest } from './api';
-import { getDetailPanelAutoFollowPatternName } from '../selectors';
+import { getSelectedAutoFollowPatternId } from '../selectors';
const { AUTO_FOLLOW_PATTERN: scope } = SECTIONS;
-export const editAutoFollowPattern = (name) => ({
- type: t.AUTO_FOLLOW_PATTERN_EDIT,
- payload: name
+export const selectDetailAutoFollowPattern = (id) => ({
+ type: t.AUTO_FOLLOW_PATTERN_SELECT_DETAIL,
+ payload: id
});
-export const openAutoFollowPatternDetailPanel = (name) => {
- return {
- type: t.AUTO_FOLLOW_PATTERN_DETAIL_PANEL,
- payload: name
- };
-};
-
-export const closeAutoFollowPatternDetailPanel = () => ({
- type: t.AUTO_FOLLOW_PATTERN_DETAIL_PANEL,
- payload: null
+export const selectEditAutoFollowPattern = (id) => ({
+ type: t.AUTO_FOLLOW_PATTERN_SELECT_EDIT,
+ payload: id
});
export const loadAutoFollowPatterns = (isUpdating = false) =>
@@ -42,21 +35,17 @@ export const loadAutoFollowPatterns = (isUpdating = false) =>
label: t.AUTO_FOLLOW_PATTERN_LOAD,
scope,
status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING,
- handler: async () => {
- return await loadAutoFollowPatternsRequest();
- },
+ handler: async () => (
+ await loadAutoFollowPatternsRequest()
+ ),
});
export const getAutoFollowPattern = (id) =>
sendApiRequest({
label: t.AUTO_FOLLOW_PATTERN_GET,
- scope,
- handler: async (dispatch) => (
- getAutoFollowPatternRequest(id)
- .then((response) => {
- dispatch(editAutoFollowPattern(id));
- return response;
- })
+ scope: `${scope}-get`,
+ handler: async () => (
+ await getAutoFollowPatternRequest(id)
)
});
@@ -64,7 +53,7 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false)
sendApiRequest({
label: isUpdating ? t.AUTO_FOLLOW_PATTERN_UPDATE : t.AUTO_FOLLOW_PATTERN_CREATE,
status: API_STATUS.SAVING,
- scope,
+ scope: `${scope}-save`,
handler: async () => {
if (isUpdating) {
return await updateAutoFollowPatternRequest(id, autoFollowPattern);
@@ -73,11 +62,11 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false)
},
onSuccess() {
const successMessage = isUpdating
- ? i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successMultipleNotificationTitle', {
+ ? i18n.translate('xpack.crossClusterReplication.autoFollowPattern.updateAction.successNotificationTitle', {
defaultMessage: `Auto-follow pattern '{name}' updated successfully`,
values: { name: id },
})
- : i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successSingleNotificationTitle', {
+ : i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successNotificationTitle', {
defaultMessage: `Added auto-follow pattern '{name}'`,
values: { name: id },
});
@@ -132,12 +121,12 @@ export const deleteAutoFollowPattern = (id) => (
});
toastNotifications.addSuccess(successMessage);
- }
- // If we've just deleted a pattern we were looking at, we need to close the panel.
- const detailPanelAutoFollowPatternName = getDetailPanelAutoFollowPatternName(getState());
- if (detailPanelAutoFollowPatternName && response.itemsDeleted.includes(detailPanelAutoFollowPatternName)) {
- dispatch(closeAutoFollowPatternDetailPanel());
+ // If we've just deleted a pattern we were looking at, we need to close the panel.
+ const autoFollowPatternId = getSelectedAutoFollowPatternId('detail')(getState());
+ if (response.itemsDeleted.includes(autoFollowPatternId)) {
+ dispatch(selectDetailAutoFollowPattern(null));
+ }
}
}
})
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..06b2511fde1ed
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js
@@ -0,0 +1,251 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+import { toastNotifications } from 'ui/notify';
+import routing from '../../services/routing';
+import { SECTIONS, API_STATUS } from '../../constants';
+import {
+ loadFollowerIndices as loadFollowerIndicesRequest,
+ getFollowerIndex as getFollowerIndexRequest,
+ createFollowerIndex as createFollowerIndexRequest,
+ pauseFollowerIndex as pauseFollowerIndexRequest,
+ resumeFollowerIndex as resumeFollowerIndexRequest,
+ unfollowLeaderIndex as unfollowLeaderIndexRequest,
+ updateFollowerIndex as updateFollowerIndexRequest,
+} from '../../services/api';
+import * as t from '../action_types';
+import { sendApiRequest } from './api';
+import { getSelectedFollowerIndexId } from '../selectors';
+
+const { FOLLOWER_INDEX: scope } = SECTIONS;
+
+export const selectDetailFollowerIndex = (id) => ({
+ type: t.FOLLOWER_INDEX_SELECT_DETAIL,
+ payload: id
+});
+
+export const selectEditFollowerIndex = (id) => ({
+ type: t.FOLLOWER_INDEX_SELECT_EDIT,
+ 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: `${scope}-get`,
+ handler: async () => (
+ await getFollowerIndexRequest(id)
+ )
+ });
+
+export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => (
+ sendApiRequest({
+ label: t.FOLLOWER_INDEX_CREATE,
+ status: API_STATUS.SAVING,
+ scope: `${scope}-save`,
+ handler: async () => {
+ if (isUpdating) {
+ return await updateFollowerIndexRequest(name, followerIndex);
+ }
+ return await createFollowerIndexRequest({ name, ...followerIndex });
+ },
+ onSuccess() {
+ const successMessage = isUpdating
+ ? i18n.translate('xpack.crossClusterReplication.followerIndex.updateAction.successNotificationTitle', {
+ defaultMessage: `Follower index '{name}' updated successfully`,
+ values: { name },
+ })
+ : i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successNotificationTitle', {
+ defaultMessage: `Added follower index '{name}'`,
+ values: { name },
+ });
+
+ toastNotifications.addSuccess(successMessage);
+ routing.navigate(`/follower_indices`, undefined, {
+ name: encodeURIComponent(name),
+ });
+ },
+ })
+);
+
+export const pauseFollowerIndex = (id) => (
+ sendApiRequest({
+ label: t.FOLLOWER_INDEX_PAUSE,
+ status: API_STATUS.SAVING,
+ scope,
+ handler: async () => (
+ pauseFollowerIndexRequest(id)
+ ),
+ onSuccess(response, dispatch) {
+ /**
+ * 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 hasMultiplePaused = response.itemsPaused.length > 1;
+
+ const successMessage = hasMultiplePaused
+ ? 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);
+
+ // 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 hasMultipleResumed = response.itemsResumed.length > 1;
+
+ const successMessage = hasMultipleResumed
+ ? 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 hasMultipleUnfollow = response.itemsUnfollowed.length > 1;
+
+ const successMessage = hasMultipleUnfollow
+ ? 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 (response.itemsNotOpen.length) {
+ const hasMultipleNotOpen = response.itemsNotOpen.length > 1;
+
+ const warningMessage = hasMultipleNotOpen
+ ? i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.notOpenWarningMultipleNotificationTitle', {
+ defaultMessage: `{count} indices could not be re-opened`,
+ values: { count: response.itemsNotOpen.length },
+ })
+ : i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.notOpenWarningSingleNotificationTitle', {
+ defaultMessage: `Index '{name}' could not be re-opened`,
+ values: { name: response.itemsNotOpen[0] },
+ });
+
+ toastNotifications.addWarning(warningMessage);
+ }
+
+ // 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/actions/index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/index.js
index 75f2344f76e06..1ecee23d1c79a 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,6 +5,6 @@
*/
export * from './auto_follow_pattern';
-
+export * from './follower_index';
export * from './api';
export * from './ccr';
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/api.js b/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/api.js
deleted file mode 100644
index 234aa24c07748..0000000000000
--- a/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/api.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * 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 { apiRequestStart, apiRequestEnd, setApiError, clearApiError } from '../actions/api';
-
-export const apiMiddleware = ({ dispatch, getState }) => next => async (action) => {
- next(action);
-
- if (action.type !== t.API) {
- return;
- }
-
- const { label, scope, status, handler, onSuccess, onError } = action.payload;
-
- dispatch(clearApiError(scope));
- dispatch(apiRequestStart({ label, scope, status }));
-
- try {
- const response = await handler(dispatch);
-
- dispatch(apiRequestEnd({ label, scope }));
- dispatch({ type: `${label}_SUCCESS`, payload: response });
-
- onSuccess(response, dispatch, getState);
-
- } catch (error) {
- dispatch(apiRequestEnd({ label, scope }));
- dispatch(setApiError({ error, scope }));
- dispatch({ type: `${label}_FAILURE`, payload: error });
-
- onError(error, dispatch, getState);
- }
-};
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/auto_follow_pattern.js
deleted file mode 100644
index dbb7985b409eb..0000000000000
--- a/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/auto_follow_pattern.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * 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 routing from '../../services/routing';
-import * as t from '../action_types';
-import { extractQueryParams } from '../../services/query_params';
-import { loadAutoFollowStats } from '../actions';
-
-export const autoFollowPatternMiddleware = ({ dispatch }) => next => action => {
- const { type, payload: name } = action;
- const { history } = routing.reactRouter;
- const search = history.location.search;
- const { pattern: patternName } = extractQueryParams(search);
-
- switch (type) {
- case t.AUTO_FOLLOW_PATTERN_DETAIL_PANEL:
- if (!routing.userHasLeftApp) {
- // Persist state to query params by removing deep link.
- if(!name) {
- history.replace({
- search: '',
- });
- }
- // Allow the user to share a deep link to this job.
- else if (patternName !== name) {
- history.replace({
- search: `?pattern=${encodeURIComponent(name)}`,
- });
-
- dispatch(loadAutoFollowStats());
- }
- }
- break;
- }
-
- return next(action);
-};
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/api.test.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js
index 818045919caf7..43d1da3f242a2 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js
@@ -8,6 +8,17 @@ import { reducer, initialState } from './api';
import { API_STATUS } from '../../constants';
import { apiRequestStart, apiRequestEnd, setApiError } from '../actions';
+jest.mock('../../constants', () => ({
+ API_STATUS: {
+ IDLE: 'idle',
+ LOADING: 'loading',
+ },
+ SECTIONS: {
+ AUTO_FOLLOW_PATTERN: 'autoFollowPattern',
+ FOLLOWER_INDEX: 'followerIndex',
+ }
+}));
+
describe('CCR Api reducers', () => {
const scope = 'testSection';
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js
index 398f949db9425..2170bc539d033 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js
@@ -10,8 +10,8 @@ import { getPrefixSuffixFromFollowPattern } from '../../services/auto_follow_pat
const initialState = {
byId: {},
- selectedId: null,
- detailPanelId: null,
+ selectedDetailId: null,
+ selectedEditId: null,
};
const success = action => `${action}_SUCCESS`;
@@ -31,11 +31,11 @@ export const reducer = (state = initialState, action) => {
case success(t.AUTO_FOLLOW_PATTERN_GET): {
return { ...state, byId: { ...state.byId, [action.payload.name]: parseAutoFollowPattern(action.payload) } };
}
- case t.AUTO_FOLLOW_PATTERN_EDIT: {
- return { ...state, selectedId: action.payload };
+ case t.AUTO_FOLLOW_PATTERN_SELECT_DETAIL: {
+ return { ...state, selectedDetailId: action.payload };
}
- case t.AUTO_FOLLOW_PATTERN_DETAIL_PANEL: {
- return { ...state, detailPanelId: action.payload };
+ case t.AUTO_FOLLOW_PATTERN_SELECT_EDIT: {
+ return { ...state, selectedEditId: action.payload };
}
case success(t.AUTO_FOLLOW_PATTERN_DELETE): {
const byId = { ...state.byId };
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..6256a16316ad3
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js
@@ -0,0 +1,45 @@
+/*
+ * 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 status into boolean
+ return { ...followerIndex, isPaused: followerIndex.status === 'paused' };
+};
+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 };
+ }
+ case t.FOLLOWER_INDEX_SELECT_EDIT: {
+ return { ...state, selectedEditId: 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/public/app/store/reducers/index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/index.js
index 2dc9d42fc2b8d..168a465aed64a 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,10 +7,12 @@
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';
import { reducer as stats } from './stats';
export const ccr = combineReducers({
autoFollowPattern,
+ followerIndex,
api,
stats,
});
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 0bb0b099b8701..8b0d4f18b21cd 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
@@ -6,10 +6,11 @@
import { createSelector } from 'reselect';
import { objectToArray } from '../../services/utils';
+import { API_STATUS } from '../../constants';
// Api
export const getApiState = (state) => state.api;
-export const getApiStatus = (scope) => createSelector(getApiState, (apiState) => apiState.status[scope]);
+export const getApiStatus = (scope) => createSelector(getApiState, (apiState) => apiState.status[scope] || API_STATUS.IDLE);
export const getApiError = (scope) => createSelector(getApiState, (apiState) => apiState.error[scope]);
export const isApiAuthorized = (scope) => createSelector(getApiError(scope), (error) => {
if (!error) {
@@ -25,26 +26,37 @@ export const getAutoFollowStats = createSelector(getStatsState, (statsState) =>
// Auto-follow pattern
export const getAutoFollowPatternState = (state) => state.autoFollowPattern;
export const getAutoFollowPatterns = createSelector(getAutoFollowPatternState, (autoFollowPatternsState) => autoFollowPatternsState.byId);
-export const getDetailPanelAutoFollowPatternName = createSelector(getAutoFollowPatternState,
- (autoFollowPatternsState) => autoFollowPatternsState.detailPanelId);
-export const getSelectedAutoFollowPattern = createSelector(getAutoFollowPatternState, (autoFollowPatternsState) => {
- if(!autoFollowPatternsState.selectedId) {
- return null;
- }
- return autoFollowPatternsState.byId[autoFollowPatternsState.selectedId];
-});
-export const isAutoFollowPatternDetailPanelOpen = createSelector(getAutoFollowPatternState, (autoFollowPatternsState) => {
- return !!autoFollowPatternsState.detailPanelId;
-});
-export const getDetailPanelAutoFollowPattern = createSelector(
+export const getSelectedAutoFollowPatternId = (view = 'detail') => createSelector(getAutoFollowPatternState, (autoFollowPatternsState) => (
+ view === 'detail' ? autoFollowPatternsState.selectedDetailId : autoFollowPatternsState.selectedEditId
+));
+export const getSelectedAutoFollowPattern = (view = 'detail') => createSelector(
getAutoFollowPatternState, getAutoFollowStats, (autoFollowPatternsState, autoFollowStatsState) => {
- if(!autoFollowPatternsState.detailPanelId) {
+ const propId = view === 'detail' ? 'selectedDetailId' : 'selectedEditId';
+
+ if(!autoFollowPatternsState[propId]) {
return null;
}
- const { detailPanelId } = autoFollowPatternsState;
- const autoFollowPattern = autoFollowPatternsState.byId[detailPanelId];
- const errors = autoFollowStatsState && autoFollowStatsState.recentAutoFollowErrors[detailPanelId] || [];
+ const id = autoFollowPatternsState[propId];
+ const autoFollowPattern = autoFollowPatternsState.byId[id];
+
+ // Check if any error and merge them on the auto-follow pattern
+ const errors = autoFollowStatsState && autoFollowStatsState.recentAutoFollowErrors[id] || [];
return autoFollowPattern ? { ...autoFollowPattern, errors } : null;
});
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/public/app/store/store.js b/x-pack/plugins/cross_cluster_replication/public/app/store/store.js
index 6d2a700deab41..b6674d89f6278 100644
--- a/x-pack/plugins/cross_cluster_replication/public/app/store/store.js
+++ b/x-pack/plugins/cross_cluster_replication/public/app/store/store.js
@@ -5,12 +5,12 @@
*/
import { applyMiddleware, compose, createStore } from 'redux';
+import thunk from 'redux-thunk';
-import { apiMiddleware, autoFollowPatternMiddleware } from './middleware';
import { ccr } from './reducers';
function createCrossClusterReplicationStore(initialState = {}) {
- const enhancers = [applyMiddleware(apiMiddleware, autoFollowPatternMiddleware)];
+ const enhancers = [applyMiddleware(thunk)];
if (window.__REDUX_DEVTOOLS_EXTENSION__) {
enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__());
diff --git a/x-pack/plugins/cross_cluster_replication/public/index.js b/x-pack/plugins/cross_cluster_replication/public/index.js
index b4bf57ec47f7a..4ec268f0de7f2 100644
--- a/x-pack/plugins/cross_cluster_replication/public/index.js
+++ b/x-pack/plugins/cross_cluster_replication/public/index.js
@@ -4,6 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import './register_ccr_section';
import './register_routes';
import './extend_index_management';
diff --git a/x-pack/plugins/cross_cluster_replication/public/register_ccr_section.js b/x-pack/plugins/cross_cluster_replication/public/register_ccr_section.js
deleted file mode 100644
index 4383e29e5548d..0000000000000
--- a/x-pack/plugins/cross_cluster_replication/public/register_ccr_section.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * 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 { management } from 'ui/management';
-import { i18n } from '@kbn/i18n';
-import chrome from 'ui/chrome';
-import { BASE_PATH } from '../common/constants';
-
-if (chrome.getInjected('ccrUiEnabled')) {
- const esSection = management.getSection('elasticsearch');
-
- esSection.register('ccr', {
- visible: true,
- display: i18n.translate('xpack.crossClusterReplication.appTitle', { defaultMessage: 'Cross Cluster Replication' }),
- order: 3,
- url: `#${BASE_PATH}`
- });
-}
diff --git a/x-pack/plugins/cross_cluster_replication/public/register_routes.js b/x-pack/plugins/cross_cluster_replication/public/register_routes.js
index d8084f1d3415c..d78e9fd12cb84 100644
--- a/x-pack/plugins/cross_cluster_replication/public/register_routes.js
+++ b/x-pack/plugins/cross_cluster_replication/public/register_routes.js
@@ -4,38 +4,62 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import routes from 'ui/routes';
import { unmountComponentAtNode } from 'react-dom';
import chrome from 'ui/chrome';
+import { management } from 'ui/management';
+import routes from 'ui/routes';
+import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info';
+import { i18n } from '@kbn/i18n';
import template from './main.html';
-import { BASE_PATH } from '../common/constants/base_path';
+import { BASE_PATH } from '../common/constants';
import { renderReact } from './app';
import { setHttpClient } from './app/services/api';
+import { setLicense } from './app/services/license';
if (chrome.getInjected('ccrUiEnabled')) {
+ const esSection = management.getSection('elasticsearch');
+
+ esSection.register('ccr', {
+ visible: true,
+ display: i18n.translate('xpack.crossClusterReplication.appTitle', { defaultMessage: 'Cross Cluster Replication' }),
+ order: 3,
+ url: `#${BASE_PATH}`
+ });
+
let elem;
const CCR_REACT_ROOT = 'ccrReactRoot';
const unmountReactApp = () => elem && unmountComponentAtNode(elem);
- routes.when(`${BASE_PATH}/:section?/:view?/:id?`, {
- template: template,
+ routes.when(`${BASE_PATH}/:section?/:subsection?/:view?/:id?`, {
+ template,
+ resolve: {
+ license(Private) {
+ const xpackInfo = Private(XPackInfoProvider);
+ return {
+ isAvailable: () => xpackInfo.get('features.crossClusterReplication.isAvailable'),
+ isActive: () => xpackInfo.get('features.crossClusterReplication.isActive'),
+ getReason: () => xpackInfo.get('features.crossClusterReplication.message'),
+ };
+ }
+ },
controllerAs: 'ccr',
controller: class CrossClusterReplicationController {
- constructor($scope, $route, $http) {
- /**
- * React-router's does not play well with the angular router. It will cause this controller
- * to re-execute without the $destroy handler being called. This means that the app will be mounted twice
- * creating a memory leak when leaving (only 1 app will be unmounted).
- * To avoid this, we unmount the React app each time we enter the controller.
- */
+ constructor($scope, $route, $http, $q) {
+ const { license: { isAvailable, isActive, getReason } } = $route.current.locals;
+ setLicense(isAvailable, isActive, getReason);
+
+ // React-router's does not play well with the angular router. It will cause this controller
+ // to re-execute without the $destroy handler being called. This means that the app will be mounted twice
+ // creating a memory leak when leaving (only 1 app will be unmounted).
+ // To avoid this, we unmount the React app each time we enter the controller.
unmountReactApp();
// NOTE: We depend upon Angular's $http service because it's decorated with interceptors,
// e.g. to check license status per request.
- setHttpClient($http);
+ setHttpClient($http, $q);
$scope.$$postDigest(() => {
elem = document.getElementById(CCR_REACT_ROOT);
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 c3ed6570c44c9..85d001674c5b6 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
@@ -10,6 +10,16 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
Client.prototype.ccr = components.clientAction.namespaceFactory();
const ccr = Client.prototype.ccr.prototype;
+ ccr.permissions = ca({
+ urls: [
+ {
+ fmt: '/_security/user/_has_privileges',
+ }
+ ],
+ needBody: true,
+ method: 'POST'
+ });
+
ccr.autoFollowPatterns = ca({
urls: [
{
@@ -63,6 +73,20 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
method: 'DELETE'
});
+ ccr.info = ca({
+ urls: [
+ {
+ fmt: '/<%=id%>/_ccr/info',
+ req: {
+ id: {
+ type: 'string'
+ }
+ }
+ }
+ ],
+ method: 'GET'
+ });
+
ccr.stats = ca({
urls: [
{
@@ -71,4 +95,76 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
],
method: 'GET'
});
+
+ ccr.followerIndexStats = ca({
+ urls: [
+ {
+ fmt: '/<%=id%>/_ccr/stats',
+ req: {
+ id: {
+ type: 'string'
+ }
+ }
+ }
+ ],
+ method: 'GET'
+ });
+
+ ccr.saveFollowerIndex = ca({
+ urls: [
+ {
+ fmt: '/<%=name%>/_ccr/follow',
+ req: {
+ name: {
+ type: 'string'
+ }
+ }
+ }
+ ],
+ 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'
+ }
+ }
+ }
+ ],
+ needBody: true,
+ 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/lib/__snapshots__/follower_index_serialization.test.js.snap b/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap
index 1c20f73287259..d001459e8234d 100644
--- a/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap
+++ b/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap
@@ -2,7 +2,19 @@
exports[`[CCR] follower index serialization deserializeFollowerIndex() deserializes Elasticsearch follower index object 1`] = `
Object {
- "name": "follower index name",
+ "leaderIndex": undefined,
+ "maxOutstandingReadRequests": undefined,
+ "maxOutstandingWriteRequests": undefined,
+ "maxReadRequestOperationCount": undefined,
+ "maxReadRequestSize": undefined,
+ "maxRetryDelay": undefined,
+ "maxWriteBufferCount": undefined,
+ "maxWriteBufferSize": undefined,
+ "maxWriteRequestOperationCount": undefined,
+ "maxWriteRequestSize": undefined,
+ "name": undefined,
+ "readPollTimeout": undefined,
+ "remoteCluster": undefined,
"shards": Array [
Object {
"bytesReadCount": undefined,
@@ -61,6 +73,7 @@ Object {
"writeBufferSizeBytes": undefined,
},
],
+ "status": "active",
}
`;
@@ -96,3 +109,20 @@ Object {
"writeBufferSizeBytes": "write buffer size in bytes",
}
`;
+
+exports[`[CCR] follower index serialization serializeFollowerIndex() serializes object to Elasticsearch follower index object 1`] = `
+Object {
+ "leader_index": "leader index",
+ "max_outstanding_read_requests": "foo",
+ "max_outstanding_write_requests": "foo",
+ "max_read_request_operation_count": "foo",
+ "max_read_request_size": "foo",
+ "max_retry_delay": "foo",
+ "max_write_buffer_count": "foo",
+ "max_write_buffer_size": "foo",
+ "max_write_request_operation_count": "foo",
+ "max_write_request_size": "foo",
+ "read_poll_timeout": "foo",
+ "remote_cluster": "remote cluster",
+}
+`;
diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js b/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js
index 35e5e3783e628..fb99de8ab5d97 100644
--- a/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js
+++ b/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js
@@ -26,7 +26,7 @@ export function checkLicense(xpackLicenseInfo) {
};
}
- const VALID_LICENSE_MODES = ['trial', 'basic', 'standard', 'gold', 'platinum'];
+ const VALID_LICENSE_MODES = [ 'trial', 'platinum' ];
const isLicenseModeValid = xpackLicenseInfo.license.isOneOf(VALID_LICENSE_MODES);
const isLicenseActive = xpackLicenseInfo.license.isActive();
@@ -36,7 +36,7 @@ export function checkLicense(xpackLicenseInfo) {
if (!isLicenseModeValid) {
return {
isAvailable: false,
- showLinks: false,
+ isActive: false,
message: i18n.translate(
'xpack.crossClusterReplication.checkLicense.errorUnsupportedMessage',
{
@@ -50,9 +50,8 @@ export function checkLicense(xpackLicenseInfo) {
// License is valid but not active
if (!isLicenseActive) {
return {
- isAvailable: false,
- showLinks: true,
- enableLinks: false,
+ isAvailable: true,
+ isActive: false,
message: i18n.translate(
'xpack.crossClusterReplication.checkLicense.errorExpiredMessage',
{
@@ -66,7 +65,6 @@ export function checkLicense(xpackLicenseInfo) {
// License is valid and active
return {
isAvailable: true,
- showLinks: true,
- enableLinks: true,
+ isActive: true,
};
}
diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js
index 7b5a0e453b65f..d4605996b471d 100644
--- a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js
+++ b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js
@@ -63,15 +63,73 @@ export const deserializeShard = ({
});
/* eslint-enable camelcase */
-export const deserializeFollowerIndex = ({ index, shards }) => ({
- name: index,
- shards: shards.map(deserializeShard),
+/* eslint-disable camelcase */
+export const deserializeFollowerIndex = ({
+ follower_index,
+ remote_cluster,
+ leader_index,
+ status,
+ parameters: {
+ max_read_request_operation_count,
+ max_outstanding_read_requests,
+ max_read_request_size,
+ max_write_request_operation_count,
+ max_write_request_size,
+ max_outstanding_write_requests,
+ max_write_buffer_count,
+ max_write_buffer_size,
+ max_retry_delay,
+ read_poll_timeout,
+ } = {},
+ shards,
+}) => ({
+ name: follower_index,
+ remoteCluster: remote_cluster,
+ leaderIndex: leader_index,
+ status,
+ maxReadRequestOperationCount: max_read_request_operation_count,
+ maxOutstandingReadRequests: max_outstanding_read_requests,
+ maxReadRequestSize: max_read_request_size,
+ maxWriteRequestOperationCount: max_write_request_operation_count,
+ maxWriteRequestSize: max_write_request_size,
+ maxOutstandingWriteRequests: max_outstanding_write_requests,
+ maxWriteBufferCount: max_write_buffer_count,
+ maxWriteBufferSize: max_write_buffer_size,
+ maxRetryDelay: max_retry_delay,
+ readPollTimeout: read_poll_timeout,
+ shards: shards && shards.map(deserializeShard),
});
+/* eslint-enable camelcase */
export const deserializeListFollowerIndices = followerIndices =>
followerIndices.map(deserializeFollowerIndex);
-export const serializeFollowerIndex = ({ remoteCluster, leaderIndex }) => ({
- remote_cluster: remoteCluster,
- leader_index: leaderIndex,
+export const serializeAdvancedSettings = ({
+ maxReadRequestOperationCount,
+ maxOutstandingReadRequests,
+ maxReadRequestSize,
+ maxWriteRequestOperationCount,
+ maxWriteRequestSize,
+ maxOutstandingWriteRequests,
+ maxWriteBufferCount,
+ maxWriteBufferSize,
+ maxRetryDelay,
+ readPollTimeout,
+}) => ({
+ max_read_request_operation_count: maxReadRequestOperationCount,
+ max_outstanding_read_requests: maxOutstandingReadRequests,
+ max_read_request_size: maxReadRequestSize,
+ max_write_request_operation_count: maxWriteRequestOperationCount,
+ max_write_request_size: maxWriteRequestSize,
+ max_outstanding_write_requests: maxOutstandingWriteRequests,
+ max_write_buffer_count: maxWriteBufferCount,
+ max_write_buffer_size: maxWriteBufferSize,
+ max_retry_delay: maxRetryDelay,
+ read_poll_timeout: readPollTimeout,
+});
+
+export const serializeFollowerIndex = (followerIndex) => ({
+ remote_cluster: followerIndex.remoteCluster,
+ leader_index: followerIndex.leaderIndex,
+ ...serializeAdvancedSettings(followerIndex)
});
diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js
index d4b5526c16a6b..c6a09d635b711 100644
--- a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js
+++ b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js
@@ -51,6 +51,7 @@ describe('[CCR] follower index serialization', () => {
it('deserializes Elasticsearch follower index object', () => {
const serializedFollowerIndex = {
index: 'follower index name',
+ status: 'active',
shards: [{
shard_id: 'shard 1',
}, {
@@ -65,18 +66,74 @@ describe('[CCR] follower index serialization', () => {
describe('deserializeListFollowerIndices()', () => {
it('deserializes list of Elasticsearch follower index objects', () => {
const serializedFollowerIndexList = [{
- index: 'follower index 1',
+ follower_index: 'follower index 1',
+ remote_cluster: 'cluster 1',
+ leader_index: 'leader 1',
+ status: 'active',
+ parameters: {
+ max_read_request_operation_count: 1,
+ max_outstanding_read_requests: 1,
+ max_read_request_size: 1,
+ max_write_request_operation_count: 1,
+ max_write_request_size: 1,
+ max_outstanding_write_requests: 1,
+ max_write_buffer_count: 1,
+ max_write_buffer_size: 1,
+ max_retry_delay: 1,
+ read_poll_timeout: 1,
+ },
shards: [],
}, {
- index: 'follower index 2',
+ follower_index: 'follower index 2',
+ remote_cluster: 'cluster 2',
+ leader_index: 'leader 2',
+ status: 'paused',
+ parameters: {
+ max_read_request_operation_count: 2,
+ max_outstanding_read_requests: 2,
+ max_read_request_size: 2,
+ max_write_request_operation_count: 2,
+ max_write_request_size: 2,
+ max_outstanding_write_requests: 2,
+ max_write_buffer_count: 2,
+ max_write_buffer_size: 2,
+ max_retry_delay: 2,
+ read_poll_timeout: 2,
+ },
shards: [],
}];
const deserializedFollowerIndexList = [{
name: 'follower index 1',
+ remoteCluster: 'cluster 1',
+ leaderIndex: 'leader 1',
+ status: 'active',
+ maxReadRequestOperationCount: 1,
+ maxOutstandingReadRequests: 1,
+ maxReadRequestSize: 1,
+ maxWriteRequestOperationCount: 1,
+ maxWriteRequestSize: 1,
+ maxOutstandingWriteRequests: 1,
+ maxWriteBufferCount: 1,
+ maxWriteBufferSize: 1,
+ maxRetryDelay: 1,
+ readPollTimeout: 1,
shards: [],
}, {
name: 'follower index 2',
+ remoteCluster: 'cluster 2',
+ leaderIndex: 'leader 2',
+ status: 'paused',
+ maxReadRequestOperationCount: 2,
+ maxOutstandingReadRequests: 2,
+ maxReadRequestSize: 2,
+ maxWriteRequestOperationCount: 2,
+ maxWriteRequestSize: 2,
+ maxOutstandingWriteRequests: 2,
+ maxWriteBufferCount: 2,
+ maxWriteBufferSize: 2,
+ maxRetryDelay: 2,
+ readPollTimeout: 2,
shards: [],
}];
@@ -90,14 +147,19 @@ describe('[CCR] follower index serialization', () => {
const deserializedFollowerIndex = {
remoteCluster: 'remote cluster',
leaderIndex: 'leader index',
+ maxReadRequestOperationCount: 'foo',
+ maxOutstandingReadRequests: 'foo',
+ maxReadRequestSize: 'foo',
+ maxWriteRequestOperationCount: 'foo',
+ maxWriteRequestSize: 'foo',
+ maxOutstandingWriteRequests: 'foo',
+ maxWriteBufferCount: 'foo',
+ maxWriteBufferSize: 'foo',
+ maxRetryDelay: 'foo',
+ readPollTimeout: 'foo',
};
- const serializedFollowerIndex = {
- remote_cluster: 'remote cluster',
- leader_index: 'leader index',
- };
-
- expect(serializeFollowerIndex(deserializedFollowerIndex)).toEqual(serializedFollowerIndex);
+ expect(serializeFollowerIndex(deserializedFollowerIndex)).toMatchSnapshot();
});
});
});
diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js
index 17354de2fb11e..3f8e149659ae6 100644
--- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js
+++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js
@@ -15,14 +15,12 @@ import {
import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory';
import { API_BASE_PATH } from '../../../common/constants';
-// import { esErrors } from '../../../fixtures'; // Temp for development to test ES error in UI
-
export const registerAutoFollowPatternRoutes = (server) => {
const isEsError = isEsErrorFactory(server);
const licensePreRouting = licensePreRoutingFactory(server);
/**
- * Returns a list of all Auto follow patterns
+ * Returns a list of all auto-follow patterns
*/
server.route({
path: `${API_BASE_PATH}/auto_follow_patterns`,
@@ -33,8 +31,6 @@ export const registerAutoFollowPatternRoutes = (server) => {
handler: async (request) => {
const callWithRequest = callWithRequestFactory(server, request);
- // throw wrapEsError(esErrors[403]); // Temp for development to test ES error in UI. MUST be commented in CR
-
try {
const response = await callWithRequest('ccr.autoFollowPatterns');
return ({
@@ -118,7 +114,7 @@ export const registerAutoFollowPatternRoutes = (server) => {
});
/**
- * Returns a single Auto follow pattern
+ * Returns a single auto-follow pattern
*/
server.route({
path: `${API_BASE_PATH}/auto_follow_patterns/{id}`,
@@ -145,7 +141,7 @@ export const registerAutoFollowPatternRoutes = (server) => {
});
/**
- * Delete an auto follow pattern
+ * Delete an auto-follow pattern
*/
server.route({
path: `${API_BASE_PATH}/auto_follow_patterns/{id}`,
diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js
index 781f4d6ec6cd5..33012ddbc779f 100644
--- a/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js
+++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js
@@ -7,7 +7,6 @@ import { callWithRequestFactory } from '../../lib/call_with_request_factory';
import { isEsErrorFactory } from '../../lib/is_es_error_factory';
import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers';
import { deserializeAutoFollowStats } from '../../lib/ccr_stats_serialization';
-import { deserializeListFollowerIndices } from '../../lib/follower_index_serialization';
import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory';
import { API_BASE_PATH } from '../../../common/constants';
@@ -15,49 +14,72 @@ export const registerCcrRoutes = (server) => {
const isEsError = isEsErrorFactory(server);
const licensePreRouting = licensePreRoutingFactory(server);
- const getStatsHandler = async (request) => {
- const callWithRequest = callWithRequestFactory(server, request);
-
- try {
- const response = await callWithRequest('ccr.stats');
- return {
- autoFollow: deserializeAutoFollowStats(response.auto_follow_stats),
- follow: {
- indices: deserializeListFollowerIndices(response.follow_stats.indices)
- }
- };
- } catch(err) {
- if (isEsError(err)) {
- throw wrapEsError(err);
- }
- throw wrapUnknownError(err);
- }
- };
-
/**
- * Returns CCR stats
+ * Returns Auto-follow stats
*/
server.route({
- path: `${API_BASE_PATH}/stats`,
+ path: `${API_BASE_PATH}/stats/auto_follow`,
method: 'GET',
config: {
pre: [ licensePreRouting ]
},
- handler: getStatsHandler,
+ handler: async (request) => {
+ const callWithRequest = callWithRequestFactory(server, request);
+
+ try {
+ const {
+ auto_follow_stats: autoFollowStats,
+ } = await callWithRequest('ccr.stats');
+
+ return deserializeAutoFollowStats(autoFollowStats);
+ } catch(err) {
+ if (isEsError(err)) {
+ throw wrapEsError(err);
+ }
+ throw wrapUnknownError(err);
+ }
+ },
});
/**
- * Returns Auto-follow stats
+ * Returns whether the user has CCR permissions
*/
server.route({
- path: `${API_BASE_PATH}/stats/auto-follow`,
+ path: `${API_BASE_PATH}/permissions`,
method: 'GET',
config: {
pre: [ licensePreRouting ]
},
handler: async (request) => {
- const { autoFollow } = await getStatsHandler(request);
- return autoFollow;
+ const callWithRequest = callWithRequestFactory(server, request);
+
+ try {
+ const {
+ has_all_requested: hasPermission,
+ cluster,
+ } = await callWithRequest('ccr.permissions', {
+ body: {
+ cluster: ['manage', 'manage_ccr'],
+ },
+ });
+
+ const missingClusterPrivileges = Object.keys(cluster).reduce((permissions, permissionName) => {
+ if (!cluster[permissionName]) {
+ permissions.push(permissionName);
+ return permissions;
+ }
+ }, []);
+
+ return {
+ hasPermission,
+ missingClusterPrivileges,
+ };
+ } catch(err) {
+ if (isEsError(err)) {
+ throw wrapEsError(err);
+ }
+ throw wrapUnknownError(err);
+ }
},
});
};
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
new file mode 100644
index 0000000000000..81e0990c7691c
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js
@@ -0,0 +1,311 @@
+/*
+ * 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 Boom from 'boom';
+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,
+ serializeAdvancedSettings,
+} from '../../lib/follower_index_serialization';
+import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory';
+import { API_BASE_PATH } from '../../../common/constants';
+import { removeEmptyFields } from '../../../common/services/utils';
+
+export const registerFollowerIndexRoutes = (server) => {
+ const isEsError = isEsErrorFactory(server);
+ const licensePreRouting = licensePreRoutingFactory(server);
+
+ /**
+ * Returns a list of all follower indices
+ */
+ server.route({
+ path: `${API_BASE_PATH}/follower_indices`,
+ method: 'GET',
+ config: {
+ pre: [ licensePreRouting ]
+ },
+ handler: async (request) => {
+ const callWithRequest = callWithRequestFactory(server, request);
+
+ try {
+ const {
+ follower_indices: followerIndices
+ } = await callWithRequest('ccr.info', { id: '_all' });
+
+ const {
+ follow_stats: {
+ indices: followerIndicesStats
+ }
+ } = await callWithRequest('ccr.stats');
+
+ const followerIndicesStatsMap = followerIndicesStats.reduce((map, stats) => {
+ map[stats.index] = stats;
+ return map;
+ }, {});
+
+ const collatedFollowerIndices = followerIndices.map(followerIndex => {
+ return {
+ ...followerIndex,
+ ...followerIndicesStatsMap[followerIndex.follower_index]
+ };
+ });
+
+ return ({
+ indices: deserializeListFollowerIndices(collatedFollowerIndices)
+ });
+ } catch(err) {
+ if (isEsError(err)) {
+ throw wrapEsError(err);
+ }
+ throw wrapUnknownError(err);
+ }
+ },
+ });
+
+ /**
+ * 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 {
+ follower_indices: followerIndices
+ } = await callWithRequest('ccr.info', { id });
+
+ const followerIndexInfo = followerIndices && followerIndices[0];
+
+ if(!followerIndexInfo) {
+ const error = Boom.notFound(`The follower index "${id}" does not exist.`);
+ throw(error);
+ }
+
+ // If this follower is paused, skip call to ES stats api since it will return 404
+ if(followerIndexInfo.status === 'paused') {
+ return deserializeFollowerIndex({
+ ...followerIndexInfo
+ });
+ } else {
+ const {
+ indices: followerIndicesStats
+ } = await callWithRequest('ccr.followerIndexStats', { id });
+
+ return deserializeFollowerIndex({
+ ...followerIndexInfo,
+ ...(followerIndicesStats ? followerIndicesStats[0] : {})
+ });
+ }
+ } catch(err) {
+ if (isEsError(err)) {
+ throw wrapEsError(err);
+ }
+ throw wrapUnknownError(err);
+ }
+ },
+ });
+
+ /**
+ * Create a follower index
+ */
+ server.route({
+ path: `${API_BASE_PATH}/follower_indices`,
+ method: 'POST',
+ config: {
+ pre: [ licensePreRouting ]
+ },
+ handler: async (request) => {
+ const callWithRequest = callWithRequestFactory(server, request);
+ const { name, ...rest } = request.payload;
+ const body = removeEmptyFields(serializeFollowerIndex(rest));
+
+ try {
+ return await callWithRequest('ccr.saveFollowerIndex', { name, body });
+ } catch(err) {
+ if (isEsError(err)) {
+ throw wrapEsError(err);
+ }
+ throw wrapUnknownError(err);
+ }
+ },
+ });
+
+ /**
+ * Edit a follower index
+ */
+ server.route({
+ path: `${API_BASE_PATH}/follower_indices/{id}`,
+ method: 'PUT',
+ config: {
+ pre: [ licensePreRouting ]
+ },
+ handler: async (request) => {
+ const callWithRequest = callWithRequestFactory(server, request);
+ const { id: _id } = request.params;
+ const { isPaused = false } = request.payload;
+ const body = removeEmptyFields(serializeAdvancedSettings(request.payload));
+
+ // We need to first pause the follower and then resume it passing the advanced settings
+ try {
+ // Pause follower if not already paused
+ if(!isPaused) {
+ await callWithRequest('ccr.pauseFollowerIndex', { id: _id });
+ }
+
+ // Resume follower
+ return await callWithRequest('ccr.resumeFollowerIndex', { id: _id, body });
+ } catch(err) {
+ if (isEsError(err)) {
+ throw wrapEsError(err);
+ }
+ throw wrapUnknownError(err);
+ }
+ },
+ });
+
+ /**
+ * 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 itemsNotOpen = [];
+ const errors = [];
+
+ await Promise.all(ids.map(async (_id) => {
+ try {
+ // Try to pause follower, let it fail silently since it may already be paused
+ try {
+ await callWithRequest('ccr.pauseFollowerIndex', { id: _id });
+ } catch (e) {
+ // Swallow errors
+ }
+
+ // Close index
+ await callWithRequest('indices.close', { index: _id });
+
+ // Unfollow leader
+ await callWithRequest('ccr.unfollowLeaderIndex', { id: _id });
+
+ // Try to re-open the index, store failures in a separate array to surface warnings in the UI
+ // This will allow users to query their index normally after unfollowing
+ try {
+ await callWithRequest('indices.open', { index: _id });
+ } catch (e) {
+ itemsNotOpen.push(_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,
+ itemsNotOpen,
+ 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
new file mode 100644
index 0000000000000..5d4a6c2a56795
--- /dev/null
+++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js
@@ -0,0 +1,294 @@
+/*
+ * 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 { callWithRequestFactory } from '../../lib/call_with_request_factory';
+import { isEsErrorFactory } from '../../lib/is_es_error_factory';
+import { registerFollowerIndexRoutes } from './follower_index';
+import {
+ getFollowerIndexStatsMock,
+ getFollowerIndexListStatsMock,
+ getFollowerIndexInfoMock,
+ getFollowerIndexListInfoMock,
+} from '../../../fixtures';
+import { deserializeFollowerIndex } from '../../lib/follower_index_serialization';
+
+jest.mock('../../lib/call_with_request_factory');
+jest.mock('../../lib/is_es_error_factory');
+jest.mock('../../lib/license_pre_routing_factory');
+
+const DESERIALIZED_KEYS = Object.keys(deserializeFollowerIndex({
+ ...getFollowerIndexInfoMock(),
+ ...getFollowerIndexStatsMock()
+}));
+
+/**
+ * Hashtable to save the route handlers
+ */
+const routeHandlers = {};
+
+/**
+ * Helper to extract all the different server route handler so we can easily call them in our tests.
+ *
+ * Important: This method registers the handlers in the order that they appear in the file, so
+ * if a 'server.route()' call is moved or deleted, then the HANDLER_INDEX_TO_ACTION must be updated here.
+ */
+const registerHandlers = () => {
+ let index = 0;
+
+ const HANDLER_INDEX_TO_ACTION = {
+ 0: 'list',
+ 1: 'get',
+ 2: 'create',
+ 3: 'edit',
+ 4: 'pause',
+ 5: 'resume',
+ 6: 'unfollow',
+ };
+
+ const server = {
+ route({ handler }) {
+ // Save handler and increment index
+ routeHandlers[HANDLER_INDEX_TO_ACTION[index]] = handler;
+ index++;
+ },
+ };
+
+ registerFollowerIndexRoutes(server);
+};
+
+/**
+ * Queue to save request response and errors
+ * It allows us to fake multiple responses from the
+ * callWithRequestFactory() when the request handler call it
+ * multiple times.
+ */
+let requestResponseQueue = [];
+
+/**
+ * Helper to mock the response from the call to Elasticsearch
+ *
+ * @param {*} err The mock error to throw
+ * @param {*} response The response to return
+ */
+const setHttpRequestResponse = (error, response) => {
+ requestResponseQueue.push({ error, response });
+};
+
+const resetHttpRequestResponses = () => requestResponseQueue = [];
+
+const getNextResponseFromQueue = () => {
+ if (!requestResponseQueue.length) {
+ return null;
+ }
+
+ const next = requestResponseQueue.shift();
+ if (next.error) {
+ return Promise.reject(next.error);
+ }
+ return Promise.resolve(next.response);
+};
+
+describe('[CCR API Routes] Follower Index', () => {
+ let routeHandler;
+
+ beforeAll(() => {
+ isEsErrorFactory.mockReturnValue(() => false);
+ callWithRequestFactory.mockReturnValue(getNextResponseFromQueue);
+ registerHandlers();
+ });
+
+ describe('list()', () => {
+ beforeEach(() => {
+ routeHandler = routeHandlers.list;
+ });
+
+ it('deserializes the response from Elasticsearch', async () => {
+ const totalResult = 2;
+ const infoResult = getFollowerIndexListInfoMock(totalResult);
+ const statsResult = getFollowerIndexListStatsMock(totalResult, infoResult.follower_indices.map(index => index.follower_index));
+ setHttpRequestResponse(null, infoResult);
+ setHttpRequestResponse(null, statsResult);
+
+ const response = await routeHandler();
+ const followerIndex = response.indices[0];
+
+ expect(response.indices.length).toEqual(totalResult);
+ expect(Object.keys(followerIndex)).toEqual(DESERIALIZED_KEYS);
+ });
+ });
+
+ describe('get()', () => {
+ beforeEach(() => {
+ routeHandler = routeHandlers.get;
+ });
+
+ it('should return a single resource even though ES return an array with 1 item', async () => {
+ const mockId = 'test1';
+ const followerIndexInfo = getFollowerIndexInfoMock(mockId);
+ const followerIndexStats = getFollowerIndexStatsMock(mockId);
+
+ setHttpRequestResponse(null, { follower_indices: [followerIndexInfo] });
+ setHttpRequestResponse(null, { indices: [followerIndexStats] });
+
+ const response = await routeHandler({ params: { id: mockId } });
+ expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS);
+ });
+ });
+
+ describe('create()', () => {
+ beforeEach(() => {
+ resetHttpRequestResponses();
+ routeHandler = routeHandlers.create;
+ });
+
+ it('should return 200 status when follower index is created', async () => {
+ setHttpRequestResponse(null, { acknowledge: true });
+
+ const response = await routeHandler({
+ payload: {
+ name: 'follower_index',
+ remoteCluster: 'remote_cluster',
+ leaderIndex: 'leader_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 });
+ 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 });
+ 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');
+ });
+ });
+});
diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/register_routes.js b/x-pack/plugins/cross_cluster_replication/server/routes/register_routes.js
index fec6d152f160c..6e4088ec8600f 100644
--- a/x-pack/plugins/cross_cluster_replication/server/routes/register_routes.js
+++ b/x-pack/plugins/cross_cluster_replication/server/routes/register_routes.js
@@ -5,9 +5,11 @@
*/
import { registerAutoFollowPatternRoutes } from './api/auto_follow_pattern';
+import { registerFollowerIndexRoutes } from './api/follower_index';
import { registerCcrRoutes } from './api/ccr';
export function registerRoutes(server) {
registerAutoFollowPatternRoutes(server);
+ registerFollowerIndexRoutes(server);
registerCcrRoutes(server);
}
diff --git a/x-pack/plugins/remote_clusters/public/index.scss b/x-pack/plugins/remote_clusters/public/index.scss
index b25832255cece..2d1a6374352e9 100644
--- a/x-pack/plugins/remote_clusters/public/index.scss
+++ b/x-pack/plugins/remote_clusters/public/index.scss
@@ -1,5 +1,6 @@
// Import the EUI global scope so we can use EUI constants
@import 'ui/public/styles/_styling_constants';
+@import './sections/remote_cluster_list/components/connection_status/index';
// Index management plugin styles
@@ -10,14 +11,6 @@
// remoteClustersChart__legend--small
// remoteClustersChart__legend-isLoading
-/**
- * 1. Override EUI styles.
- */
-.remoteClusterAddPage {
- max-width: 1000px !important; /* 1 */
- width: 100% !important; /* 1 */
-}
-
/**
* 1. Override EuiFormRow styles. Otherwise the switch will jump around when toggled on and off,
* as the 'Reset to defaults' link is added to and removed from the DOM.
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap
new file mode 100644
index 0000000000000..b4b6eb2afc962
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap
@@ -0,0 +1,539 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RemoteClusterForm renders untouched state 1`] = `
+Array [
+
+
+
+
+
+ Name
+
+
+
+ A unique name for the remote cluster.
+
+
+
+
+
+
+
+
+
+
+
+
+ Name can only contain letters, numbers, underscores, and dashes.
+
+
+
+
+
+
+
+
+
+ Seed nodes for cluster discovery
+
+
+
+
+ A list of remote cluster nodes to query for the cluster state. Specify multiple seed nodes so discovery doesn't fail if a node is unavailable.
+
+
+
+
+
+
+
+
+
+
+
+
+ host:port
+
+
+
+
+
+
+
+
+
+
+ An IP address or host name, followed by the
+
+ transport port
+
+ of the remote cluster.
+
+
+
+
+
+
+
+
+
+ Make remote cluster optional
+
+
+
+
+ By default, a request fails if any of the queried remote clusters are unavailable. To continue sending a request to other remote clusters if this cluster is unavailable, enable
+
+ Skip if unavailable
+
+ .
+
+ Learn more.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
,
+ ,
+
+
+
+
+
,
+]
+`;
+
+exports[`RemoteClusterForm validation renders invalid state and a global form error when the user tries to submit an invalid form 1`] = `
+Array [
+
+
+
+
+
+
+
+
+ Name is required.
+
+
+ Name can only contain letters, numbers, underscores, and dashes.
+
+
,
+
+
+
+
+
+
+
+ host:port
+
+
+
+
+
+
+
+
+
+
+ At least one seed node is required.
+
+
+ An IP address or host name, followed by the
+
+ transport port
+
+ of the remote cluster.
+
+
,
+
+
+
+
+
+
+
+
+
+
+
+
+
,
+
+
+
+
+ Fix errors before continuing.
+
+
+
,
+]
+`;
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/remote_cluster_form.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/remote_cluster_form.js
index dee024944d33a..e927d6b4b2b48 100644
--- a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/remote_cluster_form.js
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/remote_cluster_form.js
@@ -32,11 +32,10 @@ import {
} from '@elastic/eui';
import {
- isSeedNodeValid,
- isSeedNodePortValid,
-} from '../../../services';
-
-import { skippingDisconnectedClustersUrl } from '../../../services/documentation_links';
+ skippingDisconnectedClustersUrl,
+ transportPortUrl,
+} from '../../../services/documentation_links';
+import { validateName, validateSeeds, validateSeed } from './validators';
const defaultFields = {
name: '',
@@ -78,39 +77,10 @@ export const RemoteClusterForm = injectI18n(
getFieldsErrors(fields, seedInput = '') {
const { name, seeds } = fields;
- const errors = {};
-
- if (!name || !name.trim()) {
- errors.name = (
-
- );
- } else if (name.match(/[^a-zA-Z\d\-_]/)) {
- errors.name = (
-
- );
- }
-
- if (!seeds.some(seed => Boolean(seed.trim()))) {
- // If the user hasn't entered any seeds then we only want to prompt them for some if they
- // aren't already in the process of entering one in. In this case, we'll just show the
- // combobox-specific validation.
- if (!seedInput) {
- errors.seeds = (
-
- );
- }
- }
-
- return errors;
+ return {
+ name: validateName(name),
+ seeds: validateSeeds(seeds, seedInput),
+ };
}
onFieldsChange = (changedFields) => {
@@ -156,44 +126,13 @@ export const RemoteClusterForm = injectI18n(
save(cluster);
};
- getLocalSeedErrors = (seedNode) => {
- const { intl } = this.props;
-
- const errors = [];
-
- if (!seedNode) {
- return errors;
- }
-
- const isInvalid = !isSeedNodeValid(seedNode);
-
- if (isInvalid) {
- errors.push(intl.formatMessage({
- id: 'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage',
- defaultMessage: `Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400.
- Hosts can only consist of letters, numbers, and dashes.`,
- }));
- }
-
- const isPortInvalid = !isSeedNodePortValid(seedNode);
-
- if (isPortInvalid) {
- errors.push(intl.formatMessage({
- id: 'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage',
- defaultMessage: 'A port is required.',
- }));
- }
-
- return errors;
- };
-
onCreateSeed = (newSeed) => {
// If the user just hit enter without typing anything, treat it as a no-op.
if (!newSeed) {
return;
}
- const localSeedErrors = this.getLocalSeedErrors(newSeed);
+ const localSeedErrors = validateSeed(newSeed);
if (localSeedErrors.length !== 0) {
this.setState({
@@ -228,7 +167,7 @@ export const RemoteClusterForm = injectI18n(
const { seeds } = fields;
// Allow typing to clear the errors, but not to add new ones.
- const errors = (!seedInput || this.getLocalSeedErrors(seedInput).length === 0) ? [] : localSeedErrors;
+ const errors = (!seedInput || validateSeed(seedInput).length === 0) ? [] : localSeedErrors;
// EuiComboBox internally checks for duplicates and prevents calling onCreateOption if the
// input is a duplicate. So we need to surface this error here instead.
@@ -267,7 +206,7 @@ export const RemoteClusterForm = injectI18n(
hasErrors = () => {
const { fieldsErrors, localSeedErrors } = this.state;
const errorValues = Object.values(fieldsErrors);
- const hasErrors = errorValues.some(error => error !== undefined) || localSeedErrors.length;
+ const hasErrors = errorValues.some(error => error != null) || localSeedErrors.length;
return hasErrors;
};
@@ -318,6 +257,7 @@ export const RemoteClusterForm = injectI18n(
fullWidth
>
+
+
+ ),
+ }}
/>
)}
isInvalid={showErrors}
@@ -404,6 +354,7 @@ export const RemoteClusterForm = injectI18n(
fullWidth
>
() => 'mockId');
+
+describe('RemoteClusterForm', () => {
+ test(`renders untouched state`, () => {
+ const component = renderWithIntl(
+ {}}
+ />
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ describe('validation', () => {
+ test('renders invalid state and a global form error when the user tries to submit an invalid form', () => {
+ const component = mountWithIntl(
+ {}}/>
+ );
+
+ findTestSubject(component, 'remoteClusterFormSaveButton').simulate('click');
+
+ const fieldsSnapshot = [
+ 'remoteClusterFormNameFormRow',
+ 'remoteClusterFormSeedNodesFormRow',
+ 'remoteClusterFormSkipUnavailableFormRow',
+ 'remoteClusterFormGlobalError',
+ ].map(testSubject => {
+ const mountedField = findTestSubject(component, testSubject);
+ return takeMountedSnapshot(mountedField);
+ });
+
+ expect(fieldsSnapshot).toMatchSnapshot();
+ });
+ });
+});
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_name.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_name.test.js.snap
new file mode 100644
index 0000000000000..520b78329a976
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_name.test.js.snap
@@ -0,0 +1,161 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`validateName rejects empty input ' ' 1`] = `
+
+`;
+
+exports[`validateName rejects empty input 'null' 1`] = `
+
+`;
+
+exports[`validateName rejects empty input 'undefined' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters ' ' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '!' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '#' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '$' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '%' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '&' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '(' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters ')' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '*' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '+' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters ',' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '.' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '<' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '>' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '?' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '@' 1`] = `
+
+`;
+
+exports[`validateName rejects invalid characters '^' 1`] = `
+
+`;
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_seeds.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_seeds.test.js.snap
new file mode 100644
index 0000000000000..29eaa7105c8dc
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_seeds.test.js.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`validateSeeds rejects empty seeds when there's no input 1`] = `
+
+`;
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/index.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/index.js
new file mode 100644
index 0000000000000..66a1016c7fcc8
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/index.js
@@ -0,0 +1,9 @@
+/*
+ * 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 { validateName } from './validate_name';
+export { validateSeed } from './validate_seed';
+export { validateSeeds } from './validate_seeds';
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.js
new file mode 100644
index 0000000000000..3c7d4615bc3ad
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.js
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+export function validateName(name) {
+ if (name == null || !name.trim()) {
+ return (
+
+ );
+ }
+
+ if (name.match(/[^a-zA-Z\d\-_]/)) {
+ return (
+
+ );
+ }
+
+ return null;
+}
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.test.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.test.js
new file mode 100644
index 0000000000000..f551968bb5fd8
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.test.js
@@ -0,0 +1,25 @@
+/*
+ * 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 { validateName } from './validate_name';
+
+describe('validateName', () => {
+ describe('rejects empty input', () => {
+ [' ', undefined, null].forEach(input => {
+ test(`'${input}'`, () => {
+ expect(validateName(input)).toMatchSnapshot();
+ });
+ });
+ });
+
+ describe('rejects invalid characters', () => {
+ '!@#$%^&*()+?<> ,.'.split('').forEach(input => {
+ test(`'${input}'`, () => {
+ expect(validateName(input)).toMatchSnapshot();
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.js
new file mode 100644
index 0000000000000..fda426f86874f
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.js
@@ -0,0 +1,45 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+import {
+ isSeedNodeValid,
+ isSeedNodePortValid,
+} from '../../../../services';
+
+export function validateSeed(seed) {
+ const errors = [];
+
+ if (!seed) {
+ return errors;
+ }
+
+ const isValid = isSeedNodeValid(seed);
+
+ if (!isValid) {
+ errors.push(i18n.translate(
+ 'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage',
+ {
+ defaultMessage: `Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400.
+ Hosts can only consist of letters, numbers, and dashes.`,
+ },
+ ));
+ }
+
+ const isPortValid = isSeedNodePortValid(seed);
+
+ if (!isPortValid) {
+ errors.push(i18n.translate(
+ 'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage',
+ {
+ defaultMessage: 'A port is required.',
+ },
+ ));
+ }
+
+ return errors;
+}
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.test.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.test.js
new file mode 100644
index 0000000000000..d648202f8d6a6
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.test.js
@@ -0,0 +1,24 @@
+/*
+ * 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 { validateSeed } from './validate_seed';
+
+describe('validateSeeds', () => {
+ test(`rejects invalid seeds and invalid ports`, () => {
+ const errorsCount = validateSeed('&').length;
+ expect(errorsCount).toBe(2);
+ });
+
+ test(`accepts no seed`, () => {
+ const errorsCount = validateSeed('').length;
+ expect(errorsCount).toBe(0);
+ });
+
+ test(`accepts a valid seed with a valid port`, () => {
+ const errorsCount = validateSeed('seed:10').length;
+ expect(errorsCount).toBe(0);
+ });
+});
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.js
new file mode 100644
index 0000000000000..4fca4bf6e84e1
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.js
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import { FormattedMessage } from '@kbn/i18n/react';
+
+export function validateSeeds(seeds, seedInput) {
+ const seedsHaveBeenCreated = seeds.some(seed => Boolean(seed.trim()));
+
+ if (seedsHaveBeenCreated) {
+ return null;
+ }
+
+ // If the user hasn't entered any seeds then we only want to prompt them for some if they
+ // aren't already in the process of entering one in. In this case, we'll just show the
+ // combobox-specific validation.
+ if (seedInput) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.test.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.test.js
new file mode 100644
index 0000000000000..9f9814ee21407
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.test.js
@@ -0,0 +1,21 @@
+/*
+ * 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 { validateSeeds } from './validate_seeds';
+
+describe('validateSeeds', () => {
+ test(`rejects empty seeds when there's no input`, () => {
+ expect(validateSeeds([], '')).toMatchSnapshot();
+ });
+
+ test(`accepts empty seeds when there's input`, () => {
+ expect(validateSeeds([], 'input')).toBe(null);
+ });
+
+ test(`accepts existing seeds`, () => {
+ expect(validateSeeds(['seed'])).toBe(null);
+ });
+});
diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js
index 4187bfdd82fba..69684ec234f1f 100644
--- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js
+++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js
@@ -4,20 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { Component, Fragment } from 'react';
+import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import chrome from 'ui/chrome';
import { MANAGEMENT_BREADCRUMB } from 'ui/management';
import {
- EuiPage,
- EuiPageBody,
EuiPageContent,
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH } from '../../constants';
-import { listBreadcrumb, addBreadcrumb } from '../../services';
+import { listBreadcrumb, addBreadcrumb, getRouter, redirect, extractQueryParams } from '../../services';
import { RemoteClusterPageTitle, RemoteClusterForm } from '../components';
export const RemoteClusterAdd = injectI18n(
@@ -44,40 +42,41 @@ export const RemoteClusterAdd = injectI18n(
};
cancel = () => {
- const { history } = this.props;
- history.push(CRUD_APP_BASE_PATH);
+ const { history, route: { location: { search } } } = getRouter();
+ const { redirect: redirectUrl } = extractQueryParams(search);
+
+ if (redirectUrl) {
+ const decodedRedirect = decodeURIComponent(redirectUrl);
+ redirect(decodedRedirect);
+ } else {
+ history.push(CRUD_APP_BASE_PATH);
+ }
};
render() {
const { isAddingCluster, addClusterError } = this.props;
return (
-
-
-
-
-
- )}
- />
+
+
+ )}
+ />
-
-
-
-
-
+
+
);
}
}
diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js
index b0b3f65e5c99a..c4c40374b42b2 100644
--- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js
+++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js
@@ -12,12 +12,10 @@ import { MANAGEMENT_BREADCRUMB } from 'ui/management';
import {
EuiButtonEmpty,
+ EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
- EuiIcon,
EuiLoadingSpinner,
- EuiPage,
- EuiPageBody,
EuiPageContent,
EuiSpacer,
EuiText,
@@ -25,7 +23,7 @@ import {
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH } from '../../constants';
-import { buildListBreadcrumb, editBreadcrumb } from '../../services';
+import { buildListBreadcrumb, editBreadcrumb, extractQueryParams, getRouter, getRouterLinkProps, redirect } from '../../services';
import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components';
const disabledFields = {
@@ -85,13 +83,25 @@ export const RemoteClusterEdit = injectI18n(
};
cancel = () => {
- const { history, openDetailPanel } = this.props;
+ const { openDetailPanel } = this.props;
const { clusterName } = this.state;
- history.push(CRUD_APP_BASE_PATH);
- openDetailPanel(clusterName);
+ const { history, route: { location: { search } } } = getRouter();
+ const { redirect: redirectUrl } = extractQueryParams(search);
+
+ if (redirectUrl) {
+ const decodedRedirect = decodeURIComponent(redirectUrl);
+ redirect(decodedRedirect);
+ } else {
+ history.push(CRUD_APP_BASE_PATH);
+ openDetailPanel(clusterName);
+ }
};
renderContent() {
+ const {
+ clusterName,
+ } = this.state;
+
const {
isLoading,
cluster,
@@ -126,26 +136,39 @@ export const RemoteClusterEdit = injectI18n(
if (!cluster) {
return (
-
-
-
-
-
-
-
-
+
+
+ )}
+ color="danger"
+ iconType="alert"
+ >
+
+
+
+
+
+
-
-
-
-
+
+
+
+
);
}
@@ -184,33 +207,22 @@ export const RemoteClusterEdit = injectI18n(
}
render() {
- const {
- clusterName,
- } = this.state;
-
return (
-
-
-
-
-
- )}
- />
+
+
+ )}
+ />
- {this.renderContent()}
-
-
-
-
+ {this.renderContent()}
+
);
}
}
diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/_index.scss b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/_index.scss
new file mode 100644
index 0000000000000..c85cb36c5dc5a
--- /dev/null
+++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/_index.scss
@@ -0,0 +1,6 @@
+/**
+ * 1. Prevent inherited flexbox layout from compressing this element on IE.
+ */
+ .remoteClustersConnectionStatus__message {
+ flex-basis: auto !important; /* 1 */
+}
diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/connection_status.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/connection_status.js
index d316267f435e0..71d9e671fdae8 100644
--- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/connection_status.js
+++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/connection_status.js
@@ -56,7 +56,7 @@ export function ConnectionStatus({ isConnected }) {
{icon}
-
+
{message}
diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js
index cce5edebf7ecf..fb73fd5bc9216 100644
--- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js
+++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js
@@ -80,6 +80,7 @@ export const RemoveClusterButtonProvider = injectI18n(
{ /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ }
+