mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
* [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:
parent
cde31bfe42
commit
85572227f2
129 changed files with 8872 additions and 1220 deletions
|
@ -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, ',');
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -7,3 +7,4 @@
|
|||
export * from './plugin';
|
||||
export * from './base_path';
|
||||
export * from './app';
|
||||
export * from './settings';
|
||||
|
|
|
@ -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',
|
||||
};
|
|
@ -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;
|
||||
}, {})
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -10,3 +10,10 @@ export {
|
|||
} from './auto_follow_pattern';
|
||||
|
||||
export { esErrors } from './es_errors';
|
||||
|
||||
export {
|
||||
getFollowerIndexStatsMock,
|
||||
getFollowerIndexListStatsMock,
|
||||
getFollowerIndexInfoMock,
|
||||
getFollowerIndexListInfoMock,
|
||||
} from './follower_index';
|
||||
|
|
|
@ -5,3 +5,10 @@
|
|||
.ccrFollowerIndicesHelpText {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Prevent context menu popover appearing above confirmation modal
|
||||
*/
|
||||
.ccrFollowerIndicesDetailPanel {
|
||||
z-index: $euiZMask - 1; /* 1 */
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
`;
|
|
@ -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];
|
||||
});
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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,
|
||||
};
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
export const SECTIONS = {
|
||||
AUTO_FOLLOW_PATTERN: 'autoFollowPattern',
|
||||
INDEX_FOLLOWER: 'indexFollower',
|
||||
FOLLOWER_INDEX: 'followerIndex',
|
||||
REMOTE_CLUSTER: 'remoteCluster',
|
||||
CCR_STATS: 'ccrStats',
|
||||
};
|
||||
|
|
|
@ -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 => ({
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
|
@ -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';
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
|
@ -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';
|
|
@ -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())
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
|
@ -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';
|
|
@ -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);
|
|
@ -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);
|
|
@ -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';
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
|
@ -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';
|
|
@ -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';
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
|
@ -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';
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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]));
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
|
@ -5,6 +5,6 @@
|
|||
*/
|
||||
|
||||
export * from './auto_follow_pattern';
|
||||
|
||||
export * from './follower_index';
|
||||
export * from './api';
|
||||
export * from './ccr';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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__());
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}`
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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'
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>,
|
||||
]
|
||||
`;
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 {}}
|
||||
/>
|
||||
`;
|
|
@ -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 {}}
|
||||
/>
|
||||
`;
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue