[6.x] [CCR] Follower index CRUD (#27936) (#30148)

* [CCR] Follower index CRUD (#27936)

* [CCR] Refactor redux for Auto-follow pattern detail panel (#27491)

* [CCR] Refactor redux for Auto-follow pattern detail panel

* [CCR] Small refactor

* [CCR] Change to present tense

* [CCR] Display auto-follow pattern name even if it does not exist

* [CCR] Use href to edit auto-follow pattern + remove middelware to update "pattern" query params

* [CCR] Fix navigation back bug + set 2 ids for detail and edit an auto-follow pattern

* [CCR] Replace api middleware with redux-thunk action

* [CCR] Show detail footer close button even when cluster is not valid

* [CCR] Add endpoints for fetching and creating follower indices (#27646)

* Add GET /follower_indices endpoint with deserialization logic and tests.

* Add POST /follower_indices endpoint with serialization logic and tests.

* [CCR]  Add unit tests for RemoteClusterForm, RemoteClusterList, and RemoteClusterTable (#27647)

* Use componentDidUpdate instead of getDerivedStateFromProps.

* Add unit tests for RemoteClusterForm, RemoteClusterList, and RemoteClusterTable.

* Add jest mock for eui `makeId()` utility and get deterministic aria IDs for snapshots

* Update snapshot for Remote Cluster list test

* [CCR] Follower indices table and detail panel (#27804)

* Store for follower indices

* Initial work for follower indices table and detail panel

* Fix load auto-follow stats load as middleware was removed

* [CCR] Create follower index UI form (#27864)

* Initial setup Follower Index form

* Working form without client validation

* Add client side validation for follower index

* Add client validation to check if index already exist

* Improve error message when leader index does not exist

* Remove update method for follower index

* Clear api error on field change

* Fix i18n error

* Update snapshots

* [CCR] Add pause, resume, and unfollow actions for follower indices (#28305)

* Add pause and resume follower index routes

* Add unfollow route

* Add api methods for new routes

* Adjust routes to have bulk capabilities, add corresponding actions

* Refresh list after pausing/resuming, remove items after unfollowing

* First pass at UI for pause and unfollow (and resume, but that is not visible due to ES stats response)

* Handle additional conditions needed for unfollowing leader index, add placeholder code to deduce paused status

* PR feedback

* [CCR] Advanced settings UI for follower indices (#28267)

* Add client side validation of advanced settings form
* Move form entry row to separate component
* Add server side serialization for advanced settings
* Ignore advanced settings input when that section is hidden.
  - Cache and restore input when the section is shown again.

* [CCR] Show remote cluster validation in CCR forms, and add edit follower index (#28778)

* [CCR] Advanced settings component

* Remove preview active on toggle settings

* Add client side validation of advanced settings form

* Move form entry row to separate component

* Add title to panel

* Add i18n translation of advanced settings

* Update Follower index form with toggle for advanced settings

* Add server side serialisation for advanced settings

* Make code review changes

* Fix test: mock constant dependency

* Add section to edit follower index

* Show confirm modal before updating follower index

* Add edit icon in table + update server endpoint to pause / resume

* [CCR] Show remote cluster validation in follower index form & auto-follow pattern form

* PR feedback, cleanup form sizes, add redirect to edit remote cluster

* Fix routing, remove unused code, adjust auto follow pattern edit loading error page

* Adjust error messages and make remote cluster not found edit page the same

* Fix functionality as result of merge

* Fix validation, reorder actions, fix tests, and address feedback

* PR feedback and fix validation pt 2

* Adjust remote cluster validation

* Fix i18n

* Fix api error not showing on add follower form

* [CCR]  Integrate new follower index info ES API (#29153)

* Integrate new follower index info ES API

* Collate data from follower stats and info apis when retrieving all followers and single follower
* Add follower settings info to detail panel
* Add paused/active UI state
* Surface follower default settings to UI

* Adjust tests

* Address PR feedback

* Update snapshots

* [CCR] Surface license errors in-app and refine permissions error UI. (#29228)

* Fix camelcasing bug in XPackInfo.
* Silently swallow API error when checking for index name availability.
* Fix typo in followerIndexForm fatal error.
* Add permissions check before allowing user to access the app.

* Refine wording of CCR permission denied page, to specify cluster privileges. (#29533)

* [CCR] Improve form error handling and general UX (#29419)

* Remove unnecessary eslint disable-line

* [CCR] Implement Advanced Settings design feedback (#29543)

* Use EuiSwitch to toggle advanced settings in Create Follower Index form.
* Move 'optional' from each Advanced Setting field to the section heading.
* Change Advanced Settings switch label and description to emphasize that you can customize them or use the defaults.
* Prepopulate Advanced Settings fields with default values.
* When editing a follower index, check if advanced settings have been edited and open them if so.
* Add 'Reset to default' button below advanced settings fields if their values are different than the default.
* Remove 'Default' copy from Advanced Settings descriptions.
* Simplify toggleAdvancedSettings function, add comments, and fix React console error.

* [CCR] Follower index list fixes from design feedback (#29484)

* Delete remote cluster settings before updating

* Fix detail panel z-index

* Remove default descriptor from follower index detail panel setting values

* Follower index confirm action copy adjustments

* Change z-index styling to use EUI vars

* [CCR] Improve remote clusters test coverage (#29487)

* Add Jest test for RemoteClusterForm validation state.
* Extract validation functions out of RemoteClusterForm and add unit tests.
  - Return null instead of undefined from validators.
* Add unit tests for different types of remote clusters in RemoteClusterTable.
* Add unit test for RemoteClusterList empty prompt.
* Add tests verifying behavior for row link, row delete button, and detail panel delete button.
  - Use getRouterLinkProps to assign onClick and href to edit buttons in row and detail panel.

* [CCR] Adjust spacing around descriptions in list views, link to transport port docs, etc (#29789)

* Adjust spacing around description around descriptions in list views so that it's even on top and bottom.
* Add link to transport port docs from Remote Cluster form.
* Move 'View in Index Management' link from the detail panel body into the footer.

* Re-order follower index form sections: remote cluster, leader index, follower index. (#29885)

* Fix deep-linking to follower index after creating/updating it. (#29865)

* [CCR] Copy edits (#29676)

* Use 'Resume/pause data replication' in context menu and row actions.
* Update copy of 'Update' confirm modal for a paused follower index.
* Update copy of 'Update' confirm modal for an active follower index.
* Update copy of 'Pause data replication' confirm modal.
* Update copy of 'Resume data replication' confirm modal.
* Update copy for permissions check.
* Update copy of table empty state.
* Update copy around tables.
* Update form copy.
* Update copy for RemoteClustersFormField callouts.
* Convert 'data replication' -> 'replication'.
* Update copy for Unfollow confirm modal.
* Update copy for form API error and Auto-follow Patterns table.
* Update form save button labels to be 'Create' and 'Update'.
* Move API errors to bottom of form, into same position as sync validation errors. Remove spacer from SectionError implementation.

* [CCR] Open index after unfollowing leader (#29887)

* Open index after unfollowing leader, fix some variable names

* Fix typo

* Add comment

* [CCR] IE and Screen reader accesibility (#29731)

* Fix api endpoit for auto-follow stats

* Prevent letter wrapping in IE for the Remote cluster "connection" table column

* Move inline style to CSS class to fix IE flex bug

* [CCR] Add callout to paused follower index detail panel (#30013)

* Add callout to paused follower index detail panel

* Update copy

* Skip call to ccr stats api if follower index is paused (#30027)

* [CCR] Add integration tests for follower indices (#30064)

* [CCR] Add integration tests for follower indices

* Import advanced settings value from app constants

* Disable flaky follower indices API integration tests.
This commit is contained in:
Jen Huang 2019-02-05 18:47:51 -08:00 committed by GitHub
parent cde31bfe42
commit 85572227f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
129 changed files with 8872 additions and 1220 deletions

View file

@ -19,4 +19,9 @@
import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/index_patterns';
export const INDEX_ILLEGAL_CHARACTERS_VISIBLE = INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.concat(',');
export const INDEX_ILLEGAL_CHARACTERS_VISIBLE = [ ...INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE ];
// Insert the comma into the middle, so it doesn't look as if it has grammatical meaning when
// these characters are rendered in the UI.
const insertionIndex = Math.floor(INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.length / 2);
INDEX_ILLEGAL_CHARACTERS_VISIBLE.splice(insertionIndex, 0, ',');

View file

@ -8,3 +8,4 @@ export const BASE_PATH = '/management/elasticsearch/cross_cluster_replication';
export const BASE_PATH_REMOTE_CLUSTERS = '/management/elasticsearch/remote_clusters';
export const API_BASE_PATH = '/api/cross_cluster_replication';
export const API_REMOTE_CLUSTERS_BASE_PATH = '/api/remote_clusters';
export const API_INDEX_MANAGEMENT_BASE_PATH = '/api/index_management';

View file

@ -7,3 +7,4 @@
export * from './plugin';
export * from './base_path';
export * from './app';
export * from './settings';

View file

@ -0,0 +1,18 @@
/*
* 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 const FOLLOWER_INDEX_ADVANCED_SETTINGS = {
maxReadRequestOperationCount: 5120,
maxOutstandingReadRequests: 12,
maxReadRequestSize: '32mb',
maxWriteRequestOperationCount: 5120,
maxWriteRequestSize: '9223372036854775807b',
maxOutstandingWriteRequests: 9,
maxWriteBufferCount: 2147483647,
maxWriteBufferSize: '512mb',
maxRetryDelay: '500ms',
readPollTimeout: '1m',
};

View file

@ -15,3 +15,15 @@ export const wait = (time = 1000) => (data) => {
setTimeout(() => resolve(data), time);
});
};
/**
* Utility to remove empty fields ("") from a request body
*/
export const removeEmptyFields = (body) => (
Object.entries(body).reduce((acc, [key, value]) => {
if (value !== '') {
acc[key] = value;
}
return acc;
}, {})
);

View file

@ -0,0 +1,159 @@
/*
* 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 Chance = require('chance'); // eslint-disable-line import/no-extraneous-dependencies
const chance = new Chance();
export const getFollowerIndexStatsMock = (
name = chance.string(),
shards = [{
id: chance.string(),
remoteCluster: chance.string(),
leaderIndex: chance.string(),
leaderGlobalCheckpoint: chance.integer(),
leaderMaxSequenceNum: chance.integer(),
followerGlobalCheckpoint: chance.integer(),
followerMaxSequenceNum: chance.integer(),
lastRequestedSequenceNum: chance.integer(),
outstandingReadRequestsCount: chance.integer(),
outstandingWriteRequestsCount: chance.integer(),
writeBufferOperationsCount: chance.integer(),
writeBufferSizeBytes: chance.integer(),
followerMappingVersion: chance.integer(),
followerSettingsVersion: chance.integer(),
totalReadTimeMs: chance.integer(),
totalReadRemoteExecTimeMs: chance.integer(),
successfulReadRequestCount: chance.integer(),
failedReadRequestsCount: chance.integer(),
operationsReadCount: chance.integer(),
bytesReadCount: chance.integer(),
totalWriteTimeMs: chance.integer(),
successfulWriteRequestsCount: chance.integer(),
failedWriteRequestsCount: chance.integer(),
operationsWrittenCount: chance.integer(),
readExceptions: [ chance.string() ],
timeSinceLastReadMs: chance.integer(),
}]
) => {
const serializeShard = ({
id,
remoteCluster,
leaderIndex,
leaderGlobalCheckpoint,
leaderMaxSequenceNum,
followerGlobalCheckpoint,
followerMaxSequenceNum,
lastRequestedSequenceNum,
outstandingReadRequestsCount,
outstandingWriteRequestsCount,
writeBufferOperationsCount,
writeBufferSizeBytes,
followerMappingVersion,
followerSettingsVersion,
totalReadTimeMs,
totalReadRemoteExecTimeMs,
successfulReadRequestCount,
failedReadRequestsCount,
operationsReadCount,
bytesReadCount,
totalWriteTimeMs,
successfulWriteRequestsCount,
failedWriteRequestsCount,
operationsWrittenCount,
readExceptions,
timeSinceLastReadMs,
}) => ({
shard_id: id,
remote_cluster: remoteCluster,
leader_index: leaderIndex,
leader_global_checkpoint: leaderGlobalCheckpoint,
leader_max_seq_no: leaderMaxSequenceNum,
follower_global_checkpoint: followerGlobalCheckpoint,
follower_max_seq_no: followerMaxSequenceNum,
last_requested_seq_no: lastRequestedSequenceNum,
outstanding_read_requests: outstandingReadRequestsCount,
outstanding_write_requests: outstandingWriteRequestsCount,
write_buffer_operation_count: writeBufferOperationsCount,
write_buffer_size_in_bytes: writeBufferSizeBytes,
follower_mapping_version: followerMappingVersion,
follower_settings_version: followerSettingsVersion,
total_read_time_millis: totalReadTimeMs,
total_read_remote_exec_time_millis: totalReadRemoteExecTimeMs,
successful_read_requests: successfulReadRequestCount,
failed_read_requests: failedReadRequestsCount,
operations_read: operationsReadCount,
bytes_read: bytesReadCount,
total_write_time_millis: totalWriteTimeMs,
successful_write_requests: successfulWriteRequestsCount,
failed_write_requests: failedWriteRequestsCount,
operations_written: operationsWrittenCount,
read_exceptions: readExceptions,
time_since_last_read_millis: timeSinceLastReadMs,
});
return {
index: name,
shards: shards.map(serializeShard),
};
};
export const getFollowerIndexListStatsMock = (total = 3, names) => {
const list = {
follow_stats: {
indices: [],
},
};
for(let i = 0; i < total; i++) {
list.follow_stats.indices.push(getFollowerIndexStatsMock(names[i]));
}
return list;
};
export const getFollowerIndexInfoMock = (
name = chance.string(),
status = chance.string(),
parameters = {
maxReadRequestOperationCount: chance.string(),
maxOutstandingReadRequests: chance.string(),
maxReadRequestSize: chance.string(),
maxWriteRequestOperationCount: chance.string(),
maxWriteRequestSize: chance.string(),
maxOutstandingWriteRequests: chance.string(),
maxWriteBufferCount: chance.string(),
maxWriteBufferSize: chance.string(),
maxRetryDelay: chance.string(),
readPollTimeout: chance.string(),
}
) => {
return {
follower_index: name,
status,
max_read_request_operation_count: parameters.maxReadRequestOperationCount,
max_outstanding_read_requests: parameters.maxOutstandingReadRequests,
max_read_request_size: parameters.maxReadRequestSize,
max_write_request_operation_count: parameters.maxWriteRequestOperationCount,
max_write_request_size: parameters.maxWriteRequestSize,
max_outstanding_write_requests: parameters.maxOutstandingWriteRequests,
max_write_buffer_count: parameters.maxWriteBufferCount,
max_write_buffer_size: parameters.maxWriteBufferSize,
max_retry_delay: parameters.maxRetryDelay,
read_poll_timeout: parameters.readPollTimeout,
};
};
export const getFollowerIndexListInfoMock = (total = 3) => {
const list = {
follower_indices: [],
};
for(let i = 0; i < total; i++) {
list.follower_indices.push(getFollowerIndexInfoMock());
}
return list;
};

View file

@ -10,3 +10,10 @@ export {
} from './auto_follow_pattern';
export { esErrors } from './es_errors';
export {
getFollowerIndexStatsMock,
getFollowerIndexListStatsMock,
getFollowerIndexInfoMock,
getFollowerIndexListInfoMock,
} from './follower_index';

View file

@ -5,3 +5,10 @@
.ccrFollowerIndicesHelpText {
transform: translateY(-3px);
}
/**
* 1. Prevent context menu popover appearing above confirmation modal
*/
.ccrFollowerIndicesDetailPanel {
z-index: $euiZMask - 1; /* 1 */
}

View file

@ -4,58 +4,218 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { Route, Switch, Redirect } from 'react-router-dom';
import chrome from 'ui/chrome';
import { fatalError } from 'ui/notify';
import { i18n } from '@kbn/i18n';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiPageContent,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import routing from './services/routing';
import { BASE_PATH } from '../../common/constants';
import { SectionUnauthorized, SectionError } from './components';
import routing from './services/routing';
import { isAvailable, isActive, getReason } from './services/license';
import { loadPermissions } from './services/api';
import {
CrossClusterReplicationHome,
AutoFollowPatternAdd,
AutoFollowPatternEdit
AutoFollowPatternEdit,
FollowerIndexAdd,
FollowerIndexEdit,
} from './sections';
export class App extends Component {
static contextTypes = {
router: PropTypes.shape({
history: PropTypes.shape({
push: PropTypes.func.isRequired,
createHref: PropTypes.func.isRequired
export const App = injectI18n(
class extends Component {
static contextTypes = {
router: PropTypes.shape({
history: PropTypes.shape({
push: PropTypes.func.isRequired,
createHref: PropTypes.func.isRequired
}).isRequired
}).isRequired
}).isRequired
}
}
constructor(...args) {
super(...args);
this.registerRouter();
}
constructor(...args) {
super(...args);
this.registerRouter();
componentWillMount() {
routing.userHasLeftApp = false;
}
this.state = {
isFetchingPermissions: false,
fetchPermissionError: undefined,
hasPermission: false,
missingClusterPrivileges: [],
};
}
componentWillUnmount() {
routing.userHasLeftApp = true;
}
componentWillMount() {
routing.userHasLeftApp = false;
}
registerRouter() {
const { router } = this.context;
routing.reactRouter = router;
}
componentDidMount() {
this.checkPermissions();
}
render() {
return (
<div>
<Switch>
<Redirect exact from={`${BASE_PATH}`} to={`${BASE_PATH}/auto_follow_patterns`} />
<Route exact path={`${BASE_PATH}/auto_follow_patterns/add`} component={AutoFollowPatternAdd} />
<Route exact path={`${BASE_PATH}/auto_follow_patterns/edit/:id`} component={AutoFollowPatternEdit} />
<Route exact path={`${BASE_PATH}/:section`} component={CrossClusterReplicationHome} />
</Switch>
</div>
);
}
}
componentWillUnmount() {
routing.userHasLeftApp = true;
}
async checkPermissions() {
this.setState({
isFetchingPermissions: true,
});
try {
const { hasPermission, missingClusterPrivileges } = await loadPermissions();
this.setState({
isFetchingPermissions: false,
hasPermission,
missingClusterPrivileges,
});
} catch (error) {
// Expect an error in the shape provided by Angular's $http service.
if (error && error.data) {
return this.setState({
isFetchingPermissions: false,
fetchPermissionError: error,
});
}
// This error isn't an HTTP error, so let the fatal error screen tell the user something
// unexpected happened.
fatalError(error, i18n.translate('xpack.crossClusterReplication.app.checkPermissionsFatalErrorTitle', {
defaultMessage: 'Cross Cluster Replication app',
}));
}
}
registerRouter() {
const { router } = this.context;
routing.reactRouter = router;
}
render() {
const {
isFetchingPermissions,
fetchPermissionError,
hasPermission,
missingClusterPrivileges,
} = this.state;
if (!isAvailable() || !isActive()) {
return (
<SectionUnauthorized
title={(
<FormattedMessage
id="xpack.crossClusterReplication.app.licenseErrorTitle"
defaultMessage="License error"
/>
)}
>
{getReason()}
{' '}
<a href={chrome.addBasePath('/app/kibana#/management/elasticsearch/license_management/home')}>
<FormattedMessage
id="xpack.crossClusterReplication.app.licenseErrorLinkText"
defaultMessage="Manage your license."
/>
</a>
</SectionUnauthorized>
);
}
if (isFetchingPermissions) {
return (
<EuiPageContent horizontalPosition="center">
<EuiFlexGroup>
<EuiFlexItem>
<EuiLoadingSpinner size="xl"/>
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.crossClusterReplication.app.permissionCheckTitle"
defaultMessage="Checking permissions..."
/>
</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContent>
);
}
if (fetchPermissionError) {
return (
<Fragment>
<SectionError
title={(
<FormattedMessage
id="xpack.crossClusterReplication.app.permissionCheckErrorTitle"
defaultMessage="Error checking permissions"
/>
)}
error={fetchPermissionError}
/>
<EuiSpacer size="m" />
</Fragment>
);
}
if (!hasPermission) {
return (
<EuiPageContent horizontalPosition="center">
<EuiEmptyPrompt
iconType="securityApp"
iconColor={null}
title={
<h2>
<FormattedMessage
id="xpack.crossClusterReplication.app.deniedPermissionTitle"
defaultMessage="You're missing cluster privileges"
/>
</h2>}
body={
<p>
<FormattedMessage
id="xpack.crossClusterReplication.app.deniedPermissionDescription"
defaultMessage="To use Cross Cluster Replication, you must have {clusterPrivileges,
plural, one {this cluster privilege} other {these cluster privileges}}: {clusterPrivileges}."
values={{ clusterPrivileges: missingClusterPrivileges.join(', ') }}
/>
</p>}
/>
</EuiPageContent>
);
}
return (
<div>
<Switch>
<Redirect exact from={`${BASE_PATH}`} to={`${BASE_PATH}/follower_indices`} />
<Route exact path={`${BASE_PATH}/auto_follow_patterns/add`} component={AutoFollowPatternAdd} />
<Route exact path={`${BASE_PATH}/auto_follow_patterns/edit/:id`} component={AutoFollowPatternEdit} />
<Route exact path={`${BASE_PATH}/follower_indices/add`} component={FollowerIndexAdd} />
<Route exact path={`${BASE_PATH}/follower_indices/edit/:id`} component={FollowerIndexEdit} />
<Route exact path={`${BASE_PATH}/:section`} component={CrossClusterReplicationHome} />
</Switch>
</div>
);
}
}
);

View file

@ -26,32 +26,26 @@ import {
EuiSpacer,
EuiText,
EuiTitle,
EuiSuperSelect,
} from '@elastic/eui';
import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/index_patterns';
import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices';
import routing from '../services/routing';
import { extractQueryParams } from '../services/query_params';
import { getRemoteClusterName } from '../services/get_remote_cluster_name';
import { API_STATUS } from '../constants';
import { SectionError, AutoFollowPatternIndicesPreview } from './';
import { SectionError } from './section_error';
import { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview';
import { RemoteClustersFormField } from './remote_clusters_form_field';
import { validateAutoFollowPattern, validateLeaderIndexPattern } from '../services/auto_follow_pattern_validators';
const indexPatternIllegalCharacters = INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.join(' ');
const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' ');
const getFirstConnectedCluster = (clusters) => {
for (let i = 0; i < clusters.length; i++) {
if (clusters[i].isConnected) {
return clusters[i];
}
}
return {};
};
const getEmptyAutoFollowPattern = (remoteClusters) => ({
const getEmptyAutoFollowPattern = (remoteClusterName = '') => ({
name: '',
remoteCluster: getFirstConnectedCluster(remoteClusters).name,
remoteCluster: remoteClusterName,
leaderIndexPatterns: [],
followIndexPatternPrefix: '',
followIndexPatternSuffix: '',
@ -70,16 +64,20 @@ export class AutoFollowPatternFormUI extends PureComponent {
autoFollowPattern: PropTypes.object,
apiError: PropTypes.object,
apiStatus: PropTypes.string.isRequired,
remoteClusters: PropTypes.array.isRequired,
currentUrl: PropTypes.string.isRequired,
remoteClusters: PropTypes.array,
saveButtonLabel: PropTypes.node,
}
constructor(props) {
super(props);
const isNew = this.props.autoFollowPattern === undefined;
const { route: { location: { search } } } = routing.reactRouter;
const queryParams = extractQueryParams(search);
const remoteClusterName = getRemoteClusterName(this.props.remoteClusters, queryParams.cluster);
const autoFollowPattern = isNew
? getEmptyAutoFollowPattern(this.props.remoteClusters)
? getEmptyAutoFollowPattern(remoteClusterName)
: {
...this.props.autoFollowPattern,
};
@ -101,9 +99,11 @@ export class AutoFollowPatternFormUI extends PureComponent {
}));
const errors = validateAutoFollowPattern(fields);
this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors));
this.onFieldsErrorChange(errors);
};
onFieldsErrorChange = (errors) => this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors));
onClusterChange = (remoteCluster) => {
this.onFieldsChange({ remoteCluster });
};
@ -169,8 +169,8 @@ export class AutoFollowPatternFormUI extends PureComponent {
this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors));
} else {
this.setState(({ fieldsErrors, autoFollowPattern }) => {
const errors = validateAutoFollowPattern(autoFollowPattern);
this.setState(({ fieldsErrors, autoFollowPattern: { leaderIndexPatterns } }) => {
const errors = validateAutoFollowPattern({ leaderIndexPatterns });
return updateFormErrors(errors, fieldsErrors);
});
}
@ -187,7 +187,7 @@ export class AutoFollowPatternFormUI extends PureComponent {
};
isFormValid() {
return Object.values(this.state.fieldsErrors).every(error => error === null);
return Object.values(this.state.fieldsErrors).every(error => error === undefined || error === null);
}
sendForm = () => {
@ -217,9 +217,15 @@ export class AutoFollowPatternFormUI extends PureComponent {
if (apiError) {
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternForm.savingErrorTitle',
defaultMessage: 'Error creating auto-follow pattern',
defaultMessage: `Can't create auto-follow pattern`,
});
return <SectionError title={title} error={apiError} />;
return (
<Fragment>
<SectionError title={title} error={apiError} />
<EuiSpacer size="l" />
</Fragment>
);
}
return null;
@ -293,12 +299,39 @@ export class AutoFollowPatternFormUI extends PureComponent {
* Remote Cluster
*/
const renderRemoteClusterField = () => {
const remoteClustersOptions = this.props.remoteClusters.map(({ name, isConnected }) => ({
value: name,
inputDisplay: isConnected ? name : `${name} (not connected)`,
disabled: !isConnected,
'data-test-subj': `option-${name}`
}));
const { remoteClusters, currentUrl } = this.props;
const errorMessages = {
noClusterFound: () => (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.emptyRemoteClustersCallOutDescription"
defaultMessage="Auto-follow patterns capture indices on remote clusters."
/>
),
remoteClusterNotConnectedNotEditable: (name) => ({
title: (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.currentRemoteClusterNotConnectedCallOutTitle"
defaultMessage="Can't edit auto-follow pattern because remote cluster '{name}' is not connected"
values={{ name }}
/>
),
description: (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.currentRemoteClusterNotConnectedCallOutDescription"
defaultMessage="You can address this by editing the remote cluster."
/>
),
}),
remoteClusterDoesNotExist: (name) => (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.currentRemoteClusterNotFoundCallOutDescription"
defaultMessage="To edit this auto-follow pattern, you must add a remote cluster
named '{name}'."
values={{ name }}
/>
),
};
return (
<EuiDescribedFormGroup
@ -320,32 +353,16 @@ export class AutoFollowPatternFormUI extends PureComponent {
)}
fullWidth
>
<EuiFormRow
label={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.remoteCluster.fieldClusterLabel"
defaultMessage="Remote cluster"
/>
)}
fullWidth
>
<Fragment>
{ isNew && (
<EuiSuperSelect
options={remoteClustersOptions}
valueOfSelected={remoteCluster}
onChange={this.onClusterChange}
/>
)}
{ !isNew && (
<EuiFieldText
value={remoteCluster}
fullWidth
disabled={true}
/>
)}
</Fragment>
</EuiFormRow>
<RemoteClustersFormField
selected={remoteCluster ? remoteCluster : null}
remoteClusters={remoteClusters}
currentUrl={currentUrl}
isEditable={isNew}
areErrorsVisible={areErrorsVisible}
onChange={this.onClusterChange}
onError={(error) => this.onFieldsErrorChange({ remoteCluster: error })}
errorMessages={errorMessages}
/>
</EuiDescribedFormGroup>
);
};
@ -384,7 +401,7 @@ export class AutoFollowPatternFormUI extends PureComponent {
<p>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionLeaderIndexPatternsDescription2"
defaultMessage="{note} indices that already exist are not replicated."
defaultMessage="{note} Indices that already exist are not replicated."
values={{ note: (
<strong>
<FormattedMessage
@ -435,9 +452,9 @@ export class AutoFollowPatternFormUI extends PureComponent {
};
/**
* Auto-follow pattern
* Auto-follow pattern prefix/suffix
*/
const renderAutoFollowPattern = () => {
const renderAutoFollowPatternPrefixSuffix = () => {
const isPrefixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternPrefix;
const isSuffixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternSuffix;
@ -544,7 +561,6 @@ export class AutoFollowPatternFormUI extends PureComponent {
return (
<Fragment>
<EuiSpacer size="m" />
<EuiCallOut
title={(
<FormattedMessage
@ -555,6 +571,7 @@ export class AutoFollowPatternFormUI extends PureComponent {
color="danger"
iconType="cross"
/>
<EuiSpacer size="l" />
</Fragment>
);
};
@ -563,7 +580,7 @@ export class AutoFollowPatternFormUI extends PureComponent {
* Form Actions
*/
const renderActions = () => {
const { apiStatus } = this.props;
const { apiStatus, saveButtonLabel } = this.props;
const { areErrorsVisible } = this.state;
if (apiStatus === API_STATUS.SAVING) {
@ -597,10 +614,7 @@ export class AutoFollowPatternFormUI extends PureComponent {
fill
disabled={isSaveDisabled}
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.saveButtonLabel"
defaultMessage="Save"
/>
{saveButtonLabel}
</EuiButton>
</EuiFlexItem>
@ -625,10 +639,10 @@ export class AutoFollowPatternFormUI extends PureComponent {
{renderAutoFollowPatternName()}
{renderRemoteClusterField()}
{renderLeaderIndexPatterns()}
{renderAutoFollowPattern()}
{renderAutoFollowPatternPrefixSuffix()}
</EuiForm>
{renderFormErrorWarning()}
<EuiSpacer size="l" />
{this.renderApiErrors()}
{renderActions()}
</Fragment>
);
@ -650,7 +664,6 @@ export class AutoFollowPatternFormUI extends PureComponent {
render() {
return (
<Fragment>
{this.renderApiErrors()}
{this.renderForm()}
{this.renderLoading()}
</Fragment>

View file

@ -40,7 +40,7 @@ export const AutoFollowPatternPageTitle = ({ title }) => (
iconType="help"
>
<FormattedMessage
id="xpack.crossClusterReplication.readDocsButtonLabel"
id="xpack.crossClusterReplication.readDocsAutoFollowPatternButtonLabel"
defaultMessage="Auto-follow pattern docs"
/>
</EuiButtonEmpty>

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FollowerIndexForm /> state transitions updateFields() should merge new fields value with existing followerIndex 1`] = `
Object {
"followerIndex": Object {
"leaderIndex": "bar",
"name": "new-name",
},
}
`;
exports[`<FollowerIndexForm /> state transitions updateFormErrors() should merge errors with existing fieldsErrors 1`] = `
Object {
"fieldsErrors": Object {
"leaderIndex": null,
"name": "Some error",
},
}
`;

View file

@ -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 React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { byteUnitsUrl, timeUnitsUrl } from '../../services/documentation_links';
import { getSettingDefault } from '../../services/follower_index_default_settings';
const byteUnitsHelpText = (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.byteUnitsHelpText"
defaultMessage="Example values: 10b, 1024kb, 1mb, 5gb, 2tb, 1pb. {link}"
values={{ link: (
<a href={byteUnitsUrl} target="_blank">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.byteUnitsHelpTextLinkMessage"
defaultMessage="Learn more"
/>
</a>
) }}
/>
);
const timeUnitsHelpText = (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.timeUnitsHelpText"
defaultMessage="Example values: 2d, 24h, 20m, 30s, 500ms, 10000micros, 80000nanos. {link}"
values={{ link: (
<a href={timeUnitsUrl} target="_blank">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.timeUnitsHelpTextLinkMessage"
defaultMessage="Learn more"
/>
</a>
) }}
/>
);
export const advancedSettingsFields = [
{
field: 'maxReadRequestOperationCount',
title: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountTitle', {
defaultMessage: 'Max read request operation count'
}
),
description: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountDescription', {
defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.'
}
),
label: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountLabel', {
defaultMessage: 'Max read request operation count'
}
),
defaultValue: getSettingDefault('maxReadRequestOperationCount'),
type: 'number',
}, {
field: 'maxOutstandingReadRequests',
title: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle', {
defaultMessage: 'Max outstanding read requests'
}
),
description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', {
defaultMessage: 'The maximum number of outstanding read requests from the remote cluster.'
}),
label: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsLabel', {
defaultMessage: 'Max outstanding read requests'
}
),
defaultValue: getSettingDefault('maxOutstandingReadRequests'),
type: 'number',
}, {
field: 'maxReadRequestSize',
title: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle', {
defaultMessage: 'Max read request size'
}
),
description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', {
defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster.'
}),
label: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeLabel', {
defaultMessage: 'Max read request size'
}
),
defaultValue: getSettingDefault('maxReadRequestSize'),
helpText: byteUnitsHelpText,
}, {
field: 'maxWriteRequestOperationCount',
title: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle', {
defaultMessage: 'Max write request operation count'
}
),
description: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountDescription', {
defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.'
}
),
label: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountLabel', {
defaultMessage: 'Max write request operation count'
}
),
defaultValue: getSettingDefault('maxWriteRequestOperationCount'),
type: 'number',
}, {
field: 'maxWriteRequestSize',
title: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle', {
defaultMessage: 'Max write request size'
}
),
description: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeDescription', {
defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.'
}
),
label: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeLabel', {
defaultMessage: 'Max write request size'
}
),
defaultValue: getSettingDefault('maxWriteRequestSize'),
helpText: byteUnitsHelpText,
}, {
field: 'maxOutstandingWriteRequests',
title: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle', {
defaultMessage: 'Max outstanding write requests'
}
),
description: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsDescription', {
defaultMessage: 'The maximum number of outstanding write requests on the follower.'
}
),
label: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsLabel', {
defaultMessage: 'Max outstanding write requests'
}
),
defaultValue: getSettingDefault('maxOutstandingWriteRequests'),
type: 'number',
}, {
field: 'maxWriteBufferCount',
title: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle', {
defaultMessage: 'Max write buffer count'
}
),
description: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountDescription', {
defaultMessage: `The maximum number of operations that can be queued for writing; when this
limit is reached, reads from the remote cluster will be deferred until the number of queued
operations goes below the limit.`
}
),
label: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountLabel', {
defaultMessage: 'Max write buffer count'
}
),
defaultValue: getSettingDefault('maxWriteBufferCount'),
type: 'number',
}, {
field: 'maxWriteBufferSize',
title: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle', {
defaultMessage: 'Max write buffer size'
}
),
description: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeDescription', {
defaultMessage: `The maximum total bytes of operations that can be queued for writing; when
this limit is reached, reads from the remote cluster will be deferred until the total bytes
of queued operations goes below the limit.`
}
),
label: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeLabel', {
defaultMessage: 'Max write buffer size'
}
),
defaultValue: getSettingDefault('maxWriteBufferSize'),
helpText: byteUnitsHelpText,
}, {
field: 'maxRetryDelay',
title: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle', {
defaultMessage: 'Max retry delay'
}
),
description: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayDescription', {
defaultMessage: `The maximum time to wait before retrying an operation that failed exceptionally;
an exponential backoff strategy is employed when retrying.`
}
),
label: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayLabel', {
defaultMessage: 'Max retry delay'
}
),
defaultValue: getSettingDefault('maxRetryDelay'),
helpText: timeUnitsHelpText,
}, {
field: 'readPollTimeout',
title: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle', {
defaultMessage: 'Read poll timeout'
}
),
description: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutDescription', {
defaultMessage: `The maximum time to wait for new operations on the remote cluster when the
follower index is synchronized with the leader index; when the timeout has elapsed, the
poll for operations will return to the follower so that it can update some statistics, and
then the follower will immediately attempt to read from the leader again.`
}
),
label: i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutLabel', {
defaultMessage: 'Read poll timeout'
}
),
defaultValue: getSettingDefault('readPollTimeout'),
helpText: timeUnitsHelpText,
},
];
export const emptyAdvancedSettings = advancedSettingsFields.reduce((obj, advancedSetting) => {
const { field, defaultValue } = advancedSetting;
return { ...obj, [field]: defaultValue };
}, {});
export function areAdvancedSettingsEdited(followerIndex) {
return advancedSettingsFields.some(advancedSetting => {
const { field } = advancedSetting;
return followerIndex[field] !== emptyAdvancedSettings[field];
});
}

View file

@ -0,0 +1,719 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { debounce } from 'lodash';
import { i18n } from '@kbn/i18n';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices';
import { fatalError } from 'ui/notify';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiDescribedFormGroup,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiHorizontalRule,
EuiLoadingKibana,
EuiLoadingSpinner,
EuiOverlayMask,
EuiSpacer,
EuiSwitch,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { indexNameValidator, leaderIndexValidator } from '../../services/input_validation';
import routing from '../../services/routing';
import { loadIndices } from '../../services/api';
import { API_STATUS } from '../../constants';
import { SectionError } from '../section_error';
import { FormEntryRow } from '../form_entry_row';
import {
advancedSettingsFields,
emptyAdvancedSettings,
areAdvancedSettingsEdited,
} from './advanced_settings_fields';
import { extractQueryParams } from '../../services/query_params';
import { getRemoteClusterName } from '../../services/get_remote_cluster_name';
import { RemoteClustersFormField } from '../remote_clusters_form_field';
const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' ');
const fieldToValidatorMap = advancedSettingsFields.reduce((map, advancedSetting) => {
const { field, validator } = advancedSetting;
map[field] = validator;
return map;
}, {
'name': indexNameValidator,
'leaderIndex': leaderIndexValidator,
});
const getEmptyFollowerIndex = (remoteClusterName = '') => ({
name: '',
remoteCluster: remoteClusterName,
leaderIndex: '',
...emptyAdvancedSettings,
});
/**
* State transitions: fields update
*/
export const updateFields = (fields) => ({ followerIndex }) => ({
followerIndex: {
...followerIndex,
...fields,
},
});
/**
* State transitions: errors update
*/
export const updateFormErrors = (errors) => ({ fieldsErrors }) => ({
fieldsErrors: {
...fieldsErrors,
...errors,
}
});
export const FollowerIndexForm = injectI18n(
class extends PureComponent {
static propTypes = {
saveFollowerIndex: PropTypes.func.isRequired,
clearApiError: PropTypes.func.isRequired,
followerIndex: PropTypes.object,
apiError: PropTypes.object,
apiStatus: PropTypes.string.isRequired,
remoteClusters: PropTypes.array,
saveButtonLabel: PropTypes.node,
}
constructor(props) {
super(props);
const { route: { location: { search } } } = routing.reactRouter;
const queryParams = extractQueryParams(search);
const isNew = this.props.followerIndex === undefined;
const remoteClusterName = getRemoteClusterName(this.props.remoteClusters, queryParams.cluster);
const followerIndex = isNew
? getEmptyFollowerIndex(remoteClusterName)
: {
...getEmptyFollowerIndex(),
...this.props.followerIndex,
};
const areAdvancedSettingsVisible = isNew ? false : ( // eslint-disable-line no-nested-ternary
areAdvancedSettingsEdited(followerIndex) ? true : false
);
const fieldsErrors = this.getFieldsErrors(followerIndex);
this.state = {
isNew,
followerIndex,
fieldsErrors,
areErrorsVisible: false,
areAdvancedSettingsVisible,
isValidatingIndexName: false,
};
this.cachedAdvancedSettings = {};
this.validateIndexName = debounce(this.validateIndexName, 500);
}
onFieldsChange = (fields) => {
this.setState(updateFields(fields));
const newFields = {
...this.state.fields,
...fields,
};
this.setState(updateFormErrors(this.getFieldsErrors(newFields)));
if (this.props.apiError) {
this.props.clearApiError();
}
};
getFieldsErrors = (newFields) => {
return Object.keys(newFields).reduce((errors, field) => {
const validator = fieldToValidatorMap[field];
const value = newFields[field];
if (validator) {
const error = validator(value);
errors[field] = error;
}
return errors;
}, {});
};
onIndexNameChange = ({ name }) => {
this.onFieldsChange({ name });
if (!name || !name.trim()) {
this.setState({
isValidatingIndexName: false,
});
return;
}
this.setState({
isValidatingIndexName: true,
});
this.validateIndexName(name);
};
validateIndexName = async (name) => {
try {
const indices = await loadIndices();
const doesExist = indices.some(index => index.name === name);
if (doesExist) {
const error = {
message: (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.indexAlreadyExistError"
defaultMessage="An index with the same name already exists."
/>
),
alwaysVisible: true,
};
this.setState(updateFormErrors({ name: error }));
}
this.setState({
isValidatingIndexName: false,
});
} catch (error) {
// Expect an error in the shape provided by Angular's $http service.
if (error && error.data) {
// All validation does is check for a name collision, so we can just let the user attempt
// to save the follower index and get an error back from the API.
return this.setState({
isValidatingIndexName: false,
});
}
// This error isn't an HTTP error, so let the fatal error screen tell the user something
// unexpected happened.
fatalError(error, i18n.translate('xpack.crossClusterReplication.followerIndexForm.indexNameValidationFatalErrorTitle', {
defaultMessage: 'Follower Index Form index name validation',
}));
}
};
onClusterChange = (remoteCluster) => {
this.onFieldsChange({ remoteCluster });
};
getFields = () => {
return this.state.followerIndex;
};
toggleAdvancedSettings = (event) => {
// If the user edits the advanced settings but then hides them, we need to make sure the
// edited values don't get sent to the API when the user saves, but we *do* want to restore
// these values to the form when the user re-opens the advanced settings.
if (event.target.checked) {
// Apply the cached advanced settings to the advanced settings form.
this.onFieldsChange(this.cachedAdvancedSettings);
// Reset the cache of the advanced settings.
this.cachedAdvancedSettings = {};
// Show the advanced settings.
return this.setState({
areAdvancedSettingsVisible: true,
});
}
// Clear the advanced settings form.
this.onFieldsChange(emptyAdvancedSettings);
// Save a cache of the advanced settings.
const fields = this.getFields();
this.cachedAdvancedSettings = advancedSettingsFields.reduce((cache, { field }) => {
const value = fields[field];
if (value !== '') {
cache[field] = value;
}
return cache;
}, {});
// Hide the advanced settings.
this.setState({
areAdvancedSettingsVisible: false,
});
}
isFormValid() {
return Object.values(this.state.fieldsErrors).every(error => error === undefined || error === null);
}
sendForm = () => {
const isFormValid = this.isFormValid();
this.setState({ areErrorsVisible: !isFormValid });
if (!isFormValid) {
return;
}
const { name, ...followerIndex } = this.getFields();
this.props.saveFollowerIndex(name, followerIndex);
};
cancelForm = () => {
routing.navigate('/follower_indices');
};
/**
* Sections Renders
*/
renderApiErrors() {
const { apiError, intl } = this.props;
if (apiError) {
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexForm.savingErrorTitle',
defaultMessage: `Can't create follower index`,
});
const { leaderIndex } = this.state.followerIndex;
const error = apiError.status === 404
? {
data: {
message: intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexForm.leaderIndexNotFoundError',
defaultMessage: `The leader index '{leaderIndex}' does not exist.`,
}, { leaderIndex })
}
}
: apiError;
return (
<Fragment>
<SectionError title={title} error={error} />
<EuiSpacer size="l" />
</Fragment>
);
}
return null;
}
renderForm = () => {
const {
followerIndex,
isNew,
areErrorsVisible,
areAdvancedSettingsVisible,
fieldsErrors,
isValidatingIndexName,
} = this.state;
/**
* Follower index name
*/
const indexNameHelpText = (
<Fragment>
{isValidatingIndexName && (
<p>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.indexNameValidatingLabel"
defaultMessage="Checking availability..."
/>
</p>
)}
<p>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.indexNameHelpLabel"
defaultMessage="Spaces and the characters {characterList} are not allowed."
values={{ characterList: <strong>{indexNameIllegalCharacters}</strong> }}
/>
</p>
</Fragment>
);
const indexNameLabel = i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameTitle',
{
defaultMessage: 'Follower index'
}
);
const renderFollowerIndexName = () => (
<FormEntryRow
field="name"
value={followerIndex.name}
error={fieldsErrors.name}
title={(
<EuiTitle size="s">
<h2>{indexNameLabel}</h2>
</EuiTitle>
)}
label={indexNameLabel}
description={i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameDescription', {
defaultMessage: 'A unique name for your index.'
})}
helpText={indexNameHelpText}
isLoading={isValidatingIndexName}
disabled={!isNew}
areErrorsVisible={areErrorsVisible}
onValueUpdate={this.onIndexNameChange}
/>
);
/**
* Remote Cluster
*/
const renderRemoteClusterField = () => {
const { remoteClusters, currentUrl } = this.props;
const errorMessages = {
noClusterFound: () => (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.emptyRemoteClustersCallOutDescription"
defaultMessage="Replication requires a leader index on a remote cluster."
/>
),
remoteClusterNotConnectedNotEditable: (name) => ({
title: (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.currentRemoteClusterNotConnectedCallOutTitle"
defaultMessage="Can't edit follower index because remote cluster '{name}' is not connected"
values={{ name }}
/>
),
description: (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.currentRemoteClusterNotConnectedCallOutDescription"
defaultMessage="You can address this by editing the remote cluster."
/>
),
}),
remoteClusterDoesNotExist: (name) => (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.currentRemoteClusterNotFoundCallOutDescription"
defaultMessage="To edit this follower index, you must add a remote cluster
named '{name}'."
values={{ name }}
/>
),
};
return (
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h2>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.sectionRemoteClusterTitle"
defaultMessage="Remote cluster"
/>
</h2>
</EuiTitle>
)}
description={(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.sectionRemoteClusterDescription"
defaultMessage="The cluster that contains the index to replicate."
/>
)}
fullWidth
>
<RemoteClustersFormField
selected={followerIndex.remoteCluster ? followerIndex.remoteCluster : null}
remoteClusters={remoteClusters || []}
currentUrl={currentUrl}
isEditable={isNew}
areErrorsVisible={areErrorsVisible}
onChange={this.onClusterChange}
onError={(error) => {
this.setState(updateFormErrors({ remoteCluster: error }));
}}
errorMessages={errorMessages}
/>
</EuiDescribedFormGroup>
);
};
/**
* Leader index
*/
const leaderIndexLabel = i18n.translate(
'xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexTitle', {
defaultMessage: 'Leader index'
}
);
const renderLeaderIndex = () => (
<FormEntryRow
field="leaderIndex"
value={followerIndex.leaderIndex}
error={fieldsErrors.leaderIndex}
title={(
<EuiTitle size="s">
<h2>{leaderIndexLabel}</h2>
</EuiTitle>
)}
label={leaderIndexLabel}
description={(
<Fragment>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexDescription"
defaultMessage="The index on the remote cluster to replicate to the follower index."
/>
</p>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexDescription2"
defaultMessage="{note} The leader index must already exist."
values={{ note: (
<strong>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexDescription2.noteLabel"
defaultMessage="Note:"
/>
</strong>
) }}
/>
</p>
</Fragment>
)}
helpText={(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.indexNameHelpLabel"
defaultMessage="Spaces and the characters {characterList} are not allowed."
values={{ characterList: <strong>{indexNameIllegalCharacters}</strong> }}
/>
)}
disabled={!isNew}
areErrorsVisible={areErrorsVisible}
onValueUpdate={this.onFieldsChange}
/>
);
/**
* Advanced settings
*/
const renderAdvancedSettings = () => {
return (
<Fragment>
<EuiHorizontalRule />
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h2>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettingsTitle"
defaultMessage="Advanced settings (optional)"
/>
</h2>
</EuiTitle>
)}
description={(
<Fragment>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettingsDescription"
defaultMessage="Advanced settings control the rate of replication. You can
customize these settings or use the default values."
/>
</p>
<EuiSwitch
label={(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndex.advancedSettingsForm.showSwitchLabel"
defaultMessage="Customize advanced settings"
/>
)}
checked={areAdvancedSettingsVisible}
onChange={this.toggleAdvancedSettings}
/>
</Fragment>
)}
fullWidth
>
<Fragment /> {/* Avoid missing `children` warning */}
</EuiDescribedFormGroup>
{areAdvancedSettingsVisible && (
<Fragment>
<EuiSpacer size="s"/>
{advancedSettingsFields.map((advancedSetting) => {
const { field, title, description, label, helpText, defaultValue, type } = advancedSetting;
return (
<FormEntryRow
key={field}
field={field}
value={followerIndex[field]}
defaultValue={defaultValue}
error={fieldsErrors[field]}
title={(
<EuiTitle size="xs">
<h3>{title}</h3>
</EuiTitle>
)}
description={description}
label={label}
helpText={helpText}
type={type}
areErrorsVisible={areErrorsVisible}
onValueUpdate={this.onFieldsChange}
/>
);
})}
</Fragment>
)}
<EuiHorizontalRule />
</Fragment>
);
};
/**
* Form Error warning message
*/
const renderFormErrorWarning = () => {
const { areErrorsVisible } = this.state;
const isFormValid = this.isFormValid();
if (!areErrorsVisible || isFormValid) {
return null;
}
return (
<Fragment>
<EuiCallOut
title={(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.validationErrorTitle"
defaultMessage="Fix errors before continuing."
/>
)}
color="danger"
iconType="cross"
/>
<EuiSpacer size="l" />
</Fragment>
);
};
/**
* Form Actions
*/
const renderActions = () => {
const { apiStatus, saveButtonLabel } = this.props;
const { areErrorsVisible } = this.state;
if (apiStatus === API_STATUS.SAVING) {
return (
<EuiFlexGroup justifyContent="flexStart" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="l"/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.actions.savingText"
defaultMessage="Saving"
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
const isSaveDisabled = areErrorsVisible && !this.isFormValid();
return (
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButton
color="secondary"
iconType="check"
onClick={this.sendForm}
fill
disabled={isSaveDisabled}
>
{saveButtonLabel}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="primary"
onClick={this.cancelForm}
>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
);
};
return (
<Fragment>
<EuiForm>
{renderRemoteClusterField()}
{renderLeaderIndex()}
{renderFollowerIndexName()}
<EuiSpacer size="s" />
{renderAdvancedSettings()}
</EuiForm>
{renderFormErrorWarning()}
{this.renderApiErrors()}
{renderActions()}
</Fragment>
);
}
renderLoading = () => {
const { apiStatus } = this.props;
if (apiStatus === API_STATUS.SAVING) {
return (
<EuiOverlayMask>
<EuiLoadingKibana size="xl"/>
</EuiOverlayMask>
);
}
return null;
}
render() {
return (
<Fragment>
{this.renderForm()}
{this.renderLoading()}
</Fragment>
);
}
}
);

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { updateFields, updateFormErrors } from './follower_index_form';
jest.mock('ui/indices', () => ({
INDEX_ILLEGAL_CHARACTERS_VISIBLE: [],
}));
describe('<FollowerIndexForm /> state transitions', () => {
it('updateFormErrors() should merge errors with existing fieldsErrors', () => {
const errors = { name: 'Some error' };
const state = {
fieldsErrors: { leaderIndex: null }
};
const output = updateFormErrors(errors)(state);
expect(output).toMatchSnapshot();
});
it('updateFields() should merge new fields value with existing followerIndex', () => {
const fields = { name: 'new-name' };
const state = {
followerIndex: { name: 'foo', leaderIndex: 'bar' }
};
const output = updateFields(fields)(state);
expect(output).toMatchSnapshot();
});
});

View file

@ -4,5 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { apiMiddleware } from './api';
export { autoFollowPatternMiddleware } from './auto_follow_pattern';
export { FollowerIndexForm } from './follower_index_form';

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiPageContentHeader,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { followerIndexUrl } from '../services/documentation_links';
export const FollowerIndexPageTitle = ({ title }) => (
<Fragment>
<EuiSpacer size="xs" />
<EuiPageContentHeader>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<h1>{title}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={followerIndexUrl}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.crossClusterReplication.readDocsFollowerIndexButtonLabel"
defaultMessage="Follower index docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContentHeader>
</Fragment>
);
FollowerIndexPageTitle.propTypes = {
title: PropTypes.node.isRequired,
};

View file

@ -0,0 +1,145 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiConfirmModal,
EuiOverlayMask,
} from '@elastic/eui';
import { pauseFollowerIndex } from '../store/actions';
import { arrify } from '../../../common/services/utils';
import { areAllSettingsDefault } from '../services/follower_index_default_settings';
class Provider extends PureComponent {
static propTypes = {
onConfirm: PropTypes.func,
}
state = {
isModalOpen: false,
indices: []
}
onMouseOverModal = (event) => {
// This component can sometimes be used inside of an EuiToolTip, in which case mousing over
// the modal can trigger the tooltip. Stopping propagation prevents this.
event.stopPropagation();
};
pauseFollowerIndex = (index) => {
this.setState({ isModalOpen: true, indices: arrify(index) });
};
onConfirm = () => {
this.props.pauseFollowerIndex(this.state.indices.map(index => index.name));
this.setState({ isModalOpen: false, indices: [] });
this.props.onConfirm && this.props.onConfirm();
}
closeConfirmModal = () => {
this.setState({
isModalOpen: false,
});
};
renderModal = () => {
const { intl } = this.props;
const { indices } = this.state;
const isSingle = indices.length === 1;
const title = isSingle
? intl.formatMessage({
id: 'xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.pauseSingleTitle',
defaultMessage: 'Pause replication to follower index \'{name}\'?',
}, { name: indices[0].name })
: intl.formatMessage({
id: 'xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.pauseMultipleTitle',
defaultMessage: 'Pause replication to {count} follower indices?',
}, { count: indices.length });
const hasCustomSettings = indices.some(index => !areAllSettingsDefault(index));
return (
<EuiOverlayMask>
{ /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ }
<EuiConfirmModal
title={title}
onCancel={this.closeConfirmModal}
onConfirm={this.onConfirm}
cancelButtonText={
intl.formatMessage({
id: 'xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.cancelButtonText',
defaultMessage: 'Cancel',
})
}
buttonColor={hasCustomSettings ? 'danger' : 'primary'}
confirmButtonText={intl.formatMessage({
id: 'xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.confirmButtonText',
defaultMessage: 'Pause replication',
})}
onMouseOver={this.onMouseOverModal}
>
{hasCustomSettings && (
<p>
{isSingle ? (
<FormattedMessage
id="xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.singlePauseDescriptionWithSettingWarning"
defaultMessage="Pausing replication to this follower index clears its custom
advanced settings."
/>
) : (
<FormattedMessage
id="xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.multiplePauseDescriptionWithSettingWarning"
defaultMessage="Pausing replication to a follower index clears its custom
advanced settings."
/>
)}
</p>
)}
{!isSingle && (
<Fragment>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.multiplePauseDescription"
defaultMessage="Replication will pause on these follower indices:"
/>
</p>
<ul>
{indices.map(index => <li key={index.name}>{index.name}</li>)}
</ul>
</Fragment>
)}
</EuiConfirmModal>
</EuiOverlayMask>
);
}
render() {
const { children } = this.props;
const { isModalOpen } = this.state;
return (
<Fragment>
{children(this.pauseFollowerIndex)}
{isModalOpen && this.renderModal()}
</Fragment>
);
}
}
const mapDispatchToProps = (dispatch) => ({
pauseFollowerIndex: (id) => dispatch(pauseFollowerIndex(id)),
});
export const FollowerIndexPauseProvider = connect(
undefined,
mapDispatchToProps
)(injectI18n(Provider));

View file

@ -0,0 +1,154 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiConfirmModal,
EuiLink,
EuiOverlayMask,
} from '@elastic/eui';
import routing from '../services/routing';
import { resumeFollowerIndex } from '../store/actions';
import { arrify } from '../../../common/services/utils';
class Provider extends PureComponent {
static propTypes = {
onConfirm: PropTypes.func,
}
state = {
isModalOpen: false,
ids: null
}
onMouseOverModal = (event) => {
// This component can sometimes be used inside of an EuiToolTip, in which case mousing over
// the modal can trigger the tooltip. Stopping propagation prevents this.
event.stopPropagation();
};
resumeFollowerIndex = (id) => {
this.setState({ isModalOpen: true, ids: arrify(id) });
};
onConfirm = () => {
this.props.resumeFollowerIndex(this.state.ids);
this.setState({ isModalOpen: false, ids: null });
this.props.onConfirm && this.props.onConfirm();
}
closeConfirmModal = () => {
this.setState({
isModalOpen: false,
});
};
renderModal = () => {
const { intl } = this.props;
const { ids } = this.state;
const isSingle = ids.length === 1;
const title = isSingle
? intl.formatMessage({
id: 'xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.resumeSingleTitle',
defaultMessage: 'Resume replication to follower index \'{name}\'?',
}, { name: ids[0] })
: intl.formatMessage({
id: 'xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.resumeMultipleTitle',
defaultMessage: 'Resume replication to {count} follower indices?',
}, { count: ids.length });
return (
<EuiOverlayMask>
{ /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ }
<EuiConfirmModal
title={title}
onCancel={this.closeConfirmModal}
onConfirm={this.onConfirm}
cancelButtonText={
intl.formatMessage({
id: 'xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.cancelButtonText',
defaultMessage: 'Cancel',
})
}
buttonColor="primary"
confirmButtonText={
intl.formatMessage({
id: 'xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.confirmButtonText',
defaultMessage: 'Resume replication',
})
}
onMouseOver={this.onMouseOverModal}
>
{isSingle ? (
<p>
<FormattedMessage
id="xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.singleResumeDescription"
defaultMessage="Replication resumes using the default advanced settings. To use
custom advanced settings, {editLink}."
values={{
editLink: (
<EuiLink href={routing.getFollowerIndexPath(ids[0])}>
<FormattedMessage
id="xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.singleResumeEditLink"
defaultMessage="edit the follower index"
/>
</EuiLink>
),
}}
/>
</p>
) : (
<Fragment>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.multipleResumeDescriptionWithSettingWarning"
defaultMessage="Replication resumes using the default advanced settings."
/>
</p>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.multipleResumeDescription"
defaultMessage="Replication will resume on these follower indices:"
/>
</p>
<ul>
{ids.map(id => <li key={id}>{id}</li>)}
</ul>
</Fragment>
)}
</EuiConfirmModal>
</EuiOverlayMask>
);
}
render() {
const { children } = this.props;
const { isModalOpen } = this.state;
return (
<Fragment>
{children(this.resumeFollowerIndex)}
{isModalOpen && this.renderModal()}
</Fragment>
);
}
}
const mapDispatchToProps = (dispatch) => ({
resumeFollowerIndex: (id) => dispatch(resumeFollowerIndex(id)),
});
export const FollowerIndexResumeProvider = connect(
undefined,
mapDispatchToProps
)(injectI18n(Provider));

View file

@ -0,0 +1,137 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiConfirmModal,
EuiOverlayMask,
} from '@elastic/eui';
import { unfollowLeaderIndex } from '../store/actions';
import { arrify } from '../../../common/services/utils';
class Provider extends PureComponent {
static propTypes = {
onConfirm: PropTypes.func,
}
state = {
isModalOpen: false,
ids: null
}
onMouseOverModal = (event) => {
// This component can sometimes be used inside of an EuiToolTip, in which case mousing over
// the modal can trigger the tooltip. Stopping propagation prevents this.
event.stopPropagation();
};
unfollowLeaderIndex = (id) => {
this.setState({ isModalOpen: true, ids: arrify(id) });
};
onConfirm = () => {
this.props.unfollowLeaderIndex(this.state.ids);
this.setState({ isModalOpen: false, ids: null });
this.props.onConfirm && this.props.onConfirm();
}
closeConfirmModal = () => {
this.setState({
isModalOpen: false,
});
};
renderModal = () => {
const { intl } = this.props;
const { ids } = this.state;
const isSingle = ids.length === 1;
const title = isSingle
? intl.formatMessage({
id: 'xpack.crossClusterReplication.unfollowLeaderIndex.confirmModal.unfollowSingleTitle',
defaultMessage: `Unfollow leader index of '{name}'?`,
}, { name: ids[0] })
: intl.formatMessage({
id: 'xpack.crossClusterReplication.unfollowLeaderIndex.confirmModal.unfollowMultipleTitle',
defaultMessage: 'Unfollow {count} leader indices?',
}, { count: ids.length });
return (
<EuiOverlayMask>
{ /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ }
<EuiConfirmModal
title={title}
onCancel={this.closeConfirmModal}
onConfirm={this.onConfirm}
cancelButtonText={
intl.formatMessage({
id: 'xpack.crossClusterReplication.unfollowLeaderIndex.confirmModal.cancelButtonText',
defaultMessage: 'Cancel',
})
}
buttonColor="danger"
confirmButtonText={
intl.formatMessage({
id: 'xpack.crossClusterReplication.unfollowLeaderIndex.confirmModal.confirmButtonText',
defaultMessage: 'Unfollow leader',
})
}
onMouseOver={this.onMouseOverModal}
>
{isSingle ? (
<Fragment>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.unfollowLeaderIndex.confirmModal.singleUnfollowDescription"
defaultMessage="The follower index will be converted to a standard index. It will
no longer appear in Cross Cluster Replication, but you can manage it in Index
Management. You can't undo this operation."
/>
</p>
</Fragment>
) : (
<Fragment>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.unfollowLeaderIndex.confirmModal.multipleUnfollowDescription"
defaultMessage="The follower indices will be converted to standard indices. They
will no longer appear in Cross Cluster Replication, but you can manage them in
Index Management. You can't undo this operation."
/>
</p>
<ul>{ids.map(id => <li key={id}>{id}</li>)}</ul>
</Fragment>
)}
</EuiConfirmModal>
</EuiOverlayMask>
);
}
render() {
const { children } = this.props;
const { isModalOpen } = this.state;
return (
<Fragment>
{children(this.unfollowLeaderIndex)}
{isModalOpen && this.renderModal()}
</Fragment>
);
}
}
const mapDispatchToProps = (dispatch) => ({
unfollowLeaderIndex: (id) => dispatch(unfollowLeaderIndex(id)),
});
export const FollowerIndexUnfollowProvider = connect(
undefined,
mapDispatchToProps
)(injectI18n(Provider));

View file

@ -0,0 +1,150 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiDescribedFormGroup,
EuiFieldNumber,
EuiFieldText,
EuiFormRow,
EuiLink,
} from '@elastic/eui';
/**
* State transitions: fields update
*/
export const updateFields = (newValues) => ({ fields }) => ({
fields: {
...fields,
...newValues,
},
});
export class FormEntryRow extends PureComponent {
static propTypes = {
title: PropTypes.node,
description: PropTypes.node,
label: PropTypes.node,
helpText: PropTypes.node,
type: PropTypes.string,
onValueUpdate: PropTypes.func.isRequired,
field: PropTypes.string.isRequired,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number
]).isRequired,
defaultValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number
]),
isLoading: PropTypes.bool,
error: PropTypes.oneOfType([
PropTypes.node,
PropTypes.object,
]),
disabled: PropTypes.bool,
areErrorsVisible: PropTypes.bool.isRequired,
};
onFieldChange = (value) => {
const { field, onValueUpdate, type } = this.props;
const isNumber = type === 'number';
let valueParsed = value;
if (isNumber) {
valueParsed = !!value ? Math.max(0, parseInt(value, 10)) : value; // make sure we don't send NaN value or a negative number
}
onValueUpdate({ [field]: valueParsed });
}
renderField = (isInvalid) => {
const { value, type, disabled, isLoading } = this.props;
switch (type) {
case 'number':
return (
<EuiFieldNumber
isInvalid={isInvalid}
value={value}
onChange={e => this.onFieldChange(e.target.value)}
disabled={disabled === true}
isLoading={isLoading}
fullWidth
/>
);
default:
return (
<EuiFieldText
isInvalid={isInvalid}
value={value}
onChange={e => this.onFieldChange(e.target.value)}
disabled={disabled === true}
isLoading={isLoading}
fullWidth
/>
);
}
}
render() {
const {
field,
error,
title,
label,
description,
helpText,
areErrorsVisible,
value,
defaultValue,
} = this.props;
const hasError = !!error;
const isInvalid = hasError && (error.alwaysVisible || areErrorsVisible);
const canBeResetToDefault = defaultValue !== undefined;
const isResetToDefaultVisible = value !== defaultValue;
const fieldHelpText = (
<Fragment>
{helpText}
{canBeResetToDefault && isResetToDefaultVisible && (
<p>
<EuiLink onClick={() => this.onFieldChange(defaultValue)}>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.resetFieldButtonLabel"
defaultMessage="Reset to default"
/>
</EuiLink>
</p>
)}
</Fragment>
);
return (
<EuiDescribedFormGroup
title={title}
description={description}
fullWidth
key={field}
>
<EuiFormRow
label={label}
helpText={fieldHelpText}
error={(error && error.message) ? error.message : error}
isInvalid={isInvalid}
fullWidth
>
{this.renderField(isInvalid)}
</EuiFormRow>
</EuiDescribedFormGroup>
);
}
}

View file

@ -12,3 +12,9 @@ export { AutoFollowPatternForm } from './auto_follow_pattern_form';
export { AutoFollowPatternDeleteProvider } from './auto_follow_pattern_delete_provider';
export { AutoFollowPatternPageTitle } from './auto_follow_pattern_page_title';
export { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview';
export { FollowerIndexPauseProvider } from './follower_index_pause_provider';
export { FollowerIndexResumeProvider } from './follower_index_resume_provider';
export { FollowerIndexUnfollowProvider } from './follower_index_unfollow_provider';
export { FollowerIndexForm } from './follower_index_form';
export { FollowerIndexPageTitle } from './follower_index_page_title';
export { FormEntryRow } from './form_entry_row';

View file

@ -0,0 +1,321 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, PureComponent } from 'react';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFormErrorText,
EuiFormRow,
EuiSpacer,
EuiSelect,
EuiFieldText,
} from '@elastic/eui';
import routing from '../services/routing';
import { BASE_PATH_REMOTE_CLUSTERS } from '../../../common/constants';
const errorMessages = {
noClusterFound: () => (
<FormattedMessage
id="xpack.crossClusterReplication.forms.emptyRemoteClustersCallOutDescription"
defaultMessage="You need at least one remote cluster to create a follower index."
/>
),
remoteClusterNotConnectedEditable: (name) => ({
title: (
<FormattedMessage
id="xpack.crossClusterReplication.forms.currentRemoteClusterNotConnectedCallOutTitle"
defaultMessage="Remote cluster '{name}' is not connected"
values={{ name }}
/>
),
description: (
<FormattedMessage
id="xpack.crossClusterReplication.forms.currentRemoteClusterNotConnectedCallOutDescription"
defaultMessage="Edit the remote cluster or select a cluster that is connected."
/>
),
}),
};
export const RemoteClustersFormField = injectI18n(
class extends PureComponent {
errorMessages = {
...errorMessages,
...this.props.errorMessages
}
componentDidMount() {
const { selected, onError } = this.props;
const { error } = this.validateRemoteCluster(selected);
onError(error);
}
validateRemoteCluster(clusterName) {
const { remoteClusters } = this.props;
const remoteCluster = remoteClusters.find(c => c.name === clusterName);
return remoteCluster && remoteCluster.isConnected
? { error: null }
: { error: { message: (
<FormattedMessage
id="xpack.crossClusterReplication.forms.invalidRemoteClusterError"
defaultMessage="Invalid remote cluster"
/>
) } };
}
onRemoteClusterChange = (cluster) => {
const { onChange, onError } = this.props;
const { error } = this.validateRemoteCluster(cluster);
onChange(cluster);
onError(error);
};
renderNotEditable = () => {
const { areErrorsVisible } = this.props;
const errorMessage = this.renderErrorMessage();
return (
<Fragment>
<EuiFieldText
value={this.props.selected}
fullWidth
disabled
isInvalid={areErrorsVisible && Boolean(errorMessage)}
/>
{ areErrorsVisible && Boolean(errorMessage) ? this.renderValidRemoteClusterRequired() : null }
{ errorMessage }
</Fragment>
);
};
renderValidRemoteClusterRequired = () => (
<EuiFormErrorText>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.remoteCluster.validRemoteClusterRequired"
defaultMessage="A connected remote cluster is required."
/>
</EuiFormErrorText>
);
renderDropdown = () => {
const { remoteClusters, selected, currentUrl, areErrorsVisible } = this.props;
const hasClusters = Boolean(remoteClusters.length);
const remoteClustersOptions = hasClusters ? remoteClusters.map(({ name, isConnected }) => ({
value: name,
text: isConnected ? name : this.props.intl.formatMessage({
id: 'xpack.crossClusterReplication.forms.remoteClusterDropdownNotConnected',
defaultMessage: '{name} (not connected)',
}, { name }),
'data-test-subj': `option-${name}`
})) : [];
const errorMessage = this.renderErrorMessage();
return (
<Fragment>
<EuiSelect
fullWidth
options={remoteClustersOptions}
value={hasClusters ? selected : ''}
onChange={(e) => { this.onRemoteClusterChange(e.target.value); }}
hasNoInitialSelection={!hasClusters}
isInvalid={areErrorsVisible && Boolean(errorMessage)}
/>
{ areErrorsVisible && Boolean(errorMessage) ? this.renderValidRemoteClusterRequired() : null }
{ errorMessage }
<Fragment>
<EuiSpacer size="s" />
<div> {/* Break out of EuiFormRow's flexbox layout */}
<EuiButtonEmpty
{...routing.getRouterLinkProps('/add', BASE_PATH_REMOTE_CLUSTERS, { redirect: currentUrl }, true)}
size="s"
iconType="plusInCircle"
flush="left"
>
<FormattedMessage
id="xpack.crossClusterReplication.forms.addRemoteClusterButtonLabel"
defaultMessage="Add remote cluster"
/>
</EuiButtonEmpty>
</div>
</Fragment>
</Fragment>
);
};
renderNoClusterFound = () => {
const { intl, currentUrl } = this.props;
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.forms.emptyRemoteClustersCallOutTitle',
defaultMessage: `You don't have any remote clusters`,
});
return (
<Fragment>
<EuiCallOut
title={title}
color="danger"
iconType="cross"
>
<p>
{ this.errorMessages.noClusterFound() }
</p>
<EuiButton
{...routing.getRouterLinkProps('/add', BASE_PATH_REMOTE_CLUSTERS, { redirect: currentUrl }, true)}
iconType="plusInCircle"
color="danger"
>
<FormattedMessage
id="xpack.crossClusterReplication.forms.addRemoteClusterButtonLabel"
defaultMessage="Add remote cluster"
/>
</EuiButton>
</EuiCallOut>
</Fragment>
);
};
renderCurrentRemoteClusterNotConnected = (name, fatal) => {
const { isEditable, currentUrl } = this.props;
const {
remoteClusterNotConnectedEditable,
remoteClusterNotConnectedNotEditable,
} = this.errorMessages;
const { title, description } = isEditable
? remoteClusterNotConnectedEditable(name)
: remoteClusterNotConnectedNotEditable(name);
return (
<EuiCallOut
title={title}
color={fatal ? 'danger' : 'warning'}
iconType="cross"
>
<p>
{ description }
</p>
<EuiButton
{...routing.getRouterLinkProps(`/edit/${name}`, BASE_PATH_REMOTE_CLUSTERS, { redirect: currentUrl }, true)}
color={fatal ? 'danger' : 'warning'}
>
<FormattedMessage
id="xpack.crossClusterReplication.forms.viewRemoteClusterButtonLabel"
defaultMessage="Edit remote cluster"
/>
</EuiButton>
</EuiCallOut>
);
};
renderRemoteClusterDoesNotExist = (name) => {
const { intl, currentUrl } = this.props;
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.forms.remoteClusterNotFoundTitle',
defaultMessage: `Couldn't find remote cluster '{name}'`,
}, { name });
return (
<EuiCallOut
title={title}
color="danger"
iconType="cross"
>
<p>
{ this.errorMessages.remoteClusterDoesNotExist(name) }
</p>
<EuiButton
{...routing.getRouterLinkProps('/add', BASE_PATH_REMOTE_CLUSTERS, { redirect: currentUrl }, true)}
iconType="plusInCircle"
color="danger"
>
<FormattedMessage
id="xpack.crossClusterReplication.forms.addRemoteClusterButtonLabel"
defaultMessage="Add remote cluster"
/>
</EuiButton>
</EuiCallOut>
);
}
renderErrorMessage = () => {
const { selected, remoteClusters, isEditable } = this.props;
const remoteCluster = remoteClusters.find(c => c.name === selected);
const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected;
let error;
if (isEditable) {
/* Create */
const hasClusters = Boolean(remoteClusters.length);
if (hasClusters && !isSelectedRemoteClusterConnected) {
error = this.renderCurrentRemoteClusterNotConnected(selected);
} else if (!hasClusters) {
error = this.renderNoClusterFound();
}
} else {
/* Edit */
const doesExists = !!remoteCluster;
if (!doesExists) {
error = this.renderRemoteClusterDoesNotExist(selected);
} else if (!isSelectedRemoteClusterConnected) {
error = this.renderCurrentRemoteClusterNotConnected(selected, true);
}
}
return error ? (
<Fragment>
<EuiSpacer size="s" />
{error}
</Fragment>
) : null;
}
render() {
const { remoteClusters, selected, isEditable, areErrorsVisible } = this.props;
const remoteCluster = remoteClusters.find(c => c.name === selected);
const hasClusters = Boolean(remoteClusters.length);
const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected;
const isInvalid = areErrorsVisible && (!hasClusters || !isSelectedRemoteClusterConnected);
let field;
if(isEditable) {
if(hasClusters) {
field = this.renderDropdown();
} else {
field = this.renderErrorMessage();
}
} else {
field = this.renderNotEditable();
}
return (
<EuiFormRow
label={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.remoteCluster.fieldClusterLabel"
defaultMessage="Remote cluster"
/>
)}
isInvalid={isInvalid}
fullWidth
>
<Fragment>
{field}
</Fragment>
</EuiFormRow>
);
}
}
);

View file

@ -6,8 +6,8 @@
import React, { Fragment } from 'react';
import {
EuiSpacer,
EuiCallOut,
EuiSpacer,
} from '@elastic/eui';
export function SectionError({ title, error }) {
@ -18,23 +18,20 @@ export function SectionError({ title, error }) {
} = error.data;
return (
<Fragment>
<EuiCallOut
title={title}
color="danger"
iconType="alert"
>
<div>{message || errorString}</div>
{ cause && (
<Fragment>
<EuiSpacer size="m" />
<ul>
{ cause.map((message, i) => <li key={i}>{message}</li>) }
</ul>
</Fragment>
)}
</EuiCallOut>
<EuiSpacer size="m" />
</Fragment>
<EuiCallOut
title={title}
color="danger"
iconType="alert"
>
<div>{message || errorString}</div>
{ cause && (
<Fragment>
<EuiSpacer size="m" />
<ul>
{ cause.map((message, i) => <li key={i}>{message}</li>) }
</ul>
</Fragment>
)}
</EuiCallOut>
);
}

View file

@ -5,15 +5,10 @@
*/
import React, { Fragment } from 'react';
import { injectI18n } from '@kbn/i18n/react';
import { EuiCallOut } from '@elastic/eui';
export function SectionUnauthorizedUI({ intl, children }) {
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.remoteClusterList.noPermissionTitle',
defaultMessage: 'Permission error',
});
export function SectionUnauthorized({ title, children }) {
return (
<Fragment>
<EuiCallOut
@ -26,5 +21,3 @@ export function SectionUnauthorizedUI({ intl, children }) {
</Fragment>
);
}
export const SectionUnauthorized = injectI18n(SectionUnauthorizedUI);

View file

@ -6,7 +6,7 @@
export const SECTIONS = {
AUTO_FOLLOW_PATTERN: 'autoFollowPattern',
INDEX_FOLLOWER: 'indexFollower',
FOLLOWER_INDEX: 'followerIndex',
REMOTE_CLUSTER: 'remoteCluster',
CCR_STATS: 'ccrStats',
};

View file

@ -14,8 +14,8 @@ import { AutoFollowPatternAdd as AutoFollowPatternAddView } from './auto_follow_
const scope = SECTIONS.AUTO_FOLLOW_PATTERN;
const mapStateToProps = (state) => ({
apiStatus: getApiStatus(scope)(state),
apiError: getApiError(scope)(state),
apiStatus: getApiStatus(`${scope}-save`)(state),
apiError: getApiError(`${scope}-save`)(state),
});
const mapDispatchToProps = dispatch => ({

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import chrome from 'ui/chrome';
@ -12,19 +12,14 @@ import { MANAGEMENT_BREADCRUMB } from 'ui/management';
import {
EuiPageContent,
EuiButton,
EuiCallOut,
} from '@elastic/eui';
import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs';
import routing from '../../services/routing';
import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants';
import {
AutoFollowPatternForm,
AutoFollowPatternPageTitle,
RemoteClustersProvider,
SectionLoading,
SectionError,
} from '../../components';
export const AutoFollowPatternAdd = injectI18n(
@ -44,80 +39,8 @@ export const AutoFollowPatternAdd = injectI18n(
this.props.clearApiError();
}
renderEmptyClusters() {
const { intl, match: { url: currentUrl } } = this.props;
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.emptyRemoteClustersCallOutTitle',
defaultMessage: 'No remote cluster found'
});
return (
<Fragment>
<EuiCallOut
title={title}
color="warning"
iconType="cross"
>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternCreateForm.emptyRemoteClustersCallOutDescription"
defaultMessage="Auto-follow patterns capture indices on remote clusters. You must add
a remote cluster."
/>
</p>
<EuiButton
{...routing.getRouterLinkProps('/add', BASE_PATH_REMOTE_CLUSTERS, { redirect: currentUrl })}
iconType="plusInCircle"
color="warning"
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternCreateForm.addRemoteClusterButtonLabel"
defaultMessage="Add remote cluster"
/>
</EuiButton>
</EuiCallOut>
</Fragment>
);
}
renderNoConnectedCluster() {
const { intl } = this.props;
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.noRemoteClustersConnectedCallOutTitle',
defaultMessage: 'Remote cluster connection error'
});
return (
<Fragment>
<EuiCallOut
title={title}
color="warning"
iconType="cross"
>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternCreateForm.noRemoteClustersConnectedCallOutDescription"
defaultMessage="None of your clusters are connected. Verify your clusters settings
and make sure at least one cluster is connected before creating an auto-follow pattern." //eslint-disable-line max-len
/>
</p>
<EuiButton
{...routing.getRouterLinkProps('/', BASE_PATH_REMOTE_CLUSTERS)}
color="warning"
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternCreateForm.viewRemoteClusterButtonLabel"
defaultMessage="View remote clusters"
/>
</EuiButton>
</EuiCallOut>
</Fragment>
);
}
render() {
const { saveAutoFollowPattern, apiStatus, apiError, intl } = this.props;
const { saveAutoFollowPattern, apiStatus, apiError, match: { url: currentUrl } } = this.props;
return (
<EuiPageContent>
@ -143,28 +66,19 @@ export const AutoFollowPatternAdd = injectI18n(
);
}
if (error) {
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.loadingRemoteClustersErrorTitle',
defaultMessage: 'Error loading remote clusters',
});
return <SectionError title={title} error={error} />;
}
if (!remoteClusters.length) {
return this.renderEmptyClusters();
}
if (remoteClusters.every(cluster => cluster.isConnected === false)) {
return this.renderNoConnectedCluster();
}
return (
<AutoFollowPatternForm
apiStatus={apiStatus}
apiError={apiError}
remoteClusters={remoteClusters}
currentUrl={currentUrl}
remoteClusters={error ? [] : remoteClusters}
saveAutoFollowPattern={saveAutoFollowPattern}
saveButtonLabel={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternCreateForm.saveButtonLabel"
defaultMessage="Create"
/>
)}
/>
);
}}

View file

@ -7,22 +7,38 @@
import { connect } from 'react-redux';
import { SECTIONS } from '../../constants';
import { getApiStatus, getApiError, getSelectedAutoFollowPattern } from '../../store/selectors';
import { getAutoFollowPattern, saveAutoFollowPattern, clearApiError } from '../../store/actions';
import {
getApiStatus,
getApiError,
getSelectedAutoFollowPatternId,
getSelectedAutoFollowPattern,
} from '../../store/selectors';
import { getAutoFollowPattern, saveAutoFollowPattern, selectEditAutoFollowPattern, clearApiError } from '../../store/actions';
import { AutoFollowPatternEdit as AutoFollowPatternEditView } from './auto_follow_pattern_edit';
const scope = SECTIONS.AUTO_FOLLOW_PATTERN;
const mapStateToProps = (state) => ({
apiStatus: getApiStatus(scope)(state),
apiError: getApiError(scope)(state),
autoFollowPattern: getSelectedAutoFollowPattern(state),
apiStatus: {
get: getApiStatus(`${scope}-get`)(state),
save: getApiStatus(`${scope}-save`)(state),
},
apiError: {
get: getApiError(`${scope}-get`)(state),
save: getApiError(`${scope}-save`)(state),
},
autoFollowPatternId: getSelectedAutoFollowPatternId('edit')(state),
autoFollowPattern: getSelectedAutoFollowPattern('edit')(state),
});
const mapDispatchToProps = dispatch => ({
getAutoFollowPattern: (id) => dispatch(getAutoFollowPattern(id)),
selectAutoFollowPattern: (id) => dispatch(selectEditAutoFollowPattern(id)),
saveAutoFollowPattern: (id, autoFollowPattern) => dispatch(saveAutoFollowPattern(id, autoFollowPattern, true)),
clearApiError: () => dispatch(clearApiError(scope)),
clearApiError: () => {
dispatch(clearApiError(`${scope}-get`));
dispatch(clearApiError(`${scope}-save`));
},
});
export const AutoFollowPatternEdit = connect(

View file

@ -11,19 +11,15 @@ import chrome from 'ui/chrome';
import { MANAGEMENT_BREADCRUMB } from 'ui/management';
import {
EuiPage,
EuiPageBody,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiPageContent,
EuiSpacer,
EuiButton,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem
} from '@elastic/eui';
import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs';
import routing from '../../services/routing';
import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants';
import {
AutoFollowPatternForm,
AutoFollowPatternPageTitle,
@ -37,49 +33,78 @@ export const AutoFollowPatternEdit = injectI18n(
class extends PureComponent {
static propTypes = {
getAutoFollowPattern: PropTypes.func.isRequired,
selectAutoFollowPattern: PropTypes.func.isRequired,
saveAutoFollowPattern: PropTypes.func.isRequired,
clearApiError: PropTypes.func.isRequired,
apiError: PropTypes.object,
apiStatus: PropTypes.string.isRequired,
apiError: PropTypes.object.isRequired,
apiStatus: PropTypes.object.isRequired,
autoFollowPattern: PropTypes.object,
autoFollowPatternId: PropTypes.string,
}
componentDidMount() {
const { autoFollowPattern, match: { params: { id } } } = this.props;
if (!autoFollowPattern) {
const decodedId = decodeURIComponent(id);
this.props.getAutoFollowPattern(decodedId);
static getDerivedStateFromProps({ autoFollowPatternId }, { lastAutoFollowPatternId }) {
if (lastAutoFollowPatternId !== autoFollowPatternId) {
return { lastAutoFollowPatternId: autoFollowPatternId };
}
return null;
}
state = { lastAutoFollowPatternId: undefined }
componentDidMount() {
const { match: { params: { id } }, selectAutoFollowPattern } = this.props;
const decodedId = decodeURIComponent(id);
selectAutoFollowPattern(decodedId);
chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb ]);
}
componentDidUpdate(prevProps, prevState) {
const { autoFollowPattern, getAutoFollowPattern } = this.props;
// Fetch the auto-follow pattern on the server if we don't have it (i.e. page reload)
if (!autoFollowPattern && prevState.lastAutoFollowPatternId !== this.state.lastAutoFollowPatternId) {
getAutoFollowPattern(this.state.lastAutoFollowPatternId);
}
}
componentWillUnmount() {
this.props.clearApiError();
}
renderApiError(error) {
const { intl } = this.props;
renderGetAutoFollowPatternError(error) {
const { intl, match: { params: { id: name } } } = this.props;
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorTitle',
defaultMessage: 'Error loading auto-follow pattern',
});
const errorMessage = error.status === 404 ? {
data: {
error: intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorMessage',
defaultMessage: `The auto-follow pattern '{name}' does not exist.`,
}, { name })
}
} : error;
return (
<Fragment>
<SectionError title={title} error={error} />
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceAround">
<SectionError title={title} error={errorMessage} />
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
<EuiButtonEmpty
{...routing.getRouterLinkProps('/auto_follow_patterns')}
fill
iconType="plusInCircle"
iconType="arrowLeft"
flush="left"
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternEditForm.viewAutoFollowPatternsButtonLabel"
defaultMessage="View auto-follow patterns"
/>
</EuiButton>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
@ -97,110 +122,62 @@ export const AutoFollowPatternEdit = injectI18n(
);
}
renderMissingCluster({ name, remoteCluster }) {
const { intl } = this.props;
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.emptyRemoteClustersTitle',
defaultMessage: 'Remote cluster missing'
});
return (
<Fragment>
<EuiCallOut
title={title}
color="warning"
iconType="help"
>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternEditForm.emptyRemoteClustersDescription"
defaultMessage="The remote cluster '{remoteCluster}' does not exist or is not
connected. Make sure it is connected before editing the '{name}' auto-follow pattern."
values={{ remoteCluster, name }}
/>
</p>
<EuiButton
{...routing.getRouterLinkProps('/list', BASE_PATH_REMOTE_CLUSTERS)}
color="warning"
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternEditForm.viewRemoteClustersButtonLabel"
defaultMessage="View remote clusters"
/>
</EuiButton>
</EuiCallOut>
</Fragment>
);
}
render() {
const { saveAutoFollowPattern, apiStatus, apiError, autoFollowPattern, intl } = this.props;
const { saveAutoFollowPattern, apiStatus, apiError, autoFollowPattern, match: { url: currentUrl } } = this.props;
return (
<EuiPage>
<EuiPageBody>
<EuiPageContent
horizontalPosition="center"
className="ccrPageContent"
>
<AutoFollowPatternPageTitle
title={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.editTitle"
defaultMessage="Edit auto-follow pattern"
/>
)}
<EuiPageContent
horizontalPosition="center"
className="ccrPageContent"
>
<AutoFollowPatternPageTitle
title={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.editTitle"
defaultMessage="Edit auto-follow pattern"
/>
)}
/>
{apiStatus === API_STATUS.LOADING && this.renderLoadingAutoFollowPattern()}
{apiStatus.get === API_STATUS.LOADING && this.renderLoadingAutoFollowPattern()}
{apiError && this.renderApiError(apiError)}
{autoFollowPattern && (
<RemoteClustersProvider>
{({ isLoading, error, remoteClusters }) => {
if (isLoading) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClusters"
defaultMessage="Loading remote clusters..."
/>
</SectionLoading>
);
}
{apiError.get && this.renderGetAutoFollowPatternError(apiError.get)}
if (error) {
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClustersErrorTitle',
defaultMessage: 'Error loading remote clusters',
});
return <SectionError title={title} error={error} />;
}
const autoFollowPatternCluster = remoteClusters.find(cluster => cluster.name === autoFollowPattern.remoteCluster);
if (!autoFollowPatternCluster || !autoFollowPatternCluster.isConnected) {
return this.renderMissingCluster(autoFollowPattern);
}
return (
<AutoFollowPatternForm
apiStatus={apiStatus}
apiError={apiError}
remoteClusters={remoteClusters}
autoFollowPattern={autoFollowPattern}
saveAutoFollowPattern={saveAutoFollowPattern}
{autoFollowPattern && (
<RemoteClustersProvider>
{({ isLoading, error, remoteClusters }) => {
if (isLoading) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClusters"
defaultMessage="Loading remote clusters..."
/>
);
}}
</RemoteClustersProvider>
)}
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</SectionLoading>
);
}
return (
<AutoFollowPatternForm
apiStatus={apiStatus.save}
apiError={apiError.save}
currentUrl={currentUrl}
remoteClusters={error ? [] : remoteClusters}
autoFollowPattern={autoFollowPattern}
saveAutoFollowPattern={saveAutoFollowPattern}
saveButtonLabel={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternEditForm.saveButtonLabel"
defaultMessage="Update"
/>
)}
/>
);
}}
</RemoteClustersProvider>
)}
</EuiPageContent>
);
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { SECTIONS } from '../../constants';
import { getApiStatus, getApiError } from '../../store/selectors';
import { saveFollowerIndex, clearApiError } from '../../store/actions';
import { FollowerIndexAdd as FollowerIndexAddView } from './follower_index_add';
const scope = SECTIONS.FOLLOWER_INDEX;
const mapStateToProps = (state) => ({
apiStatus: getApiStatus(`${scope}-save`)(state),
apiError: getApiError(`${scope}-save`)(state),
});
const mapDispatchToProps = dispatch => ({
saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex)),
clearApiError: () => dispatch(clearApiError(`${scope}-save`)),
});
export const FollowerIndexAdd = connect(
mapStateToProps,
mapDispatchToProps
)(FollowerIndexAddView);

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent } 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 {
EuiPageContent,
} from '@elastic/eui';
import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs';
import {
FollowerIndexForm,
FollowerIndexPageTitle,
RemoteClustersProvider,
SectionLoading,
} from '../../components';
export const FollowerIndexAdd = injectI18n(
class extends PureComponent {
static propTypes = {
saveFollowerIndex: PropTypes.func.isRequired,
clearApiError: PropTypes.func.isRequired,
apiError: PropTypes.object,
apiStatus: PropTypes.string.isRequired,
}
componentDidMount() {
chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb ]);
}
componentWillUnmount() {
this.props.clearApiError();
}
render() {
const { saveFollowerIndex, clearApiError, apiStatus, apiError, match: { url: currentUrl } } = this.props;
return (
<EuiPageContent
horizontalPosition="center"
className="ccrPageContent"
>
<FollowerIndexPageTitle
title={(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndex.addTitle"
defaultMessage="Add follower index"
/>
)}
/>
<RemoteClustersProvider>
{({ isLoading, error, remoteClusters }) => {
if (isLoading) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexCreateForm.loadingRemoteClusters"
defaultMessage="Loading remote clusters..."
/>
</SectionLoading>
);
}
return (
<FollowerIndexForm
apiStatus={apiStatus}
apiError={apiError}
currentUrl={currentUrl}
remoteClusters={error ? [] : remoteClusters}
saveFollowerIndex={saveFollowerIndex}
clearApiError={clearApiError}
saveButtonLabel={(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexCreateForm.saveButtonLabel"
defaultMessage="Create"
/>
)}
/>
);
}}
</RemoteClustersProvider>
</EuiPageContent>
);
}
}
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { FollowerIndexAdd } from './follower_index_add.container';

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { SECTIONS } from '../../constants';
import {
getApiStatus,
getApiError,
getSelectedFollowerIndexId,
getSelectedFollowerIndex,
} from '../../store/selectors';
import {
saveFollowerIndex,
clearApiError,
getFollowerIndex,
selectEditFollowerIndex,
} from '../../store/actions';
import { FollowerIndexEdit as FollowerIndexEditView } from './follower_index_edit';
const scope = SECTIONS.FOLLOWER_INDEX;
const mapStateToProps = (state) => ({
apiStatus: {
get: getApiStatus(`${scope}-get`)(state),
save: getApiStatus(`${scope}-save`)(state),
},
apiError: {
get: getApiError(`${scope}-get`)(state),
save: getApiError(`${scope}-save`)(state),
},
followerIndexId: getSelectedFollowerIndexId('edit')(state),
followerIndex: getSelectedFollowerIndex('edit')(state),
});
const mapDispatchToProps = dispatch => ({
getFollowerIndex: (id) => dispatch(getFollowerIndex(id)),
selectFollowerIndex: (id) => dispatch(selectEditFollowerIndex(id)),
saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex, true)),
clearApiError: () => {
dispatch(clearApiError(`${scope}-get`));
dispatch(clearApiError(`${scope}-save`));
},
});
export const FollowerIndexEdit = connect(
mapStateToProps,
mapDispatchToProps
)(FollowerIndexEditView);

View file

@ -0,0 +1,275 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import chrome from 'ui/chrome';
import { MANAGEMENT_BREADCRUMB } from 'ui/management';
import {
EuiButtonEmpty,
EuiConfirmModal,
EuiFlexGroup,
EuiFlexItem,
EuiOverlayMask,
EuiPageContent,
EuiSpacer,
} from '@elastic/eui';
import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs';
import routing from '../../services/routing';
import {
FollowerIndexForm,
FollowerIndexPageTitle,
SectionLoading,
SectionError,
RemoteClustersProvider,
} from '../../components';
import { API_STATUS } from '../../constants';
export const FollowerIndexEdit = injectI18n(
class extends PureComponent {
static propTypes = {
getFollowerIndex: PropTypes.func.isRequired,
selectFollowerIndex: PropTypes.func.isRequired,
saveFollowerIndex: PropTypes.func.isRequired,
clearApiError: PropTypes.func.isRequired,
apiError: PropTypes.object.isRequired,
apiStatus: PropTypes.object.isRequired,
followerIndex: PropTypes.object,
followerIndexId: PropTypes.string,
}
static getDerivedStateFromProps({ followerIndexId }, { lastFollowerIndexId }) {
if (lastFollowerIndexId !== followerIndexId) {
return { lastFollowerIndexId: followerIndexId };
}
return null;
}
state = {
lastFollowerIndexId: undefined,
showConfirmModal: false,
}
componentDidMount() {
const { match: { params: { id } }, selectFollowerIndex } = this.props;
let decodedId;
try {
// When we navigate through the router (history.push) we need to decode both the uri and the id
decodedId = decodeURI(id);
decodedId = decodeURIComponent(decodedId);
} catch (e) {
// This is a page load. I guess that AngularJS router does already a decodeURI so it is not
// necessary in this case.
decodedId = decodeURIComponent(id);
}
selectFollowerIndex(decodedId);
chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb ]);
}
componentDidUpdate(prevProps, prevState) {
const { followerIndex, getFollowerIndex } = this.props;
// Fetch the follower index on the server if we don't have it (i.e. page reload)
if (!followerIndex && prevState.lastFollowerIndexId !== this.state.lastFollowerIndexId) {
getFollowerIndex(this.state.lastFollowerIndexId);
}
}
componentWillUnmount() {
this.props.clearApiError();
}
saveFollowerIndex = (name, followerIndex) => {
this.editedFollowerIndexPayload = { name, followerIndex };
this.showConfirmModal();
}
confirmSaveFollowerIhdex = () => {
const { name, followerIndex } = this.editedFollowerIndexPayload;
this.props.saveFollowerIndex(name, followerIndex);
this.closeConfirmModal();
}
showConfirmModal = () => this.setState({ showConfirmModal: true });
closeConfirmModal = () => this.setState({ showConfirmModal: false });
renderLoadingFollowerIndex() {
return (
<SectionLoading>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexEditForm.loadingFollowerIndexTitle"
defaultMessage="Loading follower index..."
/>
</SectionLoading>
);
}
renderGetFollowerIndexError(error) {
const { intl, match: { params: { id: name } } } = this.props;
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorTitle',
defaultMessage: 'Error loading follower index',
});
const errorMessage = error.status === 404 ? {
data: {
error: intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorMessage',
defaultMessage: `The follower index '{name}' does not exist.`,
}, { name })
}
} : error;
return (
<Fragment>
<SectionError title={title} error={errorMessage} />
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
{...routing.getRouterLinkProps('/follower_indices')}
iconType="arrowLeft"
flush="left"
>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexEditForm.viewFollowerIndicesButtonLabel"
defaultMessage="View follower indices"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
}
renderConfirmModal = () => {
const { followerIndexId, intl, followerIndex: { isPaused } } = this.props;
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.title',
defaultMessage: 'Update follower index \'{id}\'?',
}, { id: followerIndexId });
return (
<EuiOverlayMask>
<EuiConfirmModal
title={title}
onCancel={this.closeConfirmModal}
onConfirm={this.confirmSaveFollowerIhdex}
cancelButtonText={
intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.cancelButtonText',
defaultMessage: 'Cancel',
})
}
confirmButtonText={isPaused ? (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexEditForm.confirmModal.confirmAndResumeButtonText"
defaultMessage="Update and resume"
/>
) : (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexEditForm.confirmModal.confirmButtonText"
defaultMessage="Update"
/>
)}
>
<p>
{isPaused ? (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexEditForm.confirmModal.resumeDescription"
defaultMessage="Updating a follower index resumes replication of its leader index."
/>
) : (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexEditForm.confirmModal.description"
defaultMessage="The follower index is paused, then resumed. If the update fails,
try manually resuming replication."
/>
)}
</p>
</EuiConfirmModal>
</EuiOverlayMask>
);
}
render() {
const {
clearApiError,
apiStatus,
apiError,
followerIndex,
match: { url: currentUrl }
} = this.props;
const { showConfirmModal } = this.state;
/* remove non-editable properties */
const { shards, ...rest } = followerIndex || {}; // eslint-disable-line no-unused-vars
return (
<EuiPageContent
horizontalPosition="center"
className="ccrPageContent"
>
<FollowerIndexPageTitle
title={(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndex.editTitle"
defaultMessage="Edit follower index"
/>
)}
/>
{apiStatus.get === API_STATUS.LOADING && this.renderLoadingFollowerIndex()}
{apiError.get && this.renderGetFollowerIndexError(apiError.get)}
{ followerIndex && (
<RemoteClustersProvider>
{({ isLoading, error, remoteClusters }) => {
if (isLoading) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexCreateForm.loadingRemoteClusters"
defaultMessage="Loading remote clusters..."
/>
</SectionLoading>
);
}
return (
<FollowerIndexForm
followerIndex={rest}
apiStatus={apiStatus.save}
apiError={apiError.save}
currentUrl={currentUrl}
remoteClusters={error ? [] : remoteClusters}
saveFollowerIndex={this.saveFollowerIndex}
clearApiError={clearApiError}
saveButtonLabel={(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexEditForm.saveButtonLabel"
defaultMessage="Update"
/>
)}
/>
);
}}
</RemoteClustersProvider>
) }
{ showConfirmModal && this.renderConfirmModal() }
</EuiPageContent>
);
}
}
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { FollowerIndexEdit } from './follower_index_edit.container';

View file

@ -9,15 +9,14 @@ import { connect } from 'react-redux';
import { SECTIONS } from '../../../constants';
import {
getListAutoFollowPatterns,
getSelectedAutoFollowPatternId,
getApiStatus,
getApiError,
isApiAuthorized,
isAutoFollowPatternDetailPanelOpen as isDetailPanelOpen,
} from '../../../store/selectors';
import {
loadAutoFollowPatterns,
openAutoFollowPatternDetailPanel as openDetailPanel,
closeAutoFollowPatternDetailPanel as closeDetailPanel,
selectDetailAutoFollowPattern,
loadAutoFollowStats,
} from '../../../store/actions';
import { AutoFollowPatternList as AutoFollowPatternListView } from './auto_follow_pattern_list';
@ -26,20 +25,15 @@ const scope = SECTIONS.AUTO_FOLLOW_PATTERN;
const mapStateToProps = (state) => ({
autoFollowPatterns: getListAutoFollowPatterns(state),
autoFollowPatternId: getSelectedAutoFollowPatternId('detail')(state),
apiStatus: getApiStatus(scope)(state),
apiError: getApiError(scope)(state),
isAuthorized: isApiAuthorized(scope)(state),
isDetailPanelOpen: isDetailPanelOpen(state),
});
const mapDispatchToProps = dispatch => ({
loadAutoFollowPatterns: (inBackground) => dispatch(loadAutoFollowPatterns(inBackground)),
openDetailPanel: (name) => {
dispatch(openDetailPanel(name));
},
closeDetailPanel: () => {
dispatch(closeDetailPanel());
},
selectAutoFollowPattern: (id) => dispatch(selectDetailAutoFollowPattern(id)),
loadAutoFollowStats: () => dispatch(loadAutoFollowStats())
});

View file

@ -7,61 +7,171 @@
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import {
EuiButton,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import routing from '../../../services/routing';
import { extractQueryParams } from '../../../services/query_params';
import { API_STATUS } from '../../../constants';
import { SectionLoading, SectionError } from '../../../components';
import { SectionLoading, SectionError, SectionUnauthorized } from '../../../components';
import { AutoFollowPatternTable, DetailPanel } from './components';
const REFRESH_RATE_MS = 30000;
const getQueryParamPattern = ({ location: { search } }) => {
const { pattern } = extractQueryParams(search);
return pattern ? decodeURIComponent(pattern) : null;
};
export const AutoFollowPatternList = injectI18n(
class extends PureComponent {
static propTypes = {
loadAutoFollowPatterns: PropTypes.func,
selectAutoFollowPattern: PropTypes.func,
loadAutoFollowStats: PropTypes.func,
autoFollowPatterns: PropTypes.array,
apiStatus: PropTypes.string,
apiError: PropTypes.object,
openDetailPanel: PropTypes.func.isRequired,
closeDetailPanel: PropTypes.func.isRequired,
isDetailPanelOpen: PropTypes.bool,
}
static getDerivedStateFromProps({ autoFollowPatternId }, { lastAutoFollowPatternId }) {
if (autoFollowPatternId !== lastAutoFollowPatternId) {
return {
lastAutoFollowPatternId: autoFollowPatternId,
isDetailPanelOpen: !!autoFollowPatternId,
};
}
return null;
}
state = {
lastAutoFollowPatternId: null,
isDetailPanelOpen: false,
};
componentDidMount() {
this.props.loadAutoFollowPatterns();
this.props.loadAutoFollowStats();
const { loadAutoFollowPatterns, loadAutoFollowStats, selectAutoFollowPattern, history } = this.props;
loadAutoFollowPatterns();
loadAutoFollowStats();
// Select the pattern in the URL query params
selectAutoFollowPattern(getQueryParamPattern(history));
// Interval to load auto-follow patterns in the background passing "true" to the fetch method
this.interval = setInterval(() => this.props.loadAutoFollowPatterns(true), REFRESH_RATE_MS);
this.interval = setInterval(() => loadAutoFollowPatterns(true), REFRESH_RATE_MS);
}
componentDidUpdate(prevProps, prevState) {
const { history, loadAutoFollowStats } = this.props;
const { lastAutoFollowPatternId } = this.state;
/**
* Each time our state is updated (through getDerivedStateFromProps())
* we persist the auto-follow pattern id to query params for deep linking
*/
if (lastAutoFollowPatternId !== prevState.lastAutoFollowPatternId) {
if(!lastAutoFollowPatternId) {
history.replace({
search: '',
});
} else {
history.replace({
search: `?pattern=${encodeURIComponent(lastAutoFollowPatternId)}`,
});
loadAutoFollowStats();
}
}
}
componentWillUnmount() {
clearInterval(this.interval);
}
componentDidUpdate() {
const {
openDetailPanel,
closeDetailPanel,
isDetailPanelOpen,
history: {
location: {
search,
},
},
} = this.props;
renderHeader() {
const { isAuthorized } = this.props;
return (
<Fragment>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexStart">
<EuiFlexItem grow={false}>
<EuiText>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.autoFollowPatternsDescription"
defaultMessage="An auto-follow pattern replicates leader indices from a remote
cluster and copies them to follower indices on the local cluster."
/>
</p>
</EuiText>
</EuiFlexItem>
const { pattern: patternName } = extractQueryParams(search);
<EuiFlexItem grow={false}>
{isAuthorized && (
<EuiButton
{...routing.getRouterLinkProps('/auto_follow_patterns/add')}
fill
iconType="plusInCircle"
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.addAutoFollowPatternButtonLabel"
defaultMessage="Create an auto-follow pattern"
/>
</EuiButton>
)}
</EuiFlexItem>
</EuiFlexGroup>
// Show deeplinked auto follow pattern whenever patterns get loaded or the URL changes.
if (patternName != null) {
openDetailPanel(patternName);
} else if (isDetailPanelOpen) {
closeDetailPanel();
<EuiSpacer size="m" />
</Fragment>
);
}
renderContent(isEmpty) {
const { apiError, isAuthorized, intl } = this.props;
if (!isAuthorized) {
return (
<SectionUnauthorized
title={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.permissionErrorTitle"
defaultMessage="Permission error"
/>
)}
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.noPermissionText"
defaultMessage="You do not have permission to view or add auto-follow patterns."
/>
</SectionUnauthorized>
);
}
if (apiError) {
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.loadingErrorTitle',
defaultMessage: 'Error loading auto-follow patterns',
});
return (
<Fragment>
<SectionError title={title} error={apiError} />
<EuiSpacer size="m" />
</Fragment>
);
}
if (isEmpty) {
return this.renderEmpty();
}
return this.renderList();
}
renderEmpty() {
@ -104,7 +214,13 @@ export const AutoFollowPatternList = injectI18n(
}
renderList() {
const { autoFollowPatterns, apiStatus } = this.props;
const {
selectAutoFollowPattern,
autoFollowPatterns,
apiStatus,
} = this.props;
const { isDetailPanelOpen } = this.state;
if (apiStatus === API_STATUS.LOADING) {
return (
@ -120,31 +236,21 @@ export const AutoFollowPatternList = injectI18n(
return (
<Fragment>
<AutoFollowPatternTable autoFollowPatterns={autoFollowPatterns} />
<DetailPanel />
{isDetailPanelOpen && <DetailPanel closeDetailPanel={() => selectAutoFollowPattern(null)} />}
</Fragment>
);
}
render() {
const { autoFollowPatterns, apiStatus, apiError, isAuthorized, intl } = this.props;
const { autoFollowPatterns, apiStatus, } = this.props;
const isEmpty = apiStatus === API_STATUS.IDLE && !autoFollowPatterns.length;
if (!isAuthorized) {
return null;
}
if (apiStatus === API_STATUS.IDLE && !autoFollowPatterns.length) {
return this.renderEmpty();
}
if (apiError) {
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.loadingErrorTitle',
defaultMessage: 'Error loading auto-follow patterns',
});
return <SectionError title={title} error={apiError} />;
}
return this.renderList();
return (
<Fragment>
{!isEmpty && this.renderHeader()}
{this.renderContent(isEmpty)}
</Fragment>
);
}
}
);

View file

@ -7,10 +7,7 @@
import { connect } from 'react-redux';
import { SECTIONS } from '../../../../../constants';
import {
editAutoFollowPattern,
openAutoFollowPatternDetailPanel as openDetailPanel,
} from '../../../../../store/actions';
import { selectDetailAutoFollowPattern } from '../../../../../store/actions';
import { getApiStatus } from '../../../../../store/selectors';
import { AutoFollowPatternTable as AutoFollowPatternTableComponent } from './auto_follow_pattern_table';
@ -21,10 +18,7 @@ const mapStateToProps = (state) => ({
});
const mapDispatchToProps = (dispatch) => ({
editAutoFollowPattern: (name) => dispatch(editAutoFollowPattern(name)),
openDetailPanel: (name) => {
dispatch(openDetailPanel(name));
},
selectAutoFollowPattern: (name) => dispatch(selectDetailAutoFollowPattern(name)),
});
export const AutoFollowPatternTable = connect(

View file

@ -25,7 +25,7 @@ export const AutoFollowPatternTable = injectI18n(
class extends PureComponent {
static propTypes = {
autoFollowPatterns: PropTypes.array,
openDetailPanel: PropTypes.func.isRequired,
selectAutoFollowPattern: PropTypes.func.isRequired,
}
state = {
@ -61,7 +61,7 @@ export const AutoFollowPatternTable = injectI18n(
};
getTableColumns() {
const { intl, editAutoFollowPattern, openDetailPanel } = this.props;
const { intl, selectAutoFollowPattern } = this.props;
return [{
field: 'name',
@ -73,7 +73,7 @@ export const AutoFollowPatternTable = injectI18n(
truncateText: false,
render: (name) => {
return (
<EuiLink onClick={() => openDetailPanel(name)}>
<EuiLink onClick={() => selectAutoFollowPattern(name)}>
{name}
</EuiLink>
);
@ -82,7 +82,7 @@ export const AutoFollowPatternTable = injectI18n(
field: 'remoteCluster',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.table.clusterColumnTitle',
defaultMessage: 'Cluster',
defaultMessage: 'Remote cluster',
}),
truncateText: true,
sortable: true,
@ -97,14 +97,14 @@ export const AutoFollowPatternTable = injectI18n(
field: 'followIndexPatternPrefix',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.table.prefixColumnTitle',
defaultMessage: 'Follower pattern prefix',
defaultMessage: 'Follower index prefix',
}),
sortable: true,
}, {
field: 'followIndexPatternSuffix',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.table.suffixColumnTitle',
defaultMessage: 'Follower pattern suffix',
defaultMessage: 'Follower index suffix',
}),
sortable: true,
}, {
@ -116,7 +116,7 @@ export const AutoFollowPatternTable = injectI18n(
{
render: ({ name }) => {
const label = i18n.translate(
'xpack.crossClusterReplication.autofollowPatternList.table.actionDeleteDescription',
'xpack.crossClusterReplication.autoFollowPatternList.table.actionDeleteDescription',
{
defaultMessage: 'Delete auto-follow pattern',
}
@ -142,20 +142,25 @@ export const AutoFollowPatternTable = injectI18n(
},
},
{
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.editIndexPattern.fields.table.actionEditLabel',
defaultMessage: 'Edit',
}),
description: intl.formatMessage({
id: 'xpack.crossClusterReplication.editIndexPattern.fields.table.actionEditDescription',
defaultMessage: 'Edit',
}),
icon: 'pencil',
onClick: ({ name }) => {
editAutoFollowPattern(name);
routing.navigate(encodeURI(`/auto_follow_patterns/edit/${encodeURIComponent(name)}`));
render: ({ name }) => {
const label = i18n.translate('xpack.crossClusterReplication.autoFollowPatternList.table.actionEditDescription', {
defaultMessage: 'Edit auto-follow pattern',
});
return (
<EuiToolTip
content={label}
delay="long"
>
<EuiButtonIcon
aria-label={label}
iconType="pencil"
color="primary"
href={routing.getAutoFollowPatternPath(name)}
/>
</EuiToolTip>
);
},
type: 'icon',
},
],
width: '100px',

View file

@ -7,45 +7,17 @@
import { connect } from 'react-redux';
import { DetailPanel as DetailPanelView } from './detail_panel';
import {
getDetailPanelAutoFollowPattern,
getDetailPanelAutoFollowPatternName,
getApiStatus,
isAutoFollowPatternDetailPanelOpen as isDetailPanelOpen,
} from '../../../../../store/selectors';
import {
closeAutoFollowPatternDetailPanel as closeDetailPanel,
editAutoFollowPattern,
} from '../../../../../store/actions';
import {
SECTIONS
} from '../../../../../constants';
import { getSelectedAutoFollowPattern, getSelectedAutoFollowPatternId, getApiStatus, } from '../../../../../store/selectors';
import { SECTIONS } from '../../../../../constants';
const scope = SECTIONS.AUTO_FOLLOW_PATTERN;
const mapStateToProps = (state) => {
return {
isDetailPanelOpen: isDetailPanelOpen(state),
autoFollowPattern: getDetailPanelAutoFollowPattern(state),
autoFollowPatternName: getDetailPanelAutoFollowPatternName(state),
apiStatus: getApiStatus(scope)(state),
};
};
const mapDispatchToProps = (dispatch) => {
return {
closeDetailPanel: () => {
dispatch(closeDetailPanel());
},
editAutoFollowPattern: (name) => {
dispatch(editAutoFollowPattern(name));
}
};
};
const mapStateToProps = (state) => ({
autoFollowPatternId: getSelectedAutoFollowPatternId('detail')(state),
autoFollowPattern: getSelectedAutoFollowPattern('detail')(state),
apiStatus: getApiStatus(scope)(state),
});
export const DetailPanel = connect(
mapStateToProps,
mapDispatchToProps
)(DetailPanelView);

View file

@ -41,12 +41,10 @@ import routing from '../../../../../services/routing';
export class DetailPanelUi extends Component {
static propTypes = {
isDetailPanelOpen: PropTypes.bool.isRequired,
apiStatus: PropTypes.string,
autoFollowPatternId: PropTypes.string,
autoFollowPattern: PropTypes.object,
autoFollowPatternName: PropTypes.string,
closeDetailPanel: PropTypes.func.isRequired,
editAutoFollowPattern: PropTypes.func.isRequired,
}
renderAutoFollowPattern() {
@ -235,7 +233,7 @@ export class DetailPanelUi extends Component {
autoFollowPattern,
} = this.props;
if(apiStatus === API_STATUS.LOADING) {
if (apiStatus === API_STATUS.LOADING) {
return (
<EuiFlyoutBody>
<EuiFlexGroup
@ -294,16 +292,10 @@ export class DetailPanelUi extends Component {
renderFooter() {
const {
editAutoFollowPattern,
autoFollowPattern,
autoFollowPatternName,
closeDetailPanel,
} = this.props;
if (!autoFollowPattern) {
return null;
}
return (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
@ -320,56 +312,47 @@ export class DetailPanelUi extends Component {
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<AutoFollowPatternDeleteProvider>
{(deleteAutoFollowPattern) => (
<EuiButtonEmpty
color="danger"
onClick={() => deleteAutoFollowPattern(autoFollowPatternName)}
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.deleteButtonLabel"
defaultMessage="Delete"
/>
</EuiButtonEmpty>
)}
</AutoFollowPatternDeleteProvider>
</EuiFlexItem>
{autoFollowPattern && (
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<AutoFollowPatternDeleteProvider>
{(deleteAutoFollowPattern) => (
<EuiButtonEmpty
color="danger"
onClick={() => deleteAutoFollowPattern(autoFollowPattern.name)}
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.deleteButtonLabel"
defaultMessage="Delete"
/>
</EuiButtonEmpty>
)}
</AutoFollowPatternDeleteProvider>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="primary"
onClick={() => {
editAutoFollowPattern(autoFollowPatternName);
routing.navigate(encodeURI(`/auto_follow_patterns/edit/${encodeURIComponent(autoFollowPatternName)}`));
}}
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.editButtonLabel"
defaultMessage="Edit"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="primary"
href={routing.getAutoFollowPatternPath(autoFollowPattern.name)}
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.editButtonLabel"
defaultMessage="Edit"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlyoutFooter>
);
}
render() {
const {
isDetailPanelOpen,
closeDetailPanel,
autoFollowPatternName,
} = this.props;
if (!isDetailPanelOpen) {
return null;
}
const { autoFollowPatternId, closeDetailPanel } = this.props;
return (
<EuiFlyout
@ -379,14 +362,14 @@ export class DetailPanelUi extends Component {
size="m"
maxWidth={400}
>
<EuiFlyoutHeader>
<EuiTitle size="m" id="autoFollowPatternDetailsFlyoutTitle">
<h2>{autoFollowPatternName}</h2>
<h2>{autoFollowPatternId}</h2>
</EuiTitle>
</EuiFlyoutHeader>
{this.renderContent()}
{this.renderFooter()}
</EuiFlyout>
);

View file

@ -0,0 +1,178 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import PropTypes from 'prop-types';
import {
EuiButton,
EuiContextMenuPanel,
EuiContextMenuItem,
EuiPopover,
EuiPopoverTitle,
} from '@elastic/eui';
import routing from '../../../../../services/routing';
import {
FollowerIndexPauseProvider,
FollowerIndexResumeProvider,
FollowerIndexUnfollowProvider
} from '../../../../../components';
export class ContextMenuUi extends PureComponent {
static propTypes = {
iconSide: PropTypes.string,
iconType: PropTypes.string,
anchorPosition: PropTypes.string,
label: PropTypes.node,
followerIndices: PropTypes.array.isRequired,
}
state = {
isPopoverOpen: false,
}
onButtonClick = () => {
this.setState(prevState => ({
isPopoverOpen: !prevState.isPopoverOpen
}));
};
closePopover = () => {
this.setState({
isPopoverOpen: false
});
};
editFollowerIndex = (id) => {
const uri = routing.getFollowerIndexPath(id, '/edit', false);
routing.navigate(uri);
}
render() {
const { followerIndices } = this.props;
const followerIndicesLength = followerIndices.length;
const followerIndexNames = followerIndices.map((index) => index.name);
const {
iconSide = 'right',
iconType = 'arrowDown',
anchorPosition = 'rightUp',
label = (
<FormattedMessage
id="xpack.crossClusterReplication.followerIndex.contextMenu.buttonLabel"
defaultMessage="Manage follower {followerIndicesLength, plural, one {index} other {indices}}"
values={{ followerIndicesLength }}
/>
),
} = this.props;
const button = (
<EuiButton
data-test-subj="followerIndexContextMenuButton"
iconSide={iconSide}
onClick={this.onButtonClick}
iconType={iconType}
fill
>
{label}
</EuiButton>
);
const pausedFollowerIndexNames = followerIndices.filter(({ isPaused }) => isPaused).map((index) => index.name);
const activeFollowerIndices = followerIndices.filter(({ isPaused }) => !isPaused);
return (
<EuiPopover
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
withTitle
anchorPosition={anchorPosition}
repositionOnScroll
>
<EuiPopoverTitle>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndex.contextMenu.title"
defaultMessage="Follower {followerIndicesLength, plural, one {index} other {indices}} options"
values={{ followerIndicesLength }}
/>
</EuiPopoverTitle>
<EuiContextMenuPanel>
{
activeFollowerIndices.length ? (
<FollowerIndexPauseProvider onConfirm={this.closePopover}>
{(pauseFollowerIndex) => (
<EuiContextMenuItem
icon="pause"
onClick={() => pauseFollowerIndex(activeFollowerIndices)}
>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndex.contextMenu.pauseLabel"
defaultMessage="Pause replication"
/>
</EuiContextMenuItem>
)}
</FollowerIndexPauseProvider>
) : null
}
{
pausedFollowerIndexNames.length ? (
<FollowerIndexResumeProvider onConfirm={this.closePopover}>
{(resumeFollowerIndex) => (
<EuiContextMenuItem
icon="play"
onClick={() => resumeFollowerIndex(pausedFollowerIndexNames)}
>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndex.contextMenu.resumeLabel"
defaultMessage="Resume replication"
/>
</EuiContextMenuItem>
)}
</FollowerIndexResumeProvider>
) : null
}
{ followerIndexNames.length === 1 && (
<Fragment>
<EuiContextMenuItem
icon="pencil"
onClick={() => this.editFollowerIndex(followerIndexNames[0])}
>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndex.contextMenu.editLabel"
defaultMessage="Edit follower index"
/>
</EuiContextMenuItem>
</Fragment>
) }
<FollowerIndexUnfollowProvider onConfirm={this.closePopover}>
{(unfollowLeaderIndex) => (
<EuiContextMenuItem
icon="indexFlush"
onClick={() => unfollowLeaderIndex(followerIndexNames)}
>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndex.contextMenu.unfollowLabel"
defaultMessage="Unfollow leader {followerIndicesLength, plural, one {index} other {indices}}"
values={{ followerIndicesLength }}
/>
</EuiContextMenuItem>
)}
</FollowerIndexUnfollowProvider>
</EuiContextMenuPanel>
</EuiPopover>
);
}
}
export const ContextMenu = injectI18n(ContextMenuUi);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { ContextMenu } from './context_menu';

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { DetailPanel as DetailPanelView } from './detail_panel';
import { getSelectedFollowerIndex, getSelectedFollowerIndexId, getApiStatus, } from '../../../../../store/selectors';
import { SECTIONS } from '../../../../../constants';
const scope = SECTIONS.FOLLOWER_INDEX;
const mapStateToProps = (state) => ({
followerIndexId: getSelectedFollowerIndexId('detail')(state),
followerIndex: getSelectedFollowerIndex('detail')(state),
apiStatus: getApiStatus(scope)(state),
});
export const DetailPanel = connect(
mapStateToProps,
)(DetailPanelView);

View file

@ -0,0 +1,522 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { getIndexListUri } from '../../../../../../../../index_management/public/services/navigation';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiCodeEditor,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiHealth,
EuiIcon,
EuiLoadingSpinner,
EuiSpacer,
EuiText,
EuiTextColor,
EuiTitle,
} from '@elastic/eui';
import 'brace/theme/textmate';
import { ContextMenu } from '../context_menu';
import { API_STATUS } from '../../../../../constants';
export class DetailPanelUi extends Component {
static propTypes = {
apiStatus: PropTypes.string,
followerIndexId: PropTypes.string,
followerIndex: PropTypes.object,
closeDetailPanel: PropTypes.func.isRequired,
}
renderFollowerIndex() {
const {
followerIndex: {
remoteCluster,
leaderIndex,
isPaused,
shards,
maxReadRequestOperationCount,
maxOutstandingReadRequests,
maxReadRequestSize,
maxWriteRequestOperationCount,
maxWriteRequestSize,
maxOutstandingWriteRequests,
maxWriteBufferCount,
maxWriteBufferSize,
maxRetryDelay,
readPollTimeout,
},
} = this.props;
return (
<Fragment>
<EuiFlyoutBody>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.settingsTitle"
defaultMessage="Settings"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList>
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.statusLabel"
defaultMessage="Status"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{isPaused ? (
<EuiHealth color="subdued">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.pausedStatus"
defaultMessage="Paused"
/>
</EuiHealth>
) : (
<EuiHealth color="success">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.activeStatus"
defaultMessage="Active"
/>
</EuiHealth>
)}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.remoteClusterLabel"
defaultMessage="Remote cluster"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{remoteCluster}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.leaderIndexLabel"
defaultMessage="Leader index"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{leaderIndex}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
{isPaused ? (
<Fragment>
<EuiSpacer size="l" />
<EuiCallOut
size="s"
title={
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.pausedFollowerCalloutTitle"
defaultMessage="A paused follower index does not have settings or shard statistics."
/>
}
/>
</Fragment>
) : (
<Fragment>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountTitle"
defaultMessage="Max read request operation count"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{maxReadRequestOperationCount}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle"
defaultMessage="Max outstanding read requests"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{maxOutstandingReadRequests}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle"
defaultMessage="Max read request size"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{maxReadRequestSize}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle"
defaultMessage="Max write request operation count"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{maxWriteRequestOperationCount}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle"
defaultMessage="Max write request size"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{maxWriteRequestSize}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle"
defaultMessage="Max outstanding write requests"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{maxOutstandingWriteRequests}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle"
defaultMessage="Max write buffer count"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{maxWriteBufferCount}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle"
defaultMessage="Max write buffer size"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{maxWriteBufferSize}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle"
defaultMessage="Max retry delay"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{maxRetryDelay}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle"
defaultMessage="Read poll timeout"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{readPollTimeout}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
)}
<EuiSpacer size="l" />
{shards && shards.map((shard, i) => (
<Fragment key={i}>
<EuiSpacer size="m" />
<EuiTitle size="xs">
<h3>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.shardStatsTitle"
defaultMessage="Shard {id} stats"
values={{
id: shard.id
}}
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
isReadOnly
setOptions={{ maxLines: Infinity }}
value={JSON.stringify(shard, null, 2)}
editorProps={{
$blockScrolling: Infinity
}}
/>
</Fragment>
))}
</EuiDescriptionList>
</EuiFlyoutBody>
</Fragment>
);
}
renderContent() {
const {
apiStatus,
followerIndex,
} = this.props;
if (apiStatus === API_STATUS.LOADING) {
return (
<EuiFlyoutBody>
<EuiFlexGroup
justifyContent="flexStart"
alignItems="center"
gutterSize="s"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.loadingLabel"
defaultMessage="Loading follower index..."
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
);
}
if (!followerIndex) {
return (
<EuiFlyoutBody>
<EuiFlexGroup
justifyContent="flexStart"
alignItems="center"
gutterSize="s"
>
<EuiFlexItem grow={false}>
<EuiIcon size="m" type="alert" color="danger" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.notFoundLabel"
defaultMessage="Follower index not found"
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
);
}
return this.renderFollowerIndex();
}
renderFooter() {
const {
followerIndexId,
followerIndex,
closeDetailPanel,
} = this.props;
// Use ID instead of followerIndex, because followerIndex may not be loaded yet.
const indexManagementUri = getIndexListUri(`name:${followerIndexId}`);
return (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={closeDetailPanel}
>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.closeButtonLabel"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
href={indexManagementUri}
>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.viewIndexLink"
defaultMessage="View in Index Management"
/>
</EuiButton>
</EuiFlexItem>
{followerIndex && (
<EuiFlexItem grow={false}>
<ContextMenu
iconSide="left"
iconType="arrowUp"
anchorPosition="upRight"
label={(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexDetailPanel.manageButtonLabel"
defaultMessage="Manage"
/>
)}
followerIndices={[followerIndex]}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
);
}
render() {
const { followerIndexId, closeDetailPanel } = this.props;
return (
<EuiFlyout
className="ccrFollowerIndicesDetailPanel"
data-test-subj="followerIndexDetailsFlyout"
onClose={closeDetailPanel}
aria-labelledby="followerIndexDetailsFlyoutTitle"
size="m"
maxWidth={600}
>
<EuiFlyoutHeader>
<EuiTitle size="m" id="followerIndexDetailsFlyoutTitle">
<h2>{followerIndexId}</h2>
</EuiTitle>
</EuiFlyoutHeader>
{this.renderContent()}
{this.renderFooter()}
</EuiFlyout>
);
}
}
export const DetailPanel = injectI18n(DetailPanelUi);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { DetailPanel } from './detail_panel.container';

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { SECTIONS } from '../../../../../constants';
import { selectDetailFollowerIndex } from '../../../../../store/actions';
import { getApiStatus } from '../../../../../store/selectors';
import { FollowerIndicesTable as FollowerIndicesTableComponent } from './follower_indices_table';
const scope = SECTIONS.FOLLOWER_INDEX;
const mapStateToProps = (state) => ({
apiStatusDelete: getApiStatus(`${scope}-delete`)(state),
});
//
const mapDispatchToProps = (dispatch) => ({
selectFollowerIndex: (name) => dispatch(selectDetailFollowerIndex(name)),
});
export const FollowerIndicesTable = connect(
mapStateToProps,
mapDispatchToProps,
)(FollowerIndicesTableComponent);

View file

@ -0,0 +1,292 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiHealth,
EuiIcon,
EuiInMemoryTable,
EuiLink,
EuiLoadingKibana,
EuiOverlayMask,
} from '@elastic/eui';
import { API_STATUS } from '../../../../../constants';
import {
FollowerIndexPauseProvider,
FollowerIndexResumeProvider,
FollowerIndexUnfollowProvider
} from '../../../../../components';
import routing from '../../../../../services/routing';
import { ContextMenu } from '../context_menu';
export const FollowerIndicesTable = injectI18n(
class extends PureComponent {
static propTypes = {
followerIndices: PropTypes.array,
selectFollowerIndex: PropTypes.func.isRequired,
}
state = {
selectedItems: [],
}
onSearch = ({ query }) => {
const { text } = query;
const normalizedSearchText = text.toLowerCase();
this.setState({
queryText: normalizedSearchText,
});
};
editFollowerIndex = (id) => {
const uri = routing.getFollowerIndexPath(id, '/edit', false);
routing.navigate(uri);
}
getFilteredIndices = () => {
const { followerIndices } = this.props;
const { queryText } = this.state;
if(queryText) {
return followerIndices.filter(followerIndex => {
const { name, shards } = followerIndex;
const inName = name.toLowerCase().includes(queryText);
const inRemoteCluster = shards[0].remoteCluster.toLowerCase().includes(queryText);
const inLeaderIndex = shards[0].leaderIndex.toLowerCase().includes(queryText);
return inName || inRemoteCluster || inLeaderIndex;
});
}
return followerIndices.slice(0);
};
getTableColumns() {
const { intl, selectFollowerIndex } = this.props;
const actions = [
/* Pause or resume follower index */
{
render: (followerIndex) => {
const { name, isPaused } = followerIndex;
const label = isPaused
? intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexList.table.actionResumeDescription',
defaultMessage: 'Resume replication',
})
: intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexList.table.actionPauseDescription',
defaultMessage: 'Pause replication',
});
return isPaused ? (
<FollowerIndexResumeProvider>
{(resumeFollowerIndex) => (
<span onClick={() => resumeFollowerIndex(name)}>
<EuiIcon
aria-label={label}
type="play"
className="euiContextMenu__icon"
/>
<span>{label}</span>
</span>
)}
</FollowerIndexResumeProvider>
) : (
<FollowerIndexPauseProvider>
{(pauseFollowerIndex) => (
<span onClick={() => pauseFollowerIndex(followerIndex)}>
<EuiIcon
aria-label={label}
type="pause"
className="euiContextMenu__icon"
/>
<span>{label}</span>
</span>
)}
</FollowerIndexPauseProvider>
);
},
},
/* Edit follower index */
{
render: ({ name }) => {
const label = intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexList.table.actionEditDescription',
defaultMessage: 'Edit follower index',
});
return (
<span onClick={() => this.editFollowerIndex(name)}>
<EuiIcon
aria-label={label}
type="pencil"
className="euiContextMenu__icon"
/>
<span>{label}</span>
</span>
);
},
},
/* Unfollow leader index */
{
render: ({ name }) => {
const label = intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexList.table.actionUnfollowDescription',
defaultMessage: 'Unfollow leader index',
});
return (
<FollowerIndexUnfollowProvider>
{(unfollowLeaderIndex) => (
<span onClick={() => unfollowLeaderIndex(name)}>
<EuiIcon
aria-label={label}
type="indexFlush"
className="euiContextMenu__icon"
/>
<span>{label}</span>
</span>
)}
</FollowerIndexUnfollowProvider>
);
},
},
];
return [{
field: 'name',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexList.table.nameColumnTitle',
defaultMessage: 'Name',
}),
sortable: true,
truncateText: false,
render: (name) => {
return (
<EuiLink onClick={() => selectFollowerIndex(name)}>
{name}
</EuiLink>
);
}
}, {
field: 'isPaused',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexList.table.statusColumnTitle',
defaultMessage: 'Status',
}),
truncateText: true,
sortable: true,
render: (isPaused) => {
return isPaused ? (
<EuiHealth color="subdued">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexList.table.pausedStatus"
defaultMessage="Paused"
/>
</EuiHealth>
) : (
<EuiHealth color="success">
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexList.table.activeStatus"
defaultMessage="Active"
/>
</EuiHealth>
);
}
}, {
field: 'remoteCluster',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexList.table.clusterColumnTitle',
defaultMessage: 'Remote cluster',
}),
truncateText: true,
sortable: true,
}, {
field: 'leaderIndex',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexList.table.leaderIndexColumnTitle',
defaultMessage: 'Leader index',
}),
truncateText: true,
sortable: true,
}, {
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexList.table.actionsColumnTitle',
defaultMessage: 'Actions',
}),
actions,
width: '100px',
}];
}
renderLoading = () => {
const { apiStatusDelete } = this.props;
if (apiStatusDelete === API_STATUS.DELETING) {
return (
<EuiOverlayMask>
<EuiLoadingKibana size="xl"/>
</EuiOverlayMask>
);
}
return null;
};
render() {
const {
selectedItems,
} = this.state;
const sorting = {
sort: {
field: 'name',
direction: 'asc',
}
};
const pagination = {
initialPageSize: 20,
pageSizeOptions: [10, 20, 50]
};
const selection = {
onSelectionChange: (selectedItems) => this.setState({ selectedItems })
};
const search = {
toolsLeft: selectedItems.length ? (
<ContextMenu
followerIndices={selectedItems}
/>
) : undefined,
onChange: this.onSearch,
box: {
incremental: true,
},
};
return (
<Fragment>
<EuiInMemoryTable
items={this.getFilteredIndices()}
itemId="name"
columns={this.getTableColumns()}
search={search}
pagination={pagination}
sorting={sorting}
selection={selection}
isSelectable={true}
/>
{this.renderLoading()}
</Fragment>
);
}
}
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { FollowerIndicesTable } from './follower_indices_table.container';

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { FollowerIndicesTable } from './follower_indices_table';
export { DetailPanel } from './detail_panel';

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { SECTIONS } from '../../../constants';
import {
getListFollowerIndices,
getSelectedFollowerIndexId,
getApiStatus,
getApiError,
isApiAuthorized,
} from '../../../store/selectors';
import {
loadFollowerIndices, selectDetailFollowerIndex,
} from '../../../store/actions';
import { FollowerIndicesList as FollowerIndicesListView } from './follower_indices_list';
const scope = SECTIONS.FOLLOWER_INDEX;
const mapStateToProps = (state) => ({
followerIndices: getListFollowerIndices(state),
followerIndexId: getSelectedFollowerIndexId('detail')(state),
apiStatus: getApiStatus(scope)(state),
apiError: getApiError(scope)(state),
isAuthorized: isApiAuthorized(scope)(state),
});
const mapDispatchToProps = dispatch => ({
loadFollowerIndices: (inBackground) => dispatch(loadFollowerIndices(inBackground)),
selectFollowerIndex: (id) => dispatch(selectDetailFollowerIndex(id)),
});
export const FollowerIndicesList = connect(
mapStateToProps,
mapDispatchToProps
)(FollowerIndicesListView);

View file

@ -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 React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import routing from '../../../services/routing';
import { extractQueryParams } from '../../../services/query_params';
import { API_STATUS } from '../../../constants';
import { SectionLoading, SectionError, SectionUnauthorized } from '../../../components';
import { FollowerIndicesTable, DetailPanel } from './components';
const REFRESH_RATE_MS = 30000;
const getQueryParamName = ({ location: { search } }) => {
const { name } = extractQueryParams(search);
return name ? decodeURIComponent(name) : null;
};
export const FollowerIndicesList = injectI18n(
class extends PureComponent {
static propTypes = {
loadFollowerIndices: PropTypes.func,
selectFollowerIndex: PropTypes.func,
followerIndices: PropTypes.array,
apiStatus: PropTypes.string,
apiError: PropTypes.object,
}
static getDerivedStateFromProps({ followerIndexId }, { lastFollowerIndexId }) {
if (followerIndexId !== lastFollowerIndexId) {
return {
lastFollowerIndexId: followerIndexId,
isDetailPanelOpen: !!followerIndexId,
};
}
return null;
}
state = {
lastFollowerIndexId: null,
isDetailPanelOpen: false,
};
componentDidMount() {
const { loadFollowerIndices, selectFollowerIndex, history } = this.props;
loadFollowerIndices();
// Select the pattern in the URL query params
selectFollowerIndex(getQueryParamName(history));
// Interval to load follower indices in the background passing "true" to the fetch method
this.interval = setInterval(() => loadFollowerIndices(true), REFRESH_RATE_MS);
}
componentDidUpdate(prevProps, prevState) {
const { history } = this.props;
const { lastFollowerIndexId } = this.state;
/**
* Each time our state is updated (through getDerivedStateFromProps())
* we persist the follower index id to query params for deep linking
*/
if (lastFollowerIndexId !== prevState.lastFollowerIndexId) {
if(!lastFollowerIndexId) {
history.replace({
search: '',
});
} else {
history.replace({
search: `?name=${encodeURIComponent(lastFollowerIndexId)}`,
});
}
}
}
componentWillUnmount() {
clearInterval(this.interval);
}
renderHeader() {
const { isAuthorized } = this.props;
return (
<Fragment>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexStart">
<EuiFlexItem grow={false}>
<EuiText>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexList.followerIndicesDescription"
defaultMessage="A follower index replicates a leader index on a remote cluster."
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isAuthorized && (
<EuiButton
{...routing.getRouterLinkProps('/follower_indices/add')}
fill
iconType="plusInCircle"
>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexList.addFollowerButtonLabel"
defaultMessage="Create a follower index"
/>
</EuiButton>
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</Fragment>
);
}
renderContent(isEmpty) {
const { apiError, isAuthorized, intl } = this.props;
if (!isAuthorized) {
return (
<SectionUnauthorized
title={(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexList.permissionErrorTitle"
defaultMessage="Permission error"
/>
)}
>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexList.noPermissionText"
defaultMessage="You do not have permission to view or add follower indices."
/>
</SectionUnauthorized>
);
}
if (apiError) {
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.followerIndexList.loadingErrorTitle',
defaultMessage: 'Error loading follower indices',
});
return (
<Fragment>
<SectionError title={title} error={apiError} />
<EuiSpacer size="m" />
</Fragment>
);
}
if (isEmpty) {
return this.renderEmpty();
}
return this.renderList();
}
renderEmpty() {
return (
<EuiEmptyPrompt
iconType="managementApp"
title={(
<h1>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexList.emptyPromptTitle"
defaultMessage="Create your first follower index"
/>
</h1>
)}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexList.emptyPromptDescription"
defaultMessage="Use a follower index to replicate a leader index on a remote cluster."
/>
</p>
</Fragment>
}
actions={
<EuiButton
{...routing.getRouterLinkProps('/follower_indices/add')}
fill
iconType="plusInCircle"
>
<FormattedMessage
id="xpack.crossClusterReplication.addFollowerButtonLabel"
defaultMessage="Create a follower index"
/>
</EuiButton>
}
/>
);
}
renderList() {
const {
selectFollowerIndex,
followerIndices,
apiStatus,
} = this.props;
const { isDetailPanelOpen } = this.state;
if (apiStatus === API_STATUS.LOADING) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexList.loadingTitle"
defaultMessage="Loading follower indices..."
/>
</SectionLoading>
);
}
return (
<Fragment>
<FollowerIndicesTable followerIndices={followerIndices} />
{isDetailPanelOpen && <DetailPanel closeDetailPanel={() => selectFollowerIndex(null)} />}
</Fragment>
);
}
render() {
const { followerIndices, apiStatus } = this.props;
const isEmpty = apiStatus === API_STATUS.IDLE && !followerIndices.length;
return (
<Fragment>
{!isEmpty && this.renderHeader()}
{this.renderContent(isEmpty)}
</Fragment>
);
}
}
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { FollowerIndicesList } from './follower_indices_list.container';

View file

@ -7,12 +7,14 @@
import { connect } from 'react-redux';
import { SECTIONS } from '../../constants';
import { getListAutoFollowPatterns, isApiAuthorized } from '../../store/selectors';
import { getListAutoFollowPatterns, getListFollowerIndices, isApiAuthorized } from '../../store/selectors';
import { CrossClusterReplicationHome as CrossClusterReplicationHomeView } from './home';
const mapStateToProps = (state) => ({
autoFollowPatterns: getListAutoFollowPatterns(state),
isAutoFollowApiAuthorized: isApiAuthorized(SECTIONS.AUTO_FOLLOW_PATTERN)(state)
isAutoFollowApiAuthorized: isApiAuthorized(SECTIONS.AUTO_FOLLOW_PATTERN)(state),
followerIndices: getListFollowerIndices(state),
isFollowerIndexApiAuthorized: isApiAuthorized(SECTIONS.FOLLOWER_INDEX)(state),
});
export const CrossClusterReplicationHome = connect(

View file

@ -4,130 +4,97 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import React, { PureComponent } from 'react';
import { Route, Switch } from 'react-router-dom';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import chrome from 'ui/chrome';
import { MANAGEMENT_BREADCRUMB } from 'ui/management';
import { BASE_PATH } from '../../../../common/constants';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiPageBody,
EuiPageContent,
EuiSpacer,
EuiText,
EuiTab,
EuiTabs,
EuiTitle,
} from '@elastic/eui';
import { BASE_PATH } from '../../../../common/constants';
import { listBreadcrumb } from '../../services/breadcrumbs';
import routing from '../../services/routing';
import { AutoFollowPatternList } from './auto_follow_pattern_list';
import { SectionUnauthorized } from '../../components';
import { FollowerIndicesList } from './follower_indices_list';
export const CrossClusterReplicationHome = injectI18n(
class extends PureComponent {
static propTypes = {
autoFollowPatterns: PropTypes.array,
state = {
activeSection: 'follower_indices'
}
state = {
sectionActive: 'auto-follow'
}
tabs = [{
id: 'follower_indices',
name: (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.followerIndicesTitle"
defaultMessage="Follower indices"
/>
)
}, {
id: 'auto_follow_patterns',
name: (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.autoFollowPatternsTitle"
defaultMessage="Auto-follow patterns"
/>
)
}]
componentDidMount() {
chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb ]);
}
getHeaderSection() {
const { isAutoFollowApiAuthorized, autoFollowPatterns } = this.props;
// We want to show the title when the user isn't authorized.
if (isAutoFollowApiAuthorized && !autoFollowPatterns.length) {
return null;
}
return (
<Fragment>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.crossClusterReplicationTitle"
defaultMessage="Cross Cluster Replication"
/>
</h1>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.autoFollowPatternsTitle"
defaultMessage="Auto-follow patterns"
/>
</h2>
</EuiTitle>
<EuiText>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.autoFollowPatternsDescription"
defaultMessage="Auto-follow patterns replicate leader indices from a remote
cluster to follower indices on the local cluster."
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isAutoFollowApiAuthorized && (
<EuiButton
{...routing.getRouterLinkProps('/auto_follow_patterns/add')}
fill
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.addAutofollowPatternButtonLabel"
defaultMessage="Create an auto-follow pattern"
/>
</EuiButton>
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
</Fragment>
);
static getDerivedStateFromProps(props) {
const { match: { params: { section } } } = props;
return {
activeSection: section
};
}
getUnauthorizedSection() {
const { isAutoFollowApiAuthorized } = this.props;
if (!isAutoFollowApiAuthorized) {
return (
<SectionUnauthorized>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.noPermissionText"
defaultMessage="You do not have permission to view or add auto-follow patterns."
/>
</SectionUnauthorized>
);
}
onSectionChange = (section) => {
routing.navigate(`/${section}`);
}
render() {
return (
<EuiPageBody>
<EuiPageContent
horizontalPosition="center"
>
{this.getHeaderSection()}
{this.getUnauthorizedSection()}
<EuiPageContent>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.crossClusterReplicationTitle"
defaultMessage="Cross Cluster Replication"
/>
</h1>
</EuiTitle>
<EuiSpacer size="s" />
<EuiTabs>
{this.tabs.map(tab => (
<EuiTab
onClick={() => this.onSectionChange(tab.id)}
isSelected={tab.id === this.state.activeSection}
key={tab.id}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
<EuiSpacer size="m" />
<Switch>
<Route exact path={`${BASE_PATH}/follower_indices`} component={FollowerIndicesList} />
<Route exact path={`${BASE_PATH}/auto_follow_patterns`} component={AutoFollowPatternList} />
</Switch>
</EuiPageContent>

View file

@ -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';

View file

@ -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);
};
export const loadAutoFollowStats = () => (
httpClient.get(`${apiPrefix}/stats/auto-follow`).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)
);
/* 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)
);

View file

@ -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`;

View file

@ -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]));
};

View file

@ -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;
};

View file

@ -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 [(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.errors.nameMissing"
defaultMessage="Name is required."
/>
)];
}
if (beginsWithPeriod(value)) {
return [(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.errors.nameBeginsWithPeriod"
defaultMessage="Name can't begin with a period."
/>
)];
}
const illegalCharacters = findIllegalCharacters(value);
if (illegalCharacters.length) {
return [(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.errors.nameIllegalCharacters"
defaultMessage="Remove the characters {characterList} from your name."
values={{ characterList: <strong>{illegalCharacters.join(' ')}</strong> }}
/>
)];
}
return undefined;
};
export const leaderIndexValidator = (value) => {
if (isEmpty(value)) {
return [(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.errors.leaderIndexMissing"
defaultMessage="Leader index is required."
/>
)];
}
const illegalCharacters = findIllegalCharacters(value);
if (illegalCharacters.length) {
return [(
<FormattedMessage
id="xpack.crossClusterReplication.followerIndexForm.errors.leaderIndexIllegalCharacters"
defaultMessage="Remove the characters {characterList} from your leader index."
values={{ characterList: <strong>{illegalCharacters.join(' ')}</strong> }}
/>
)];
}
return undefined;
};

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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';

View file

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

View file

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

View file

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

View file

@ -5,6 +5,6 @@
*/
export * from './auto_follow_pattern';
export * from './follower_index';
export * from './api';
export * from './ccr';

View file

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

View file

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

View file

@ -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,
},
};

View file

@ -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';

View file

@ -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 };

View file

@ -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;
}
};

View file

@ -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,
});

View file

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

View file

@ -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__());

View file

@ -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';

View file

@ -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}`
});
}

View file

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

View file

@ -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'
});
};

View file

@ -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",
}
`;

View file

@ -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,
};
}

View file

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

View file

@ -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();
});
});
});

View file

@ -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}`,

View file

@ -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
*/
server.route({
path: `${API_BASE_PATH}/stats`,
method: 'GET',
config: {
pre: [ licensePreRouting ]
},
handler: getStatsHandler,
});
/**
* Returns Auto-follow stats
*/
server.route({
path: `${API_BASE_PATH}/stats/auto-follow`,
path: `${API_BASE_PATH}/stats/auto_follow`,
method: 'GET',
config: {
pre: [ licensePreRouting ]
},
handler: async (request) => {
const { autoFollow } = await getStatsHandler(request);
return autoFollow;
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 whether the user has CCR permissions
*/
server.route({
path: `${API_BASE_PATH}/permissions`,
method: 'GET',
config: {
pre: [ licensePreRouting ]
},
handler: async (request) => {
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);
}
},
});
};

View file

@ -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
};
},
});
};

View file

@ -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');
});
});
});

View file

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

View file

@ -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.

View file

@ -0,0 +1,539 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RemoteClusterForm renders untouched state 1`] = `
Array [
<div
class="euiForm"
>
<div
aria-describedby="mockId"
aria-labelledby="mockId-title"
class="euiDescribedFormGroup euiDescribedFormGroup--fullWidth"
role="group"
>
<div
class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem"
>
<h4
class="euiTitle euiTitle--small euiTitle euiTitle--xsmall euiDescribedFormGroup__title"
id="mockId-title"
>
Name
</h4>
<div
class="euiText euiText--small euiDescribedFormGroup__description"
id="mockId"
>
<div
class="euiTextColor euiTextColor--subdued"
>
A unique name for the remote cluster.
</div>
</div>
</div>
<div
class="euiFlexItem euiDescribedFormGroup__fields euiDescribedFormGroup__fieldPadding--xsmall"
>
<div
class="euiFormRow euiFormRow--fullWidth"
data-test-subj="remoteClusterFormNameFormRow"
id="mockId-row"
>
<label
aria-invalid="false"
class="euiFormLabel"
for="mockId"
>
Name
</label>
<div
class="euiFormControlLayout euiFormControlLayout--fullWidth"
>
<div
class="euiFormControlLayout__childrenWrapper"
>
<input
aria-describedby="mockId-help"
class="euiFieldText euiFieldText--fullWidth"
id="mockId"
type="text"
value=""
/>
</div>
</div>
<div
class="euiFormHelpText euiFormRow__text"
id="mockId-help"
>
Name can only contain letters, numbers, underscores, and dashes.
</div>
</div>
</div>
</div>
</div>
<div
aria-describedby="mockId"
aria-labelledby="mockId-title"
class="euiDescribedFormGroup euiDescribedFormGroup--fullWidth"
role="group"
>
<div
class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem"
>
<h4
class="euiTitle euiTitle--small euiTitle euiTitle--xsmall euiDescribedFormGroup__title"
id="mockId-title"
>
Seed nodes for cluster discovery
</h4>
<div
class="euiText euiText--small euiDescribedFormGroup__description"
id="mockId"
>
<div
class="euiTextColor euiTextColor--subdued"
>
<p>
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.
</p>
</div>
</div>
</div>
<div
class="euiFlexItem euiDescribedFormGroup__fields euiDescribedFormGroup__fieldPadding--xsmall"
>
<div
class="euiFormRow euiFormRow--fullWidth"
data-test-subj="remoteClusterFormSeedNodesFormRow"
id="mockId-row"
>
<label
aria-invalid="false"
class="euiFormLabel"
for="mockId"
>
Seed nodes
</label>
<div
aria-describedby="mockId-help"
aria-expanded="false"
aria-haspopup="listbox"
class="euiComboBox euiComboBox--fullWidth"
role="combobox"
>
<div
class="euiFormControlLayout euiFormControlLayout--fullWidth"
>
<div
class="euiFormControlLayout__childrenWrapper"
>
<div
class="euiComboBox__inputWrap euiComboBox__inputWrap--fullWidth euiComboBox__inputWrap-isClearable"
data-test-subj="comboBoxInput"
tabindex="-1"
>
<p
class="euiComboBoxPlaceholder"
>
host:port
</p>
<div
class="euiComboBox__input"
style="font-size:14px;display:inline-block"
>
<input
data-test-subj="comboBoxSearchInput"
id="mockId"
role="textbox"
style="box-sizing:content-box;width:1px"
value=""
/>
<div
style="position:absolute;top:0;left:0;visibility:hidden;height:0;overflow:scroll;white-space:pre"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="euiFormHelpText euiFormRow__text"
id="mockId-help"
>
An IP address or host name, followed by the
<a
class="euiLink euiLink--primary"
href="undefinedguide/en/elasticsearch/reference/undefined/modules-transport.html"
rel="noopener noreferrer"
target="_blank"
>
transport port
</a>
of the remote cluster.
</div>
</div>
</div>
</div>
</div>
<div
aria-describedby="mockId"
aria-labelledby="mockId-title"
class="euiDescribedFormGroup euiDescribedFormGroup--fullWidth"
role="group"
>
<div
class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem"
>
<h4
class="euiTitle euiTitle--small euiTitle euiTitle--xsmall euiDescribedFormGroup__title"
id="mockId-title"
>
Make remote cluster optional
</h4>
<div
class="euiText euiText--small euiDescribedFormGroup__description"
id="mockId"
>
<div
class="euiTextColor euiTextColor--subdued"
>
<p>
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
<strong>
Skip if unavailable
</strong>
.
<a
class="euiLink euiLink--primary"
href="undefinedguide/en/elasticsearch/reference/undefined/modules-cross-cluster-search.html#_skipping_disconnected_clusters"
rel="noopener noreferrer"
target="_blank"
>
Learn more.
</a>
</p>
</div>
</div>
</div>
<div
class="euiFlexItem euiDescribedFormGroup__fields euiDescribedFormGroup__fieldPadding--xsmall"
>
<div
class="euiFormRow euiFormRow--hasEmptyLabelSpace euiFormRow--fullWidth remoteClusterSkipIfUnavailableSwitch"
data-test-subj="remoteClusterFormSkipUnavailableFormRow"
id="mockId-row"
>
<div
class="euiSwitch"
>
<input
class="euiSwitch__input"
id="mockId"
type="checkbox"
/>
<span
class="euiSwitch__body"
>
<span
class="euiSwitch__thumb"
/>
<span
class="euiSwitch__track"
>
<svg
class="euiIcon euiIcon--medium euiSwitch__icon"
focusable="false"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.293 8L3.146 3.854a.5.5 0 1 1 .708-.708L8 7.293l4.146-4.147a.5.5 0 0 1 .708.708L8.707 8l4.147 4.146a.5.5 0 0 1-.708.708L8 8.707l-4.146 4.147a.5.5 0 0 1-.708-.708L7.293 8z"
/>
</svg>
<svg
class="euiIcon euiIcon--medium euiSwitch__icon euiSwitch__icon--checked"
focusable="false"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.5 12a.502.502 0 0 1-.354-.146l-4-4a.502.502 0 0 1 .708-.708L6.5 10.793l6.646-6.647a.502.502 0 0 1 .708.708l-7 7A.502.502 0 0 1 6.5 12"
fill-rule="evenodd"
/>
</svg>
</span>
</span>
<label
class="euiSwitch__label"
for="mockId"
>
Skip if unavailable
</label>
</div>
</div>
</div>
</div>
</div>
</div>,
<div
class="euiSpacer euiSpacer--l"
/>,
<div
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
>
<button
class="euiButton euiButton--secondary euiButton--fill"
data-test-subj="remoteClusterFormSaveButton"
type="button"
>
<span
class="euiButton__content"
>
<svg
aria-hidden="true"
class="euiIcon euiIcon--medium euiButton__icon"
focusable="false"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.5 12a.502.502 0 0 1-.354-.146l-4-4a.502.502 0 0 1 .708-.708L6.5 10.793l6.646-6.647a.502.502 0 0 1 .708.708l-7 7A.502.502 0 0 1 6.5 12"
fill-rule="evenodd"
/>
</svg>
<span
class="euiButton__text"
>
Save
</span>
</span>
</button>
</div>
</div>,
]
`;
exports[`RemoteClusterForm validation renders invalid state and a global form error when the user tries to submit an invalid form 1`] = `
Array [
<div
class="euiFormRow euiFormRow--fullWidth"
data-test-subj="remoteClusterFormNameFormRow"
id="mockId-row"
>
<label
aria-invalid="true"
class="euiFormLabel euiFormLabel-isInvalid"
for="mockId"
>
Name
</label>
<div
class="euiFormControlLayout euiFormControlLayout--fullWidth"
>
<div
class="euiFormControlLayout__childrenWrapper"
>
<input
aria-describedby="mockId-help mockId-error-0"
class="euiFieldText euiFieldText--fullWidth"
id="mockId"
type="text"
value=""
/>
</div>
</div>
<div
aria-live="polite"
class="euiFormErrorText euiFormRow__text"
id="mockId-error-0"
>
Name is required.
</div>
<div
class="euiFormHelpText euiFormRow__text"
id="mockId-help"
>
Name can only contain letters, numbers, underscores, and dashes.
</div>
</div>,
<div
class="euiFormRow euiFormRow--fullWidth"
data-test-subj="remoteClusterFormSeedNodesFormRow"
id="mockId-row"
>
<label
aria-invalid="true"
class="euiFormLabel euiFormLabel-isInvalid"
for="mockId"
>
Seed nodes
</label>
<div
aria-describedby="mockId-help mockId-error-0"
aria-expanded="false"
aria-haspopup="listbox"
class="euiComboBox euiComboBox-isInvalid euiComboBox--fullWidth"
role="combobox"
>
<div
class="euiFormControlLayout euiFormControlLayout--fullWidth"
>
<div
class="euiFormControlLayout__childrenWrapper"
>
<div
class="euiComboBox__inputWrap euiComboBox__inputWrap--fullWidth euiComboBox__inputWrap-isClearable"
data-test-subj="comboBoxInput"
tabindex="-1"
>
<p
class="euiComboBoxPlaceholder"
>
host:port
</p>
<div
class="euiComboBox__input"
style="font-size: 14px; display: inline-block;"
>
<input
data-test-subj="comboBoxSearchInput"
id="mockId"
role="textbox"
style="box-sizing: content-box; width: 2px;"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
</div>
</div>
</div>
</div>
<div
aria-live="polite"
class="euiFormErrorText euiFormRow__text"
id="mockId-error-0"
>
At least one seed node is required.
</div>
<div
class="euiFormHelpText euiFormRow__text"
id="mockId-help"
>
An IP address or host name, followed by the
<a
class="euiLink euiLink--primary"
href="undefinedguide/en/elasticsearch/reference/undefined/modules-transport.html"
rel="noopener noreferrer"
target="_blank"
>
transport port
</a>
of the remote cluster.
</div>
</div>,
<div
class="euiFormRow euiFormRow--hasEmptyLabelSpace euiFormRow--fullWidth remoteClusterSkipIfUnavailableSwitch"
data-test-subj="remoteClusterFormSkipUnavailableFormRow"
id="mockId-row"
>
<div
class="euiSwitch"
>
<input
class="euiSwitch__input"
id="mockId"
type="checkbox"
/>
<span
class="euiSwitch__body"
>
<span
class="euiSwitch__thumb"
/>
<span
class="euiSwitch__track"
>
<svg
class="euiIcon euiIcon--medium euiSwitch__icon"
focusable="false"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.293 8L3.146 3.854a.5.5 0 1 1 .708-.708L8 7.293l4.146-4.147a.5.5 0 0 1 .708.708L8.707 8l4.147 4.146a.5.5 0 0 1-.708.708L8 8.707l-4.146 4.147a.5.5 0 0 1-.708-.708L7.293 8z"
/>
</svg>
<svg
class="euiIcon euiIcon--medium euiSwitch__icon euiSwitch__icon--checked"
focusable="false"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.5 12a.502.502 0 0 1-.354-.146l-4-4a.502.502 0 0 1 .708-.708L6.5 10.793l6.646-6.647a.502.502 0 0 1 .708.708l-7 7A.502.502 0 0 1 6.5 12"
fill-rule="evenodd"
/>
</svg>
</span>
</span>
<label
class="euiSwitch__label"
for="mockId"
>
Skip if unavailable
</label>
</div>
</div>,
<div
class="euiCallOut euiCallOut--danger"
data-test-subj="remoteClusterFormGlobalError"
>
<div
class="euiCallOutHeader"
>
<svg
aria-hidden="true"
class="euiIcon euiIcon--medium euiCallOutHeader__icon"
focusable="false"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.293 8L3.146 3.854a.5.5 0 1 1 .708-.708L8 7.293l4.146-4.147a.5.5 0 0 1 .708.708L8.707 8l4.147 4.146a.5.5 0 0 1-.708.708L8 8.707l-4.146 4.147a.5.5 0 0 1-.708-.708L7.293 8z"
/>
</svg>
<span
class="euiCallOutHeader__title"
>
Fix errors before continuing.
</span>
</div>
</div>,
]
`;

View file

@ -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 = (
<FormattedMessage
id="xpack.remoteClusters.form.errors.nameMissing"
defaultMessage="Name is required."
/>
);
} else if (name.match(/[^a-zA-Z\d\-_]/)) {
errors.name = (
<FormattedMessage
id="xpack.remoteClusters.form.errors.illegalCharacters"
defaultMessage="Name contains invalid characters."
/>
);
}
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 = (
<FormattedMessage
id="xpack.remoteClusters.form.errors.seedMissing"
defaultMessage="At least one seed node is required."
/>
);
}
}
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
>
<EuiFormRow
data-test-subj="remoteClusterFormSeedNodesFormRow"
label={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldSeedsLabel"
@ -327,7 +267,17 @@ export const RemoteClusterForm = injectI18n(
helpText={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSeedsHelpText"
defaultMessage="An IP address or host name, followed by the transport port of the remote cluster."
defaultMessage="An IP address or host name, followed by the {transportPort} of the remote cluster."
values={{
transportPort: (
<EuiLink href={transportPortUrl} target="_blank">
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSeedsHelpText.transportPortLinkText"
defaultMessage="transport port"
/>
</EuiLink>
),
}}
/>
)}
isInvalid={showErrors}
@ -404,6 +354,7 @@ export const RemoteClusterForm = injectI18n(
fullWidth
>
<EuiFormRow
data-test-subj="remoteClusterFormSkipUnavailableFormRow"
className="remoteClusterSkipIfUnavailableSwitch"
hasEmptyLabelSpace
fullWidth
@ -477,6 +428,7 @@ export const RemoteClusterForm = injectI18n(
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="remoteClusterFormSaveButton"
color="secondary"
iconType="check"
onClick={this.save}
@ -559,6 +511,7 @@ export const RemoteClusterForm = injectI18n(
<Fragment>
<EuiSpacer size="m" />
<EuiCallOut
data-test-subj="remoteClusterFormGlobalError"
title={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.errorTitle"
@ -614,6 +567,7 @@ export const RemoteClusterForm = injectI18n(
fullWidth
>
<EuiFormRow
data-test-subj="remoteClusterFormNameFormRow"
label={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNameLabel"

View file

@ -0,0 +1,46 @@
/*
* 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 { mountWithIntl, renderWithIntl } from 'test_utils/enzyme_helpers';
import { findTestSubject, takeMountedSnapshot } from '@elastic/eui/lib/test';
import { RemoteClusterForm } from './remote_cluster_form';
// Make sure we have deterministic aria IDs.
jest.mock('@elastic/eui/lib/components/form/form_row/make_id', () => () => 'mockId');
describe('RemoteClusterForm', () => {
test(`renders untouched state`, () => {
const component = renderWithIntl(
<RemoteClusterForm
save={() => {}}
/>
);
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(
<RemoteClusterForm save={() => {}}/>
);
findTestSubject(component, 'remoteClusterFormSaveButton').simulate('click');
const fieldsSnapshot = [
'remoteClusterFormNameFormRow',
'remoteClusterFormSeedNodesFormRow',
'remoteClusterFormSkipUnavailableFormRow',
'remoteClusterFormGlobalError',
].map(testSubject => {
const mountedField = findTestSubject(component, testSubject);
return takeMountedSnapshot(mountedField);
});
expect(fieldsSnapshot).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,161 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`validateName rejects empty input ' ' 1`] = `
<FormattedMessage
defaultMessage="Name is required."
id="xpack.remoteClusters.form.errors.nameMissing"
values={Object {}}
/>
`;
exports[`validateName rejects empty input 'null' 1`] = `
<FormattedMessage
defaultMessage="Name is required."
id="xpack.remoteClusters.form.errors.nameMissing"
values={Object {}}
/>
`;
exports[`validateName rejects empty input 'undefined' 1`] = `
<FormattedMessage
defaultMessage="Name is required."
id="xpack.remoteClusters.form.errors.nameMissing"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters ' ' 1`] = `
<FormattedMessage
defaultMessage="Name is required."
id="xpack.remoteClusters.form.errors.nameMissing"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '!' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '#' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '$' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '%' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '&' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '(' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters ')' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '*' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '+' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters ',' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '.' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '<' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '>' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '?' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '@' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;
exports[`validateName rejects invalid characters '^' 1`] = `
<FormattedMessage
defaultMessage="Name contains invalid characters."
id="xpack.remoteClusters.form.errors.illegalCharacters"
values={Object {}}
/>
`;

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`validateSeeds rejects empty seeds when there's no input 1`] = `
<FormattedMessage
defaultMessage="At least one seed node is required."
id="xpack.remoteClusters.form.errors.seedMissing"
values={Object {}}
/>
`;

View file

@ -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';

View file

@ -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 (
<FormattedMessage
id="xpack.remoteClusters.form.errors.nameMissing"
defaultMessage="Name is required."
/>
);
}
if (name.match(/[^a-zA-Z\d\-_]/)) {
return (
<FormattedMessage
id="xpack.remoteClusters.form.errors.illegalCharacters"
defaultMessage="Name contains invalid characters."
/>
);
}
return null;
}

View file

@ -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();
});
});
});
});

Some files were not shown because too many files have changed in this diff Show more