[CCR] Remote Clusters and Cross-cluster Replication apps (#26777)

This commit is contained in:
CJ Cenizal 2018-12-18 17:59:10 -08:00 committed by GitHub
parent 9df85816c0
commit 2371e58590
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
222 changed files with 11540 additions and 6 deletions

View file

@ -18,6 +18,7 @@
"tsvb": "src/legacy/core_plugins/metrics",
"xpack.apm": "x-pack/plugins/apm",
"xpack.beatsManagement": "x-pack/plugins/beats_management",
"xpack.crossClusterReplication": "x-pack/plugins/cross_cluster_replication",
"xpack.graph": "x-pack/plugins/graph",
"xpack.grokDebugger": "x-pack/plugins/grokdebugger",
"xpack.idxMgmt": "x-pack/plugins/index_management",
@ -26,6 +27,7 @@
"xpack.ml": "x-pack/plugins/ml",
"xpack.logstash": "x-pack/plugins/logstash",
"xpack.monitoring": "x-pack/plugins/monitoring",
"xpack.remoteClusters": "x-pack/plugins/remote_clusters",
"xpack.reporting": "x-pack/plugins/reporting",
"xpack.rollupJobs": "x-pack/plugins/rollup",
"xpack.searchProfiler": "x-pack/plugins/searchprofiler",

View file

@ -29,3 +29,9 @@ export {
INDEX_PATTERN_ILLEGAL_CHARACTERS,
INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE,
} from './constants';
export {
ILLEGAL_CHARACTERS,
CONTAINS_SPACES,
validateIndexPattern,
} from './validate';

View file

@ -0,0 +1,24 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export {
ILLEGAL_CHARACTERS,
CONTAINS_SPACES,
validateIndexPattern,
} from './validate_index_pattern';

View file

@ -0,0 +1,55 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from '../constants';
export const ILLEGAL_CHARACTERS = 'ILLEGAL_CHARACTERS';
export const CONTAINS_SPACES = 'CONTAINS_SPACES';
function findIllegalCharacters(indexPattern) {
const illegalCharacters = INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => {
if (indexPattern.includes(char)) {
chars.push(char);
}
return chars;
}, []);
return illegalCharacters;
}
function indexPatternContainsSpaces(indexPattern) {
return indexPattern.includes(' ');
}
export function validateIndexPattern(indexPattern) {
const errors = {};
const illegalCharacters = findIllegalCharacters(indexPattern);
if (illegalCharacters.length) {
errors[ILLEGAL_CHARACTERS] = illegalCharacters;
}
if (indexPatternContainsSpaces(indexPattern)) {
errors[CONTAINS_SPACES] = true;
}
return errors;
}

View file

@ -0,0 +1,44 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from '../constants';
import {
ILLEGAL_CHARACTERS,
CONTAINS_SPACES,
validateIndexPattern,
} from './validate_index_pattern';
describe('Index Pattern Validation', () => {
it('should not allow space in the pattern', () => {
const errors = validateIndexPattern('my pattern');
expect(errors[CONTAINS_SPACES]).toBe(true);
});
it('should not allow illegal characters', () => {
INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.forEach((char) => {
const errors = validateIndexPattern(`pattern${char}`);
expect(errors[ILLEGAL_CHARACTERS]).toEqual([ char ]);
});
});
it('should return empty object when there are no errors', () => {
expect(validateIndexPattern('my-pattern-*')).toEqual({});
});
});

View file

@ -20,3 +20,9 @@
export {
INDEX_ILLEGAL_CHARACTERS_VISIBLE,
} from './constants';
export {
indexNameBeginsWithPeriod,
findIllegalCharactersInIndexName,
indexNameContainsSpaces,
} from './validate';

View file

@ -0,0 +1,24 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export {
indexNameBeginsWithPeriod,
findIllegalCharactersInIndexName,
indexNameContainsSpaces,
} from './validate_index';

View file

@ -0,0 +1,41 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from '../constants';
// Names beginning with periods are reserved for system indices.
export function indexNameBeginsWithPeriod(indexName = '') {
return indexName[0] === '.';
}
export function findIllegalCharactersInIndexName(indexName) {
const illegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => {
if (indexName.includes(char)) {
chars.push(char);
}
return chars;
}, []);
return illegalCharacters;
}
export function indexNameContainsSpaces(indexName) {
return indexName.includes(' ');
}

View file

@ -0,0 +1,53 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from '../constants';
import {
indexNameBeginsWithPeriod,
findIllegalCharactersInIndexName,
indexNameContainsSpaces,
} from './validate_index';
jest.mock('ui/index_patterns/index_patterns.js', () => ({
IndexPatternsProvider: jest.fn(),
}));
jest.mock('ui/index_patterns/index_patterns_api_client_provider.js', () => ({
IndexPatternsApiClientProvider: jest.fn(),
}));
describe('Index name validation', () => {
it('should not allow name to begin with a period', () => {
const beginsWithPeriod = indexNameBeginsWithPeriod('.system_index');
expect(beginsWithPeriod).toBe(true);
});
it('should not allow space in the name', () => {
const containsSpaces = indexNameContainsSpaces('my name');
expect(containsSpaces).toBe(true);
});
it('should not allow illegal characters', () => {
INDEX_ILLEGAL_CHARACTERS_VISIBLE.forEach((char) => {
const illegalCharacters = findIllegalCharactersInIndexName(`name${char}`);
expect(illegalCharacters).toEqual([ char ]);
});
});
});

View file

@ -29,6 +29,8 @@ import { kueryAutocomplete } from './plugins/kuery_autocomplete';
import { canvas } from './plugins/canvas';
import { infra } from './plugins/infra';
import { rollup } from './plugins/rollup';
import { remoteClusters } from './plugins/remote_clusters';
import { crossClusterReplication } from './plugins/cross_cluster_replication';
import { upgradeAssistant } from './plugins/upgrade_assistant';
module.exports = function (kibana) {
@ -58,6 +60,8 @@ module.exports = function (kibana) {
kueryAutocomplete(kibana),
infra(kibana),
rollup(kibana),
remoteClusters(kibana),
crossClusterReplication(kibana),
upgradeAssistant(kibana),
];
};

View file

@ -0,0 +1,10 @@
/*
* 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 APPS = {
CCR_APP: 'ccr',
REMOTE_CLUSTER_APP: 'remote_cluster',
};

View file

@ -0,0 +1,10 @@
/*
* 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 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';

View file

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

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const PLUGIN = {
ID: 'cross_cluster_replication',
};

View file

@ -0,0 +1,17 @@
/*
* 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 arrify = val => Array.isArray(val) ? val : [val];
/**
* Utilty to add some latency in a Promise chain
*
* @param {number} time Time in millisecond to wait
*/
export const wait = (time = 1000) => (data) => {
return new Promise((resolve) => {
setTimeout(() => resolve(data), time);
});
};

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { arrify } from './utils';
describe('utils', () => {
describe('arrify()', () => {
it('should convert value to array', () => {
const value = 'foo';
expect(arrify(value)).toEqual([value]);
});
it('should not change a value that is already an array', () => {
const value = ['foo'];
expect(arrify(value)).toBe(value);
});
});
});

View file

@ -0,0 +1,35 @@
/*
* 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 getAutoFollowPatternMock = (
name = chance.string(),
remoteCluster = chance.string(),
leaderIndexPatterns = [chance.string()],
followIndexPattern = chance.string()
) => ({
name,
pattern: {
remote_cluster: remoteCluster,
leader_index_patterns: leaderIndexPatterns,
follow_index_pattern: followIndexPattern
}
});
export const getAutoFollowPatternListMock = (total = 3) => {
const list = {
patterns: []
};
let i = total;
while(i--) {
list.patterns.push(getAutoFollowPatternMock());
}
return list;
};

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/**
* Errors mocks to throw during development to help visualizing
* the different flows in the UI
*
* TODO: Consult the ES team and make sure the error shapes are correct
* for each statusCode.
*/
const error400 = new Error('Something went wrong');
error400.statusCode = 400;
error400.response = `
{
"error": {
"root_cause": [
{
"type": "x_content_parse_exception",
"reason": "[2:3] [put_auto_follow_pattern_request] unknown field [remote_clusterxxxxx], parser not found"
}
],
"type": "x_content_parse_exception",
"reason": "[2:3] [put_auto_follow_pattern_request] unknown field [remote_clusterxxxxx], parser not found"
},
"status": 400
}`;
const error403 = new Error('Unauthorized');
error403.statusCode = 403;
error403.response = `
{
"acknowledged": true,
"trial_was_started": false,
"error_message": "Operation failed: Trial was already activated."
}
`;
export const esErrors = {
400: error400,
403: error403
};

View file

@ -0,0 +1,12 @@
/*
* 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 {
getAutoFollowPatternMock,
getAutoFollowPatternListMock,
} from './auto_follow_pattern';
export { esErrors } from './es_errors';

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { resolve } from 'path';
import { PLUGIN } from './common/constants';
import { registerLicenseChecker } from './server/lib/register_license_checker';
import { registerRoutes } from './server/routes/register_routes';
export function crossClusterReplication(kibana) {
return new kibana.Plugin({
id: PLUGIN.ID,
configPrefix: 'xpack.ccr',
publicDir: resolve(__dirname, 'public'),
require: ['kibana', 'elasticsearch', 'xpack_main', 'remote_clusters', 'index_management'],
uiExports: {
styleSheetPaths: `${__dirname}/public/index.scss`,
managementSections: ['plugins/cross_cluster_replication'],
injectDefaultVars(server) {
const config = server.config();
return {
ccrUiEnabled: config.get('xpack.ccr.ui.enabled'),
};
},
},
config(Joi) {
return Joi.object({
// display menu item
ui: Joi.object({
enabled: Joi.boolean().default(true)
}).default(),
// enable plugin
enabled: Joi.boolean().default(true),
}).default();
},
init: function initCcrPlugin(server) {
registerLicenseChecker(server);
registerRoutes(server);
},
});
}

View file

@ -0,0 +1,19 @@
/**
* 1. Encapsulate this fix to avoid modifying global styling.
*/
.ccrPageContent {
max-width: 1000px !important;
width: 100% !important;
.euiText .euiButton--warning {
color: $euiColorWarning; // [1]
}
}
.ccrFollowerIndicesFormRow {
padding-bottom: 0;
}
.ccrFollowerIndicesHelpText {
transform: translateY(-3px);
}

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Route, Switch, Redirect } from 'react-router-dom';
import routing from './services/routing';
import { BASE_PATH } from '../../common/constants';
import {
CrossClusterReplicationHome,
AutoFollowPatternAdd,
AutoFollowPatternEdit
} from './sections';
export class App extends Component {
static contextTypes = {
router: PropTypes.shape({
history: PropTypes.shape({
push: PropTypes.func.isRequired,
createHref: PropTypes.func.isRequired
}).isRequired
}).isRequired
}
constructor(...args) {
super(...args);
this.registerRouter();
}
componentWillMount() {
routing.userHasLeftApp = false;
}
componentWillUnmount() {
routing.userHasLeftApp = true;
}
registerRouter() {
const { router } = this.context;
routing.reactRouter = router;
}
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>
);
}
}

View file

@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<AutoFollowPatternForm state update updateFormErrors() should merge errors with existing fieldsErrors 1`] = `
Object {
"fieldsErrors": Object {
"leaderIndexPatterns": null,
"name": "Some error",
},
}
`;

View file

@ -0,0 +1,119 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import { connect } from 'react-redux';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiConfirmModal,
EuiOverlayMask,
} from '@elastic/eui';
import { deleteAutoFollowPattern } from '../store/actions';
import { arrify } from '../../../common/services/utils';
class Provider extends PureComponent {
state = {
isModalOpen: false,
ids: null
}
onMouseOverModal = (event) => {
// This component can sometimes be used inside of an EuiToolTip, in which case mousing over
// the modal can trigger the tooltip. Stopping propagation prevents this.
event.stopPropagation();
};
deleteAutoFollowPattern = (id) => {
this.setState({ isModalOpen: true, ids: arrify(id) });
};
onConfirm = () => {
this.props.deleteAutoFollowPattern(this.state.ids);
this.setState({ isModalOpen: false, ids: null });
}
closeConfirmModal = () => {
this.setState({
isModalOpen: false,
});
};
renderModal = () => {
const { intl } = this.props;
const { ids } = this.state;
const isSingle = ids.length === 1;
const title = isSingle
? intl.formatMessage({
id: 'xpack.crossClusterReplication.deleteAutoFollowPattern.confirmModal.deleteSingleTitle',
defaultMessage: 'Remove auto-follow pattern \'{name}\'?',
}, { name: ids[0] })
: intl.formatMessage({
id: 'xpack.crossClusterReplication.deleteAutoFollowPattern.confirmModal.deleteMultipleTitle',
defaultMessage: 'Remove {count} auto-follow patterns?',
}, { 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.deleteAutoFollowPattern.confirmModal.cancelButtonText',
defaultMessage: 'Cancel',
})
}
buttonColor="danger"
confirmButtonText={
intl.formatMessage({
id: 'xpack.crossClusterReplication.deleteAutoFollowPattern.confirmModal.confirmButtonText',
defaultMessage: 'Remove',
})
}
onMouseOver={this.onMouseOverModal}
>
{!isSingle && (
<Fragment>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.deleteAutoFollowPattern.confirmModal.multipleDeletionDescription"
defaultMessage="You are about to remove these auto-follow patterns:"
/>
</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.deleteAutoFollowPattern)}
{isModalOpen && this.renderModal()}
</Fragment>
);
}
}
const mapDispatchToProps = dispatch => ({
deleteAutoFollowPattern: (id) => dispatch(deleteAutoFollowPattern(id)),
});
export const AutoFollowPatternDeleteProvider = connect(
undefined,
mapDispatchToProps
)(injectI18n(Provider));

View file

@ -0,0 +1,661 @@
/*
* 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,
EuiButtonEmpty,
EuiCallOut,
EuiComboBox,
EuiDescribedFormGroup,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormHelpText,
EuiFormRow,
EuiLoadingKibana,
EuiLoadingSpinner,
EuiOverlayMask,
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 { API_STATUS } from '../constants';
import { SectionError, AutoFollowPatternIndicesPreview } from './';
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) => ({
name: '',
remoteCluster: getFirstConnectedCluster(remoteClusters).name,
leaderIndexPatterns: [],
followIndexPatternPrefix: '',
followIndexPatternSuffix: '',
});
export const updateFormErrors = (errors, existingErrors) => ({
fieldsErrors: {
...existingErrors,
...errors,
}
});
export class AutoFollowPatternFormUI extends PureComponent {
static propTypes = {
saveAutoFollowPattern: PropTypes.func.isRequired,
autoFollowPattern: PropTypes.object,
apiError: PropTypes.object,
apiStatus: PropTypes.string.isRequired,
remoteClusters: PropTypes.array.isRequired,
}
constructor(props) {
super(props);
const isNew = this.props.autoFollowPattern === undefined;
const autoFollowPattern = isNew
? getEmptyAutoFollowPattern(this.props.remoteClusters)
: {
...this.props.autoFollowPattern,
};
this.state = {
autoFollowPattern,
fieldsErrors: validateAutoFollowPattern(autoFollowPattern),
areErrorsVisible: false,
isNew,
};
}
onFieldsChange = (fields) => {
this.setState(({ autoFollowPattern }) => ({
autoFollowPattern: {
...autoFollowPattern,
...fields,
},
}));
const errors = validateAutoFollowPattern(fields);
this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors));
};
onClusterChange = (remoteCluster) => {
this.onFieldsChange({ remoteCluster });
};
onCreateLeaderIndexPattern = (indexPattern) => {
const error = validateLeaderIndexPattern(indexPattern);
if (error) {
const errors = {
leaderIndexPatterns:
{
...error,
alwaysVisible: true,
},
};
this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors));
// Return false to explicitly reject the user's input.
return false;
}
const {
autoFollowPattern: {
leaderIndexPatterns,
},
} = this.state;
const newLeaderIndexPatterns = [
...leaderIndexPatterns,
indexPattern,
];
this.onFieldsChange({ leaderIndexPatterns: newLeaderIndexPatterns });
};
onLeaderIndexPatternChange = (indexPatterns) => {
this.onFieldsChange({
leaderIndexPatterns: indexPatterns.map(({ label }) => label)
});
};
onLeaderIndexPatternInputChange = (leaderIndexPattern) => {
if (!leaderIndexPattern || !leaderIndexPattern.trim()) {
return;
}
const { autoFollowPattern: { leaderIndexPatterns } } = this.state;
if (leaderIndexPatterns.includes(leaderIndexPattern)) {
const { intl } = this.props;
const errorMsg = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternForm.leaderIndexPatternError.duplicateMessage',
defaultMessage: `Duplicate leader index pattern aren't allowed.`,
});
const errors = {
leaderIndexPatterns: {
message: errorMsg,
alwaysVisible: true,
},
};
this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors));
} else {
this.setState(({ fieldsErrors, autoFollowPattern }) => {
const errors = validateAutoFollowPattern(autoFollowPattern);
return updateFormErrors(errors, fieldsErrors);
});
}
};
getFields = () => {
const { autoFollowPattern: stateFields } = this.state;
const { followIndexPatternPrefix, followIndexPatternSuffix, ...rest } = stateFields;
return {
...rest,
followIndexPattern: `${followIndexPatternPrefix}{{leader_index}}${followIndexPatternSuffix}`
};
};
isFormValid() {
return Object.values(this.state.fieldsErrors).every(error => error === null);
}
sendForm = () => {
const isFormValid = this.isFormValid();
if (!isFormValid) {
this.setState({ areErrorsVisible: true });
return;
}
this.setState({ areErrorsVisible: false });
const { name, ...autoFollowPattern } = this.getFields();
this.props.saveAutoFollowPattern(name, autoFollowPattern);
};
cancelForm = () => {
routing.navigate('/auto_follow_patterns');
};
/**
* Secctions Renders
*/
renderApiErrors() {
const { apiError, intl } = this.props;
if (apiError) {
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternForm.savingErrorTitle',
defaultMessage: 'Error creating auto-follow pattern',
});
return <SectionError title={title} error={apiError} />;
}
return null;
}
renderForm = () => {
const { intl } = this.props;
const {
autoFollowPattern: {
name,
remoteCluster,
leaderIndexPatterns,
followIndexPatternPrefix,
followIndexPatternSuffix,
},
isNew,
areErrorsVisible,
fieldsErrors,
} = this.state;
/**
* Auto-follow pattern Name
*/
const renderAutoFollowPatternName = () => {
const isInvalid = areErrorsVisible && !!fieldsErrors.name;
return (
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionAutoFollowPatternNameTitle"
defaultMessage="Name"
/>
</h4>
</EuiTitle>
)}
description={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionAutoFollowPatternNameDescription"
defaultMessage="A unique name for the auto-follow pattern."
/>
)}
fullWidth
>
<EuiFormRow
label={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.autoFollowPatternName.fieldNameLabel"
defaultMessage="Name"
/>
)}
error={fieldsErrors.name}
isInvalid={isInvalid}
fullWidth
>
<EuiFieldText
isInvalid={isInvalid}
value={name}
onChange={e => this.onFieldsChange({ name: e.target.value })}
fullWidth
disabled={!isNew}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
};
/**
* 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}`
}));
return (
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionRemoteClusterTitle"
defaultMessage="Remote cluster"
/>
</h4>
</EuiTitle>
)}
description={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionRemoteClusterDescription"
defaultMessage="The remote cluster to replicate leader indices from."
/>
)}
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>
</EuiDescribedFormGroup>
);
};
/**
* Leader index pattern(s)
*/
const renderLeaderIndexPatterns = () => {
const hasError = !!fieldsErrors.leaderIndexPatterns;
const isInvalid = hasError && (fieldsErrors.leaderIndexPatterns.alwaysVisible || areErrorsVisible);
const formattedLeaderIndexPatterns = leaderIndexPatterns.map(pattern => ({ label: pattern }));
return (
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionLeaderIndexPatternsTitle"
defaultMessage="Leader indices"
/>
</h4>
</EuiTitle>
)}
description={(
<Fragment>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionLeaderIndexPatternsDescription1"
defaultMessage="One or more index patterns that identify the indices you want to
replicate from the remote cluster. As new indices matching these patterns are
created, they are replicated to follower indices on the local cluster."
/>
</p>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionLeaderIndexPatternsDescription2"
defaultMessage="{note} indices that already exist are not replicated."
values={{ note: (
<strong>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionLeaderIndexPatternsDescription2.noteLabel"
defaultMessage="Note:"
/>
</strong>
) }}
/>
</p>
</Fragment>
)}
fullWidth
>
<EuiFormRow
label={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.fieldLeaderIndexPatternsLabel"
defaultMessage="Index patterns"
/>
)}
helpText={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.fieldLeaderIndexPatternsHelpLabel"
defaultMessage="Spaces and the characters {characterList} are not allowed."
values={{ characterList: <strong>{indexPatternIllegalCharacters}</strong> }}
/>
)}
isInvalid={isInvalid}
error={fieldsErrors.leaderIndexPatterns && fieldsErrors.leaderIndexPatterns.message}
fullWidth
>
<EuiComboBox
noSuggestions
placeholder={intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternForm.fieldLeaderIndexPatternsPlaceholder',
defaultMessage: 'Type and then hit ENTER',
})}
selectedOptions={formattedLeaderIndexPatterns}
onCreateOption={this.onCreateLeaderIndexPattern}
onChange={this.onLeaderIndexPatternChange}
onSearchChange={this.onLeaderIndexPatternInputChange}
fullWidth
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
};
/**
* Auto-follow pattern
*/
const renderAutoFollowPattern = () => {
const isPrefixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternPrefix;
const isSuffixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternSuffix;
return (
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionAutoFollowPatternTitle"
defaultMessage="Follower indices (optional)"
/>
</h4>
</EuiTitle>
)}
description={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.sectionAutoFollowPatternDescription"
defaultMessage="A custom prefix or suffix to apply to the names of the follower
indices so you can more easily identify replicated indices. By default, a follower
index has the same name as the leader index."
/>
)}
fullWidth
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiFormRow
className="ccrFollowerIndicesFormRow"
label={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.autoFollowPattern.fieldPrefixLabel"
defaultMessage="Prefix"
/>
)}
error={fieldsErrors.followIndexPatternPrefix}
isInvalid={isPrefixInvalid}
fullWidth
>
<EuiFieldText
isInvalid={isPrefixInvalid}
value={followIndexPatternPrefix}
onChange={e => this.onFieldsChange({ followIndexPatternPrefix: e.target.value })}
fullWidth
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
className="ccrFollowerIndicesFormRow"
label={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.autoFollowPattern.fieldSuffixLabel"
defaultMessage="Suffix"
/>
)}
error={fieldsErrors.followIndexPatternSuffix}
isInvalid={isSuffixInvalid}
fullWidth
>
<EuiFieldText
isInvalid={isSuffixInvalid}
value={followIndexPatternSuffix}
onChange={e => this.onFieldsChange({ followIndexPatternSuffix: e.target.value })}
fullWidth
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFormHelpText className={isPrefixInvalid || isSuffixInvalid ? null : 'ccrFollowerIndicesHelpText'}>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.fieldFollowerIndicesHelpLabel"
defaultMessage="Spaces and the characters {characterList} are not allowed."
values={{ characterList: <strong>{indexNameIllegalCharacters}</strong> }}
/>
</EuiFormHelpText>
{!!leaderIndexPatterns.length && (
<Fragment>
<EuiSpacer size="m" />
<AutoFollowPatternIndicesPreview
prefix={followIndexPatternPrefix}
suffix={followIndexPatternSuffix}
leaderIndexPatterns={leaderIndexPatterns}
/>
</Fragment>
)}
</EuiDescribedFormGroup>
);
};
/**
* Form Error warning message
*/
const renderFormErrorWarning = () => {
const { areErrorsVisible } = this.state;
const isFormValid = this.isFormValid();
if (!areErrorsVisible || isFormValid) {
return null;
}
return (
<Fragment>
<EuiSpacer size="m" />
<EuiCallOut
title={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.validationErrorTitle"
defaultMessage="Fix errors before continuing."
/>
)}
color="danger"
iconType="cross"
/>
</Fragment>
);
};
/**
* Form Actions
*/
const renderActions = () => {
const { apiStatus } = 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.autoFollowPatternForm.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}
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="primary"
onClick={this.cancelForm}
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
);
};
return (
<Fragment>
<EuiForm>
{renderAutoFollowPatternName()}
{renderRemoteClusterField()}
{renderLeaderIndexPatterns()}
{renderAutoFollowPattern()}
</EuiForm>
{renderFormErrorWarning()}
<EuiSpacer size="l" />
{renderActions()}
</Fragment>
);
}
renderLoading = () => {
const { apiStatus } = this.props;
if (apiStatus === API_STATUS.SAVING) {
return (
<EuiOverlayMask>
<EuiLoadingKibana size="xl"/>
</EuiOverlayMask>
);
}
return null;
}
render() {
return (
<Fragment>
{this.renderApiErrors()}
{this.renderForm()}
{this.renderLoading()}
</Fragment>
);
}
}
export const AutoFollowPatternForm = injectI18n(AutoFollowPatternFormUI);

View file

@ -0,0 +1,31 @@
/*
* 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 { updateFormErrors } from './auto_follow_pattern_form';
jest.mock('../services/auto_follow_pattern_validators', () => ({
validateAutoFollowPattern: jest.fn(),
validateLeaderIndexPattern: jest.fn(),
}));
jest.mock('ui/index_patterns/index_patterns.js', () => ({
IndexPatternsProvider: jest.fn(),
}));
jest.mock('ui/index_patterns/index_patterns_api_client_provider.js', () => ({
IndexPatternsApiClientProvider: jest.fn(),
}));
describe('<AutoFollowPatternForm state update', () => {
describe('updateFormErrors()', () => {
it('should merge errors with existing fieldsErrors', () => {
const errors = { name: 'Some error' };
const existingErrors = { leaderIndexPatterns: null };
const output = updateFormErrors(errors, existingErrors);
expect(output).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 { EuiCallOut } from '@elastic/eui';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { getPreviewIndicesFromAutoFollowPattern } from '../services/auto_follow_pattern';
export const AutoFollowPatternIndicesPreview = injectI18n(({ prefix, suffix, leaderIndexPatterns, intl }) => {
const { indicesPreview } = getPreviewIndicesFromAutoFollowPattern({
prefix,
suffix,
leaderIndexPatterns
});
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternForm.indicesPreviewTitle',
defaultMessage: 'Index name examples',
});
return (
<EuiCallOut
title={title}
iconType="indexMapping"
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternForm.indicesPreviewDescription"
defaultMessage="The above settings will generate index names that look like this:"
/>
<ul>
{indicesPreview.map(({ followPattern: { prefix, suffix, template } }, i) => (
<li key={i}>
{prefix}<strong>{template}</strong>{suffix}
</li>
))}
</ul>
</EuiCallOut>
);
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiPageContentHeader,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { autoFollowPatternUrl } from '../services/documentation_links';
export const AutoFollowPatternPageTitle = ({ 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={autoFollowPatternUrl}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.crossClusterReplication.readDocsButtonLabel"
defaultMessage="Auto-follow pattern docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContentHeader>
</Fragment>
);
AutoFollowPatternPageTitle.propTypes = {
title: PropTypes.node.isRequired,
};

View file

@ -0,0 +1,14 @@
/*
* 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 { SectionLoading } from './section_loading';
export { SectionError } from './section_error';
export { SectionUnauthorized } from './section_unauthorized';
export { RemoteClustersProvider } from './remote_clusters_provider';
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';

View file

@ -0,0 +1,50 @@
/*
* 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'; // eslint-disable-line no-unused-vars
import { loadRemoteClusters } from '../services/api';
export class RemoteClustersProvider extends PureComponent {
state = {
isLoading: true,
error: null,
remoteClusters: []
}
componentDidMount() {
this.loadRemoteClusters();
}
loadRemoteClusters() {
const sortClusterByName = (remoteClusters) => (
remoteClusters.sort((a, b) => {
if(a.name < b.name) { return -1; }
if(a.name > b.name) { return 1; }
return 0;
})
);
loadRemoteClusters()
.then((remoteClusters) => {
this.setState({
isLoading: false,
remoteClusters: sortClusterByName(remoteClusters)
});
})
.catch((error) => {
this.setState({
isLoading: false,
error
});
});
}
render() {
const { children } = this.props;
const { isLoading, error, remoteClusters } = this.state;
return children({ isLoading, error, remoteClusters });
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import {
EuiSpacer,
EuiCallOut,
} from '@elastic/eui';
export function SectionError({ title, error }) {
const {
error: errorString,
cause, // wrapEsError() on the server add a "cause" array
message,
} = 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>
);
}

View file

@ -0,0 +1,37 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiText,
EuiTextColor,
} from '@elastic/eui';
export function SectionLoading({ children }) {
return (
<EuiFlexGroup
justifyContent="flexStart"
alignItems="center"
gutterSize="s"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<EuiTextColor color="subdued">
{children}
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { 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',
});
return (
<Fragment>
<EuiCallOut
title={title}
color="warning"
iconType="help"
>
{children}
</EuiCallOut>
</Fragment>
);
}
export const SectionUnauthorized = injectI18n(SectionUnauthorizedUI);

View file

@ -0,0 +1,13 @@
/*
* 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 API_STATUS = {
IDLE: 'idle',
LOADING: 'loading',
UPDATING: 'updating',
SAVING: 'saving',
DELETING: 'deleting'
};

View file

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

View file

@ -0,0 +1,11 @@
/*
* 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 SECTIONS = {
AUTO_FOLLOW_PATTERN: 'autoFollowPattern',
INDEX_FOLLOWER: 'indexFollower',
REMOTE_CLUSTER: 'remoteCluster'
};

View file

@ -0,0 +1,26 @@
/*
* 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 { I18nProvider } from '@kbn/i18n/react';
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { HashRouter } from 'react-router-dom';
import { App } from './app';
import { ccrStore } from './store';
export const renderReact = async (elem) => {
render(
<I18nProvider>
<Provider store={ccrStore}>
<HashRouter>
<App />
</HashRouter>
</Provider>
</I18nProvider>,
elem
);
};

View file

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

View file

@ -0,0 +1,185 @@
/*
* 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 {
EuiPage,
EuiPageBody,
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(
class extends PureComponent {
static propTypes = {
saveAutoFollowPattern: 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();
}
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;
return (
<EuiPage>
<EuiPageBody>
<EuiPageContent
horizontalPosition="center"
className="ccrPageContent"
>
<AutoFollowPatternPageTitle
title={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.addTitle"
defaultMessage="Add auto-follow pattern"
/>
)}
/>
<RemoteClustersProvider>
{({ isLoading, error, remoteClusters }) => {
if (isLoading) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternCreateForm.loadingRemoteClusters"
defaultMessage="Loading remote clusters..."
/>
</SectionLoading>
);
}
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}
saveAutoFollowPattern={saveAutoFollowPattern}
/>
);
}}
</RemoteClustersProvider>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
}
}
);

View file

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

View file

@ -0,0 +1,31 @@
/*
* 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, getSelectedAutoFollowPattern } from '../../store/selectors';
import { getAutoFollowPattern, saveAutoFollowPattern, 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),
});
const mapDispatchToProps = dispatch => ({
getAutoFollowPattern: (id) => dispatch(getAutoFollowPattern(id)),
saveAutoFollowPattern: (id, autoFollowPattern) => dispatch(saveAutoFollowPattern(id, autoFollowPattern, true)),
clearApiError: () => dispatch(clearApiError(scope)),
});
export const AutoFollowPatternEdit = connect(
mapStateToProps,
mapDispatchToProps
)(AutoFollowPatternEditView);

View file

@ -0,0 +1,207 @@
/*
* 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 {
EuiPage,
EuiPageBody,
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,
RemoteClustersProvider,
SectionLoading,
SectionError,
} from '../../components';
import { API_STATUS } from '../../constants';
export const AutoFollowPatternEdit = injectI18n(
class extends PureComponent {
static propTypes = {
getAutoFollowPattern: PropTypes.func.isRequired,
saveAutoFollowPattern: PropTypes.func.isRequired,
clearApiError: PropTypes.func.isRequired,
apiError: PropTypes.object,
apiStatus: PropTypes.string.isRequired,
}
componentDidMount() {
const { autoFollowPattern, match: { params: { id } } } = this.props;
if (!autoFollowPattern) {
const decodedId = decodeURIComponent(id);
this.props.getAutoFollowPattern(decodedId);
}
chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb ]);
}
componentWillUnmount() {
this.props.clearApiError();
}
renderApiError(error) {
const { intl } = this.props;
const title = intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorTitle',
defaultMessage: 'Error loading auto-follow pattern',
});
return (
<Fragment>
<SectionError title={title} error={error} />
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiButton
{...routing.getRouterLinkProps('/auto_follow_patterns')}
fill
iconType="plusInCircle"
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternEditForm.viewAutoFollowPatternsButtonLabel"
defaultMessage="View auto-follow patterns"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
}
renderLoadingAutoFollowPattern() {
return (
<SectionLoading>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternEditForm.loadingTitle"
defaultMessage="Loading auto-follow pattern..."
/>
</SectionLoading>
);
}
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;
return (
<EuiPage>
<EuiPageBody>
<EuiPageContent
horizontalPosition="center"
className="ccrPageContent"
>
<AutoFollowPatternPageTitle
title={(
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.editTitle"
defaultMessage="Edit auto-follow pattern"
/>
)}
/>
{apiStatus === 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>
);
}
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}
/>
);
}}
</RemoteClustersProvider>
)}
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
}
}
);

View file

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

View file

@ -0,0 +1,47 @@
/*
* 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 {
getListAutoFollowPatterns,
getApiStatus,
getApiError,
isApiAuthorized,
isAutoFollowPatternDetailPanelOpen as isDetailPanelOpen,
} from '../../../store/selectors';
import {
loadAutoFollowPatterns,
openAutoFollowPatternDetailPanel as openDetailPanel,
closeAutoFollowPatternDetailPanel as closeDetailPanel,
} from '../../../store/actions';
import { AutoFollowPatternList as AutoFollowPatternListView } from './auto_follow_pattern_list';
const scope = SECTIONS.AUTO_FOLLOW_PATTERN;
const mapStateToProps = (state) => ({
autoFollowPatterns: getListAutoFollowPatterns(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());
},
});
export const AutoFollowPatternList = connect(
mapStateToProps,
mapDispatchToProps
)(AutoFollowPatternListView);

View file

@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import routing from '../../../services/routing';
import { extractQueryParams } from '../../../services/query_params';
import { API_STATUS } from '../../../constants';
import { SectionLoading, SectionError } from '../../../components';
import { AutoFollowPatternTable, DetailPanel } from './components';
const REFRESH_RATE_MS = 30000;
export const AutoFollowPatternList = injectI18n(
class extends PureComponent {
static propTypes = {
loadAutoFollowPatterns: PropTypes.func,
autoFollowPatterns: PropTypes.array,
apiStatus: PropTypes.string,
apiError: PropTypes.object,
openDetailPanel: PropTypes.func.isRequired,
closeDetailPanel: PropTypes.func.isRequired,
isDetailPanelOpen: PropTypes.bool,
}
componentDidMount() {
this.props.loadAutoFollowPatterns();
// 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);
}
componentWillUnmount() {
clearInterval(this.interval);
}
componentDidUpdate() {
const {
openDetailPanel,
closeDetailPanel,
isDetailPanelOpen,
history: {
location: {
search,
},
},
} = this.props;
const { pattern: patternName } = extractQueryParams(search);
// Show deeplinked auto follow pattern whenever patterns get loaded or the URL changes.
if (patternName != null) {
openDetailPanel(patternName);
} else if (isDetailPanelOpen) {
closeDetailPanel();
}
}
renderEmpty() {
return (
<EuiEmptyPrompt
iconType="managementApp"
title={(
<h1>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.emptyPromptTitle"
defaultMessage="Create your first auto-follow pattern"
/>
</h1>
)}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.emptyPromptDescription"
defaultMessage="Use an auto-follow pattern to automatically replicate indices from
a remote cluster."
/>
</p>
</Fragment>
}
actions={
<EuiButton
{...routing.getRouterLinkProps('/auto_follow_patterns/add')}
fill
iconType="plusInCircle"
>
<FormattedMessage
id="xpack.crossClusterReplication.addAutoFollowPatternButtonLabel"
defaultMessage="Create auto-follow pattern"
/>
</EuiButton>
}
/>
);
}
renderList() {
const { autoFollowPatterns, apiStatus } = this.props;
if (apiStatus === API_STATUS.LOADING) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternList.loadingTitle"
defaultMessage="Loading auto-follow patterns..."
/>
</SectionLoading>
);
}
return (
<Fragment>
<AutoFollowPatternTable autoFollowPatterns={autoFollowPatterns} />
<DetailPanel />
</Fragment>
);
}
render() {
const { autoFollowPatterns, apiStatus, apiError, isAuthorized, intl } = this.props;
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();
}
}
);

View file

@ -0,0 +1,33 @@
/*
* 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 {
editAutoFollowPattern,
openAutoFollowPatternDetailPanel as openDetailPanel,
} from '../../../../../store/actions';
import { getApiStatus } from '../../../../../store/selectors';
import { AutoFollowPatternTable as AutoFollowPatternTableComponent } from './auto_follow_pattern_table';
const scope = SECTIONS.AUTO_FOLLOW_PATTERN;
const mapStateToProps = (state) => ({
apiStatusDelete: getApiStatus(`${scope}-delete`)(state),
});
const mapDispatchToProps = (dispatch) => ({
editAutoFollowPattern: (name) => dispatch(editAutoFollowPattern(name)),
openDetailPanel: (name) => {
dispatch(openDetailPanel(name));
},
});
export const AutoFollowPatternTable = connect(
mapStateToProps,
mapDispatchToProps,
)(AutoFollowPatternTableComponent);

View file

@ -0,0 +1,242 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import { i18n } from '@kbn/i18n';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiButtonIcon,
EuiInMemoryTable,
EuiLink,
EuiLoadingKibana,
EuiToolTip,
EuiOverlayMask,
} from '@elastic/eui';
import { API_STATUS } from '../../../../../constants';
import { AutoFollowPatternDeleteProvider } from '../../../../../components';
import routing from '../../../../../services/routing';
export const AutoFollowPatternTable = injectI18n(
class extends PureComponent {
static propTypes = {
autoFollowPatterns: PropTypes.array,
openDetailPanel: PropTypes.func.isRequired,
}
state = {
selectedItems: [],
}
onSearch = ({ query }) => {
const { text } = query;
const normalizedSearchText = text.toLowerCase();
this.setState({
queryText: normalizedSearchText,
});
};
getFilteredPatterns = () => {
const { autoFollowPatterns } = this.props;
const { queryText } = this.state;
if(queryText) {
return autoFollowPatterns.filter(autoFollowPattern => {
const { name, remoteCluster, followIndexPatternPrefix, followIndexPatternSuffix } = autoFollowPattern;
const inName = name.toLowerCase().includes(queryText);
const inRemoteCluster = remoteCluster.toLowerCase().includes(queryText);
const inPrefix = followIndexPatternPrefix.toLowerCase().includes(queryText);
const inSuffix = followIndexPatternSuffix.toLowerCase().includes(queryText);
return inName || inRemoteCluster || inPrefix || inSuffix;
});
}
return autoFollowPatterns.slice(0);
};
getTableColumns() {
const { intl, editAutoFollowPattern, openDetailPanel } = this.props;
return [{
field: 'name',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.table.nameColumnTitle',
defaultMessage: 'Name',
}),
sortable: true,
truncateText: false,
render: (name) => {
return (
<EuiLink onClick={() => openDetailPanel(name)}>
{name}
</EuiLink>
);
}
}, {
field: 'remoteCluster',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.table.clusterColumnTitle',
defaultMessage: 'Cluster',
}),
truncateText: true,
sortable: true,
}, {
field: 'leaderIndexPatterns',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.table.leaderPatternsColumnTitle',
defaultMessage: 'Leader patterns',
}),
render: (leaderPatterns) => leaderPatterns.join(', '),
}, {
field: 'followIndexPatternPrefix',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.table.prefixColumnTitle',
defaultMessage: 'Follower pattern prefix',
}),
sortable: true,
}, {
field: 'followIndexPatternSuffix',
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.table.suffixColumnTitle',
defaultMessage: 'Follower pattern suffix',
}),
sortable: true,
}, {
name: intl.formatMessage({
id: 'xpack.crossClusterReplication.autoFollowPatternList.table.actionsColumnTitle',
defaultMessage: 'Actions',
}),
actions: [
{
render: ({ name }) => {
const label = i18n.translate(
'xpack.crossClusterReplication.autofollowPatternList.table.actionDeleteDescription',
{
defaultMessage: 'Delete auto-follow pattern',
}
);
return (
<EuiToolTip
content={label}
delay="long"
>
<AutoFollowPatternDeleteProvider>
{(deleteAutoFollowPattern) => (
<EuiButtonIcon
aria-label={label}
iconType="trash"
color="danger"
onClick={() => deleteAutoFollowPattern(name)}
/>
)}
</AutoFollowPatternDeleteProvider>
</EuiToolTip>
);
},
},
{
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)}`));
},
type: 'icon',
},
],
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 ? (
<AutoFollowPatternDeleteProvider>
{(deleteAutoFollowPattern) => (
<EuiButton
iconType="trash"
color="danger"
onClick={() => deleteAutoFollowPattern(selectedItems.map(({ name }) => name))}
>
<FormattedMessage
id="xpack.crossClusterReplication.deleteAutoFollowPatternButtonLabel"
defaultMessage="Delete auto-follow {total, plural, one {pattern} other {patterns}}"
values={{
total: selectedItems.length
}}
/>
</EuiButton>
)}
</AutoFollowPatternDeleteProvider>
) : undefined,
onChange: this.onSearch,
box: {
incremental: true,
},
};
return (
<Fragment>
<EuiInMemoryTable
items={this.getFilteredPatterns()}
itemId="name"
columns={this.getTableColumns()}
search={search}
pagination={pagination}
sorting={sorting}
selection={selection}
isSelectable={true}
/>
{this.renderLoading()}
</Fragment>
);
}
}
);

View file

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

View file

@ -0,0 +1,51 @@
/*
* 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 {
getDetailPanelAutoFollowPattern,
getDetailPanelAutoFollowPatternName,
getApiStatus,
isAutoFollowPatternDetailPanelOpen as isDetailPanelOpen,
} from '../../../../../store/selectors';
import {
closeAutoFollowPatternDetailPanel as closeDetailPanel,
editAutoFollowPattern,
} from '../../../../../store/actions';
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));
}
};
};
export const DetailPanel = connect(
mapStateToProps,
mapDispatchToProps
)(DetailPanelView);

View file

@ -0,0 +1,353 @@
/*
* 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,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiIcon,
EuiLink,
EuiLoadingSpinner,
EuiSpacer,
EuiText,
EuiTextColor,
EuiTitle,
} from '@elastic/eui';
import {
AutoFollowPatternIndicesPreview,
AutoFollowPatternDeleteProvider,
} from '../../../../../components';
import { API_STATUS } from '../../../../../constants';
import routing from '../../../../../services/routing';
export class DetailPanelUi extends Component {
static propTypes = {
isDetailPanelOpen: PropTypes.bool.isRequired,
apiStatus: PropTypes.string,
autoFollowPattern: PropTypes.object,
autoFollowPatternName: PropTypes.string,
closeDetailPanel: PropTypes.func.isRequired,
editAutoFollowPattern: PropTypes.func.isRequired,
}
renderAutoFollowPattern() {
const {
autoFollowPattern: {
followIndexPatternPrefix,
followIndexPatternSuffix,
remoteCluster,
leaderIndexPatterns,
},
} = this.props;
let indexManagementFilter;
if(followIndexPatternPrefix) {
indexManagementFilter = `name:${followIndexPatternPrefix}`;
} else if(followIndexPatternSuffix) {
indexManagementFilter = `name:${followIndexPatternSuffix}`;
}
const indexManagementUri = getIndexListUri(indexManagementFilter);
return (
<Fragment>
<EuiFlyoutBody>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.statusTitle"
defaultMessage="Settings"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList>
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.remoteClusterLabel"
defaultMessage="Remote cluster"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{remoteCluster}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.leaderPatternsLabel"
defaultMessage="Leader patterns"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{leaderIndexPatterns.join(', ')}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.prefixLabel"
defaultMessage="Prefix"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{followIndexPatternPrefix || (
<em>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.prefixEmptyValue"
defaultMessage="No prefix"
/>
</em>
)}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.suffixLabel"
defaultMessage="Suffix"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{followIndexPatternSuffix || (
<em>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.suffixEmptyValue"
defaultMessage="No suffix"
/>
</em>
)}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<AutoFollowPatternIndicesPreview
prefix={followIndexPatternPrefix}
suffix={followIndexPatternSuffix}
leaderIndexPatterns={leaderIndexPatterns}
/>
<EuiSpacer size="l" />
<EuiLink
href={indexManagementUri}
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.viewIndicesLink"
defaultMessage="View your follower indices in Index Management"
/>
</EuiLink>
</EuiDescriptionList>
</EuiFlyoutBody>
</Fragment>
);
}
renderContent() {
const {
apiStatus,
autoFollowPattern,
} = 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.autoFollowPatternDetailPanel.loadingLabel"
defaultMessage="Loading auto-follow pattern..."
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
);
}
if (!autoFollowPattern) {
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.autoFollowPatternDetailPanel.notFoundLabel"
defaultMessage="Auto-follow pattern not found"
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
);
}
return this.renderAutoFollowPattern();
}
renderFooter() {
const {
editAutoFollowPattern,
autoFollowPattern,
autoFollowPatternName,
closeDetailPanel,
} = this.props;
if (!autoFollowPattern) {
return null;
}
return (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={closeDetailPanel}
>
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPatternDetailPanel.closeButtonLabel"
defaultMessage="Close"
/>
</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>
<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>
</EuiFlexGroup>
</EuiFlyoutFooter>
);
}
render() {
const {
isDetailPanelOpen,
closeDetailPanel,
autoFollowPatternName,
} = this.props;
if (!isDetailPanelOpen) {
return null;
}
return (
<EuiFlyout
data-test-subj="autoFollowPatternDetailsFlyout"
onClose={closeDetailPanel}
aria-labelledby="autoFollowPatternDetailsFlyoutTitle"
size="m"
maxWidth={400}
>
<EuiFlyoutHeader>
<EuiTitle size="m" id="autoFollowPatternDetailsFlyoutTitle">
<h2>{autoFollowPatternName}</h2>
</EuiTitle>
</EuiFlyoutHeader>
{this.renderContent()}
{this.renderFooter()}
</EuiFlyout>
);
}
}
export const DetailPanel = injectI18n(DetailPanelUi);

View file

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

View file

@ -0,0 +1,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 { AutoFollowPatternTable } from './auto_follow_pattern_table';
export { DetailPanel } from './detail_panel';

View file

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

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { SECTIONS } from '../../constants';
import { getListAutoFollowPatterns, isApiAuthorized } from '../../store/selectors';
import { CrossClusterReplicationHome as CrossClusterReplicationHomeView } from './home';
const mapStateToProps = (state) => ({
autoFollowPatterns: getListAutoFollowPatterns(state),
isAutoFollowApiAuthorized: isApiAuthorized(SECTIONS.AUTO_FOLLOW_PATTERN)(state)
});
export const CrossClusterReplicationHome = connect(
mapStateToProps,
null
)(CrossClusterReplicationHomeView);

View file

@ -0,0 +1,139 @@
/*
* 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 { 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,
EuiPage,
EuiPageBody,
EuiPageContent,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { listBreadcrumb } from '../../services/breadcrumbs';
import routing from '../../services/routing';
import { AutoFollowPatternList } from './auto_follow_pattern_list';
import { SectionUnauthorized } from '../../components';
export const CrossClusterReplicationHome = injectI18n(
class extends PureComponent {
static propTypes = {
autoFollowPatterns: PropTypes.array,
}
state = {
sectionActive: 'auto-follow'
}
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>
);
}
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>
);
}
}
render() {
return (
<EuiPage>
<EuiPageBody>
<EuiPageContent>
{this.getHeaderSection()}
{this.getUnauthorizedSection()}
<Switch>
<Route exact path={`${BASE_PATH}/auto_follow_patterns`} component={AutoFollowPatternList} />
</Switch>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
}
}
);

View file

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

View file

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

View file

@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Auto-follow pattern validators validateAutoFollowPattern() returns empty object when autoFollowPattern is undefined 1`] = `Object {}`;
exports[`Auto-follow pattern validators validateAutoFollowPattern() should validate all props from auto-follow patten 1`] = `
Object {
"followIndexPatternPrefix": <FormattedMessage
defaultMessage="Remove the {characterListLength, plural, one {character} other {characters}} {characterList} from the prefix."
id="xpack.crossClusterReplication.autoFollowPattern.prefixValidation.illegalCharacters"
values={
Object {
"characterList": <strong>
?
</strong>,
"characterListLength": 1,
}
}
/>,
"followIndexPatternSuffix": <FormattedMessage
defaultMessage="Remove the {characterListLength, plural, one {character} other {characters}} {characterList} from the suffix."
id="xpack.crossClusterReplication.autoFollowPattern.suffixValidation.illegalCharacters"
values={
Object {
"characterList": <strong>
?
</strong>,
"characterListLength": 1,
}
}
/>,
"leaderIndexPatterns": null,
"name": "Name can't begin with an underscore.",
"otherProp": null,
}
`;

View file

@ -0,0 +1,50 @@
/*
* 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 chrome from 'ui/chrome';
import { API_BASE_PATH, API_REMOTE_CLUSTERS_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);
// 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) {
httpClient = client;
}
// ---
const extractData = (response) => response.data;
export const loadAutoFollowPatterns = () => (
httpClient.get(`${apiPrefix}/auto_follow_patterns`).then(extractData)
);
export const getAutoFollowPattern = (id) => (
httpClient.get(`${apiPrefix}/auto_follow_patterns/${encodeURIComponent(id)}`).then(extractData)
);
export const loadRemoteClusters = () => (
httpClient.get(`${apiPrefixRemoteClusters}`).then(extractData)
);
export const createAutoFollowPattern = (autoFollowPattern) => (
httpClient.post(`${apiPrefix}/auto_follow_patterns`, autoFollowPattern).then(extractData)
);
export const updateAutoFollowPattern = (id, autoFollowPattern) => (
httpClient.put(`${apiPrefix}/auto_follow_patterns/${encodeURIComponent(id)}`, autoFollowPattern).then(extractData)
);
export const deleteAutoFollowPattern = (id) => {
const ids = arrify(id).map(_id => encodeURIComponent(_id)).join(',');
return httpClient.delete(`${apiPrefix}/auto_follow_patterns/${ids}`).then(extractData);
};

View file

@ -0,0 +1,72 @@
/*
* 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 moment from 'moment';
const getFollowPattern = (prefix = '', suffix = '', template = '{{leader_index}}') => (
{
followPattern: {
prefix,
suffix,
template,
},
toString: prefix + template + suffix
}
);
/**
* Generate an array of indices preview that would be generated for an auto-follow pattern.
* It concatenates the prefix + the leader index pattern populated with values + the suffix
*
* Example of the array returned:
* ["prefix_leader-index-0_suffix", "prefix_leader-index-1_suffix", "prefix_leader-index-2_suffix"]
*/
export const getPreviewIndicesFromAutoFollowPattern = ({
prefix,
suffix,
leaderIndexPatterns,
limit = 5,
wildcardPlaceHolders = [
moment().format('YYYY-MM-DD'),
moment().add(1, 'days').format('YYYY-MM-DD'),
moment().add(2, 'days').format('YYYY-MM-DD'),
]
}) => {
const indicesPreview = [];
let indexPreview;
let leaderIndexTemplate;
leaderIndexPatterns.forEach((leaderIndexPattern) => {
wildcardPlaceHolders.forEach((placeHolder) => {
leaderIndexTemplate = leaderIndexPattern.replace(/\*/g, placeHolder);
indexPreview = getFollowPattern(prefix, suffix, leaderIndexTemplate);
if (!indicesPreview.some((_indexPreview) => indexPreview.toString === _indexPreview.toString)) {
indicesPreview.push(indexPreview);
}
});
});
return {
indicesPreview: indicesPreview.slice(0, limit),
hasMore: indicesPreview.length > limit,
};
};
export const getPrefixSuffixFromFollowPattern = (followPattern) => {
let followIndexPatternPrefix;
let followIndexPatternSuffix;
const template = '{{leader_index}}';
const index = followPattern.indexOf(template);
if (index >= 0) {
followIndexPatternPrefix = followPattern.slice(0, index);
followIndexPatternSuffix = followPattern.slice(index + template.length);
}
return { followIndexPatternPrefix, followIndexPatternSuffix };
};

View file

@ -0,0 +1,73 @@
/*
* 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 moment from 'moment';
import { getPreviewIndicesFromAutoFollowPattern, getPrefixSuffixFromFollowPattern } from './auto_follow_pattern';
describe('Auto-follo pattern service', () => {
describe('getPreviewIndicesFromAutoFollowPattern()', () => {
let prefix;
let suffix;
let leaderIndexPatterns;
beforeEach(() => {
prefix = 'prefix_';
suffix = '_suffix';
leaderIndexPatterns = ['logstash-*'];
});
it('should render a list of indices preview', () => {
const { indicesPreview, hasMore } = getPreviewIndicesFromAutoFollowPattern({ prefix, suffix, leaderIndexPatterns });
expect(hasMore).toBe(false);
expect(indicesPreview.map(preview => preview.toString)).toEqual([
`prefix_logstash-${moment().format('YYYY-MM-DD')}_suffix`,
`prefix_logstash-${moment().add(1, 'days').format('YYYY-MM-DD')}_suffix`,
`prefix_logstash-${moment().add(2, 'days').format('YYYY-MM-DD')}_suffix`,
]);
});
it('should have a default limit of 5', () => {
leaderIndexPatterns.push('other-*');
const { indicesPreview, hasMore } = getPreviewIndicesFromAutoFollowPattern({ prefix, suffix, leaderIndexPatterns });
expect(hasMore).toBe(true);
expect(indicesPreview.map(preview => preview.toString)).toEqual([
`prefix_logstash-${moment().format('YYYY-MM-DD')}_suffix`,
`prefix_logstash-${moment().add(1, 'days').format('YYYY-MM-DD')}_suffix`,
`prefix_logstash-${moment().add(2, 'days').format('YYYY-MM-DD')}_suffix`,
`prefix_other-${moment().format('YYYY-MM-DD')}_suffix`,
`prefix_other-${moment().add(1, 'days').format('YYYY-MM-DD')}_suffix`,
]);
});
it('should allow custom limit and wildcard placeholder', () => {
const limit = 2;
const wildcardPlaceHolders = ['A', 'B', 'C'];
const { indicesPreview } = getPreviewIndicesFromAutoFollowPattern({
prefix,
suffix,
leaderIndexPatterns,
limit,
wildcardPlaceHolders
});
expect(indicesPreview.map(preview => preview.toString)).toEqual([
'prefix_logstash-A_suffix',
'prefix_logstash-B_suffix',
]);
});
});
describe('getPrefixSuffixFromFollowPattern()', () => {
it('should extract prefix and suffix from a {{leader_index}} template', () => {
const result = getPrefixSuffixFromFollowPattern('prefix_{{leader_index}}_suffix');
expect(result.followIndexPatternPrefix).toEqual('prefix_');
expect(result.followIndexPatternSuffix).toEqual('_suffix');
});
});
});

View file

@ -0,0 +1,199 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
ILLEGAL_CHARACTERS,
CONTAINS_SPACES,
validateIndexPattern as getIndexPatternErrors,
} from 'ui/index_patterns';
import {
indexNameBeginsWithPeriod,
findIllegalCharactersInIndexName,
indexNameContainsSpaces,
} from 'ui/indices';
export const validateName = (name = '') => {
let errorMsg = null;
if (!name || !name.trim()) {
errorMsg = i18n.translate(
'xpack.crossClusterReplication.autoFollowPattern.nameValidation.errorEmptyName',
{ defaultMessage: 'Name is required.' }
);
} else {
if (name.includes(' ')) {
errorMsg = i18n.translate('xpack.crossClusterReplication.autoFollowPattern.nameValidation.errorSpace', {
defaultMessage: 'Spaces are not allowed in the name.'
});
}
if (name[0] === '_') {
errorMsg = i18n.translate(
'xpack.crossClusterReplication.autoFollowPattern.nameValidation.errorUnderscore',
{ defaultMessage: "Name can't begin with an underscore." }
);
}
if (name.includes(',')) {
errorMsg = i18n.translate(
'xpack.crossClusterReplication.autoFollowPattern.nameValidation.errorComma',
{ defaultMessage: "Commas are not allowed in the name." }
);
}
}
return errorMsg;
};
export const validateLeaderIndexPattern = (indexPattern) => {
const errors = getIndexPatternErrors(indexPattern);
if (errors[ILLEGAL_CHARACTERS]) {
return (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.leaderIndexPatternValidation.illegalCharacters"
defaultMessage="Remove the {characterListLength, plural, one {character} other {characters}}
{characterList} from the index pattern."
values={{
characterList: <strong>{errors[ILLEGAL_CHARACTERS].join(' ')}</strong>,
characterListLength: errors[ILLEGAL_CHARACTERS].length,
}}
/>
);
}
if (errors[CONTAINS_SPACES]) {
return (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.leaderIndexPatternValidation.noEmptySpace"
defaultMessage="Spaces are not allowed in the index pattern."
/>
);
}
return null;
};
export const validatePrefix = (prefix) => {
// If it's empty, it is valid
if (!prefix || !prefix.trim()) {
return null;
}
// Prefix can't begin with a period, because that's reserved for system indices.
if (indexNameBeginsWithPeriod(prefix)) {
return (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.prefixValidation.beginsWithPeriod"
defaultMessage="The prefix can't begin with a period."
/>
);
}
const illegalCharacters = findIllegalCharactersInIndexName(prefix);
if (illegalCharacters.length) {
return (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.prefixValidation.illegalCharacters"
defaultMessage="Remove the {characterListLength, plural, one {character} other {characters}}
{characterList} from the prefix."
values={{
characterList: <strong>{illegalCharacters.join(' ')}</strong>,
characterListLength: illegalCharacters.length,
}}
/>
);
}
if (indexNameContainsSpaces(prefix)) {
return (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.prefixValidation.noEmptySpace"
defaultMessage="Spaces are not allowed in the prefix."
/>
);
}
return null;
};
export const validateSuffix = (suffix) => {
// If it's empty, it is valid
if (!suffix || !suffix.trim()) {
return null;
}
const illegalCharacters = findIllegalCharactersInIndexName(suffix);
if (illegalCharacters.length) {
return (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.suffixValidation.illegalCharacters"
defaultMessage="Remove the {characterListLength, plural, one {character} other {characters}}
{characterList} from the suffix."
values={{
characterList: <strong>{illegalCharacters.join(' ')}</strong>,
characterListLength: illegalCharacters.length,
}}
/>
);
}
if (indexNameContainsSpaces(suffix)) {
return (
<FormattedMessage
id="xpack.crossClusterReplication.autoFollowPattern.suffixValidation.noEmptySpace"
defaultMessage="Spaces are not allowed in the suffix."
/>
);
}
return null;
};
export const validateAutoFollowPattern = (autoFollowPattern = {}) => {
const errors = {};
let error = null;
let fieldValue;
Object.keys(autoFollowPattern).forEach((fieldName) => {
fieldValue = autoFollowPattern[fieldName];
error = null;
switch(fieldName) {
case 'name':
error = validateName(fieldValue);
break;
case 'leaderIndexPatterns':
if (!fieldValue.length) {
error = {
message: i18n.translate('xpack.crossClusterReplication.autoFollowPattern.leaderIndexPatternValidation.isEmpty', {
defaultMessage: 'At least one leader index pattern is required.',
})
};
}
break;
case 'followIndexPatternPrefix':
error = validatePrefix(fieldValue);
break;
case 'followIndexPatternSuffix':
error = validateSuffix(fieldValue);
break;
}
errors[fieldName] = error;
});
return errors;
};

View file

@ -0,0 +1,37 @@
/*
* 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 { validateAutoFollowPattern } from './auto_follow_pattern_validators';
jest.mock('ui/index_patterns/index_patterns.js', () => ({
IndexPatternsProvider: jest.fn(),
}));
jest.mock('ui/index_patterns/index_patterns_api_client_provider.js', () => ({
IndexPatternsApiClientProvider: jest.fn(),
}));
describe('Auto-follow pattern validators', () => {
describe('validateAutoFollowPattern()', () => {
it('returns empty object when autoFollowPattern is undefined', () => {
const errors = validateAutoFollowPattern();
expect(errors).toMatchSnapshot();
});
it('should validate all props from auto-follow patten', () => {
const autoFollowPattern = {
name: '_wrong-name',
leaderIndexPatterns: ['wrong\pattern'],
followIndexPatternPrefix: 'pre?fix_',
followIndexPatternSuffix: '_suf?fix',
otherProp: 'foo'
};
const errors = validateAutoFollowPattern(autoFollowPattern);
expect(errors).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { BASE_PATH } from '../../../common/constants';
export const listBreadcrumb = {
text: i18n.translate('xpack.crossClusterReplication.homeBreadcrumbTitle', {
defaultMessage: 'Cross Cluster Replication',
}),
href: `#${BASE_PATH}`,
};
export const addBreadcrumb = {
text: i18n.translate('xpack.crossClusterReplication.addBreadcrumbTitle', {
defaultMessage: 'Add',
}),
};
export const editBreadcrumb = {
text: i18n.translate('xpack.crossClusterReplication.editBreadcrumbTitle', {
defaultMessage: 'Edit',
}),
};

View file

@ -0,0 +1,11 @@
/*
* 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 { 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`;

View file

@ -0,0 +1,16 @@
/*
* 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 { parse } from 'querystring';
export function extractQueryParams(queryString) {
const hrefSplit = queryString.split('?');
if (!hrefSplit.length) {
return {};
}
return parse(hrefSplit[1]);
}

View file

@ -0,0 +1,100 @@
/*
* 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.
*/
/**
* This file based on guidance from https://github.com/elastic/eui/blob/master/wiki/react-router.md
*/
import { createLocation } from 'history';
import { stringify } from 'querystring';
import { APPS, BASE_PATH, BASE_PATH_REMOTE_CLUSTERS } from '../../../common/constants';
const isModifiedEvent = event => !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
const isLeftClickEvent = event => event.button === 0;
const queryParamsFromObject = params => {
if (!params) {
return;
}
const paramsStr = stringify(params, '&', '=', {
encodeURIComponent: (val) => val, // Don't encode special chars
});
return `?${paramsStr}`;
};
const appToBasePathMap = {
[APPS.CCR_APP]: BASE_PATH,
[APPS.REMOTE_CLUSTER_APP]: BASE_PATH_REMOTE_CLUSTERS
};
class Routing {
_userHasLeftApp = false;
_reactRouter = null;
/**
* The logic for generating hrefs and onClick handlers from the `to` prop is largely borrowed from
* https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/Link.js.
*
* @param {*} to URL to navigate to
*/
getRouterLinkProps(to, base = BASE_PATH, params = {}) {
const search = queryParamsFromObject(params) || '';
const location = typeof to === "string"
? createLocation(base + to + search, null, null, this._reactRouter.history.location)
: to;
const href = this._reactRouter.history.createHref(location);
const onClick = event => {
if (event.defaultPrevented) {
return;
}
// If target prop is set (e.g. to "_blank"), let browser handle link.
if (event.target.getAttribute('target')) {
return;
}
if (isModifiedEvent(event) || !isLeftClickEvent(event)) {
return;
}
// Prevent regular link behavior, which causes a browser refresh.
event.preventDefault();
this._reactRouter.history.push(location);
};
return { href, onClick };
}
navigate(route = '/home', app = APPS.CCR_APP, params) {
const search = queryParamsFromObject(params);
this._reactRouter.history.push({
pathname: encodeURI(appToBasePathMap[app] + route),
search,
});
}
get reactRouter() {
return this._reactRouter;
}
set reactRouter(router) {
this._reactRouter = router;
}
get userHasLeftApp() {
return this._userHasLeftApp;
}
set userHasLeftApp(hasLeft) {
this._userHasLeftApp = hasLeft;
}
}
export default new Routing();

View file

@ -0,0 +1,16 @@
/*
* 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 objectToArray = (obj) => (
Object.keys(obj).map(k => ({ ...obj[k], __id__: k }))
);
export const arrayToObject = (array, keyProp = 'id') => (
array.reduce((acc, item) => {
acc[item[keyProp]] = item;
return acc;
}, {})
);

View file

@ -0,0 +1,31 @@
/*
* 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 { objectToArray, arrayToObject } from './utils';
describe('utils', () => {
describe('objectToArray()', () => {
it('should convert object to an array', () => {
const item1 = { name: 'foo' };
const item2 = { name: 'bar' };
const expected = [{ ...item1, __id__: 'item1' }, { ...item2, __id__: 'item2' }];
const output = objectToArray({ item1, item2 });
expect(output).toEqual(expected);
});
});
describe('arrayToObject()', () => {
it('should convert an array to array', () => {
const item1 = { name: 'foo', customKey: 'key1' };
const item2 = { name: 'bar', customKey: 'key2' };
const expected = { key1: item1, key2: item2 };
const output = arrayToObject([item1, item2], 'customKey');
expect(output).toEqual(expected);
});
});
});

View file

@ -0,0 +1,20 @@
/*
* 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.
*/
// Api
export const API = 'API';
export const API_REQUEST_START = 'API_REQUEST_START';
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_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';

View file

@ -0,0 +1,31 @@
/*
* 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 { 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 },
});
export const apiRequestEnd = ({ label, scope }) => ({ type: t.API_REQUEST_END, payload: { label, scope } });
export const setApiError = ({ error, scope }) => ({
type: t.API_ERROR_SET,
payload: { error, scope },
});
export const clearApiError = scope => ({ type: t.API_ERROR_SET, payload: { error: null, scope } });

View file

@ -0,0 +1,144 @@
/*
* 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 { SECTIONS, API_STATUS } from '../../constants';
import {
loadAutoFollowPatterns as loadAutoFollowPatternsRequest,
getAutoFollowPattern as getAutoFollowPatternRequest,
createAutoFollowPattern as createAutoFollowPatternRequest,
updateAutoFollowPattern as updateAutoFollowPatternRequest,
deleteAutoFollowPattern as deleteAutoFollowPatternRequest,
} from '../../services/api';
import routing from '../../services/routing';
import * as t from '../action_types';
import { sendApiRequest } from './api';
import { getDetailPanelAutoFollowPatternName } from '../selectors';
const { AUTO_FOLLOW_PATTERN: scope } = SECTIONS;
export const editAutoFollowPattern = (name) => ({
type: t.AUTO_FOLLOW_PATTERN_EDIT,
payload: name
});
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 loadAutoFollowPatterns = (isUpdating = false) =>
sendApiRequest({
label: t.AUTO_FOLLOW_PATTERN_LOAD,
scope,
status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING,
handler: async () => {
return 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;
})
)
});
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,
handler: async () => {
if (isUpdating) {
return await updateAutoFollowPatternRequest(id, autoFollowPattern);
}
return await createAutoFollowPatternRequest({ id, ...autoFollowPattern });
},
onSuccess() {
const successMessage = isUpdating
? i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successMultipleNotificationTitle', {
defaultMessage: `Auto-follow pattern '{name}' updated successfully`,
values: { name: id },
})
: i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successSingleNotificationTitle', {
defaultMessage: `Added auto-follow pattern '{name}'`,
values: { name: id },
});
toastNotifications.addSuccess(successMessage);
routing.navigate(`/auto_follow_patterns`, undefined, {
pattern: encodeURIComponent(id),
});
},
})
);
export const deleteAutoFollowPattern = (id) => (
sendApiRequest({
label: t.AUTO_FOLLOW_PATTERN_DELETE,
scope: `${scope}-delete`,
status: API_STATUS.DELETING,
handler: async () => (
deleteAutoFollowPatternRequest(id)
),
onSuccess(response, dispatch, getState) {
/**
* We can have 1 or more auto-follow pattern delete 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.autoFollowPattern.removeAction.errorMultipleNotificationTitle', {
defaultMessage: `Error removing {count} auto-follow patterns`,
values: { count: response.errors.length },
})
: i18n.translate('xpack.crossClusterReplication.autoFollowPattern.removeAction.errorSingleNotificationTitle', {
defaultMessage: `Error removing the '{name}' auto-follow pattern`,
values: { name: response.errors[0].id },
});
toastNotifications.addDanger(errorMessage);
}
if (response.itemsDeleted.length) {
const hasMultipleDelete = response.itemsDeleted.length > 1;
const successMessage = hasMultipleDelete
? i18n.translate('xpack.crossClusterReplication.autoFollowPattern.removeAction.successMultipleNotificationTitle', {
defaultMessage: `{count} auto-follow patterns were removed`,
values: { count: response.itemsDeleted.length },
})
: i18n.translate('xpack.crossClusterReplication.autoFollowPattern.removeAction.successSingleNotificationTitle', {
defaultMessage: `Auto-follow pattern '{name}' was removed`,
values: { name: response.itemsDeleted[0] },
});
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());
}
}
})
);

View file

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

View file

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

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from '../action_types';
import { apiRequestStart, apiRequestEnd, setApiError, clearApiError } from '../actions/api';
export const apiMiddleware = ({ dispatch, getState }) => next => async (action) => {
next(action);
if (action.type !== t.API) {
return;
}
const { label, scope, status, handler, onSuccess, onError } = action.payload;
dispatch(clearApiError(scope));
dispatch(apiRequestStart({ label, scope, status }));
try {
const response = await handler(dispatch);
dispatch(apiRequestEnd({ label, scope }));
dispatch({ type: `${label}_SUCCESS`, payload: response });
onSuccess(response, dispatch, getState);
} catch (error) {
dispatch(apiRequestEnd({ label, scope }));
dispatch(setApiError({ error, scope }));
dispatch({ type: `${label}_FAILURE`, payload: error });
onError(error, dispatch, getState);
}
};

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import routing from '../../services/routing';
import * as t from '../action_types';
import { extractQueryParams } from '../../services/query_params';
export const autoFollowPatternMiddleware = () => 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)}`,
});
}
}
break;
}
return next(action);
};

View file

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

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SECTIONS, API_STATUS } from '../../constants';
import * as t from '../action_types';
export const initialState = {
status: {
[SECTIONS.AUTO_FOLLOW_PATTERN]: API_STATUS.IDLE,
[SECTIONS.INDEX_FOLLOWER]: API_STATUS.IDLE,
},
error: {
[SECTIONS.AUTO_FOLLOW_PATTERN]: null,
[SECTIONS.INDEX_FOLLOWER]: null,
},
};
export const reducer = (state = initialState, action) => {
const payload = action.payload || {};
const { scope, status, error } = payload;
switch (action.type) {
case t.API_REQUEST_START: {
return { ...state, status: { ...state.status, [scope]: status } };
}
case t.API_REQUEST_END: {
return { ...state, status: { ...state.status, [scope]: API_STATUS.IDLE } };
}
case t.API_ERROR_SET: {
return { ...state, error: { ...state.error, [scope]: error } };
}
default:
return state;
}
};

View file

@ -0,0 +1,33 @@
/*
* 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 { reducer, initialState } from './api';
import { API_STATUS } from '../../constants';
import { apiRequestStart, apiRequestEnd, setApiError } from '../actions';
describe('CCR Api reducers', () => {
const scope = 'testSection';
it('API_REQUEST_START should set the Api status to "loading" on scope', () => {
const result = reducer(initialState, apiRequestStart({ scope }));
expect(result.status[scope]).toEqual(API_STATUS.LOADING);
});
it('API_END should set the Api status to "idle" on scope', () => {
const updatedState = reducer(initialState, apiRequestStart({ scope }));
const result = reducer(updatedState, apiRequestEnd({ scope }));
expect(result.status[scope]).toEqual(API_STATUS.IDLE);
});
it('API_ERROR_SET should set the Api error on scope', () => {
const error = { foo: 'bar' };
const result = reducer(initialState, setApiError({ error, scope }));
expect(result.error[scope]).toBe(error);
});
});

View file

@ -0,0 +1,49 @@
/*
* 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';
import { getPrefixSuffixFromFollowPattern } from '../../services/auto_follow_pattern';
const initialState = {
byId: {},
selectedId: null,
detailPanelId: null,
};
const success = action => `${action}_SUCCESS`;
const parseAutoFollowPattern = (autoFollowPattern) => {
// Extract prefix and suffix from follow index pattern
const { followIndexPatternPrefix, followIndexPatternSuffix } = getPrefixSuffixFromFollowPattern(autoFollowPattern.followIndexPattern);
return { ...autoFollowPattern, followIndexPatternPrefix, followIndexPatternSuffix };
};
export const reducer = (state = initialState, action) => {
switch (action.type) {
case success(t.AUTO_FOLLOW_PATTERN_LOAD): {
return { ...state, byId: arrayToObject(action.payload.patterns.map(parseAutoFollowPattern), 'name') };
}
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_DETAIL_PANEL: {
return { ...state, detailPanelId: action.payload };
}
case success(t.AUTO_FOLLOW_PATTERN_DELETE): {
const byId = { ...state.byId };
const { itemsDeleted } = action.payload;
itemsDeleted.forEach(id => delete byId[id]);
return { ...state, byId };
}
default:
return state;
}
};

View file

@ -0,0 +1,14 @@
/*
* 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 { combineReducers } from 'redux';
import { reducer as api } from './api';
import { reducer as autoFollowPattern } from './auto_follow_pattern';
export const ccr = combineReducers({
autoFollowPattern,
api,
});

View file

@ -0,0 +1,41 @@
/*
* 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 { createSelector } from 'reselect';
import { objectToArray } from '../../services/utils';
// Api
export const getApiState = (state) => state.api;
export const getApiStatus = (scope) => createSelector(getApiState, (apiState) => apiState.status[scope]);
export const getApiError = (scope) => createSelector(getApiState, (apiState) => apiState.error[scope]);
export const isApiAuthorized = (scope) => createSelector(getApiError(scope), (error) => {
if (!error) {
return true;
}
return error.status !== 403;
});
// 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(getAutoFollowPatternState, (autoFollowPatternsState) => {
if(!autoFollowPatternsState.detailPanelId) {
return null;
}
return autoFollowPatternsState.byId[autoFollowPatternsState.detailPanelId];
});
export const getListAutoFollowPatterns = createSelector(getAutoFollowPatterns, (autoFollowPatterns) => objectToArray(autoFollowPatterns));

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { applyMiddleware, compose, createStore } from 'redux';
import { apiMiddleware, autoFollowPatternMiddleware } from './middleware';
import { ccr } from './reducers';
function createCrossClusterReplicationStore(initialState = {}) {
const enhancers = [applyMiddleware(apiMiddleware, autoFollowPatternMiddleware)];
if (window.__REDUX_DEVTOOLS_EXTENSION__) {
enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__());
}
return createStore(ccr, initialState, compose(...enhancers));
}
export const ccrStore = createCrossClusterReplicationStore();

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './register_ccr_section';
import './register_routes';

View file

@ -0,0 +1,13 @@
// Import the EUI global scope so we can use EUI constants
@import 'ui/public/styles/_styling_constants';
// Cross Cluster Replication plugin styles
// Prefix all styles with "ccr" to avoid conflicts.
// Examples
// ccrChart
// ccrChart__legend
// ccrChart__legend--small
// ccrChart__legend-isLoading
@import 'app/app';

View file

@ -0,0 +1,3 @@
<kbn-management-app section="elasticsearch/ccr">
<div id="ccrReactRoot"></div>
</kbn-management-app>

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { management } from 'ui/management';
import { i18n } from '@kbn/i18n';
import chrome from 'ui/chrome';
import { BASE_PATH } from '../common/constants';
if (chrome.getInjected('ccrUiEnabled')) {
const esSection = management.getSection('elasticsearch');
esSection.register('ccr', {
visible: true,
display: i18n.translate('xpack.crossClusterReplication.appTitle', { defaultMessage: 'Cross Cluster Replication' }),
order: 3,
url: `#${BASE_PATH}`
});
}

View file

@ -0,0 +1,66 @@
/*
* 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 routes from 'ui/routes';
import { unmountComponentAtNode } from 'react-dom';
import chrome from 'ui/chrome';
import template from './main.html';
import { BASE_PATH } from '../common/constants/base_path';
import { renderReact } from './app';
import { setHttpClient } from './app/services/api';
if (chrome.getInjected('ccrUiEnabled')) {
let elem;
const CCR_REACT_ROOT = 'ccrReactRoot';
const unmountReactApp = () => elem && unmountComponentAtNode(elem);
routes.when(`${BASE_PATH}/:section?/:view?/:id?`, {
template: template,
controllerAs: 'ccr',
controller: class CrossClusterReplicationController {
constructor($scope, $route, $http) {
/**
* React-router's <Redirect> does not play wall 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);
$scope.$$postDigest(() => {
elem = document.getElementById(CCR_REACT_ROOT);
renderReact(elem);
// Angular Lifecycle
const appRoute = $route.current;
const stopListeningForLocationChange = $scope.$on('$locationChangeSuccess', () => {
const currentRoute = $route.current;
const isNavigationInApp = currentRoute.$$route.template === appRoute.$$route.template;
// When we navigate within CCR, prevent Angular from re-matching the route and rebuild the app
if (isNavigationInApp) {
$route.current = appRoute;
} else {
// Any clean up when User leaves the CCR
}
$scope.$on('$destroy', () => {
stopListeningForLocationChange && stopListeningForLocationChange();
unmountReactApp();
});
});
});
}
}
});
}

View file

@ -0,0 +1,65 @@
/*
* 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 elasticsearchJsPlugin = (Client, config, components) => {
const ca = components.clientAction.factory;
Client.prototype.ccr = components.clientAction.namespaceFactory();
const ccr = Client.prototype.ccr.prototype;
ccr.autoFollowPatterns = ca({
urls: [
{
fmt: '/_ccr/auto_follow',
}
],
method: 'GET'
});
ccr.autoFollowPattern = ca({
urls: [
{
fmt: '/_ccr/auto_follow/<%=id%>',
req: {
id: {
type: 'string'
}
}
}
],
method: 'GET'
});
ccr.saveAutoFollowPattern = ca({
urls: [
{
fmt: '/_ccr/auto_follow/<%=id%>',
req: {
id: {
type: 'string'
}
}
}
],
needBody: true,
method: 'PUT'
});
ccr.deleteAutoFollowPattern = ca({
urls: [
{
fmt: '/_ccr/auto_follow/<%=id%>',
req: {
id: {
type: 'string'
}
}
}
],
needBody: true,
method: 'DELETE'
});
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const deserializeAutoFollowPattern = (
{ name, pattern: { remote_cluster, leader_index_patterns, follow_index_pattern } } = { pattern: {} } // eslint-disable-line camelcase
) => ({
name,
remoteCluster: remote_cluster,
leaderIndexPatterns: leader_index_patterns,
followIndexPattern: follow_index_pattern,
});
export const deserializeListAutoFollowPatterns = autoFollowPatterns =>
autoFollowPatterns.map(deserializeAutoFollowPattern);
export const serializeAutoFollowPattern = ({
remoteCluster,
leaderIndexPatterns,
followIndexPattern,
}) => ({
remote_cluster: remoteCluster,
leader_index_patterns: leaderIndexPatterns,
follow_index_pattern: followIndexPattern,
});

View file

@ -0,0 +1,102 @@
/*
* 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 {
deserializeAutoFollowPattern,
deserializeListAutoFollowPatterns,
serializeAutoFollowPattern,
} from './auto_follow_pattern_serialization';
describe('[CCR] auto-follow_serialization', () => {
describe('deserializeAutoFollowPattern()', () => {
it('should return empty object if name or esObject are not provided', () => {
expect(deserializeAutoFollowPattern()).toEqual({});
});
it('should deserialize Elasticsearch object', () => {
const expected = {
name: 'some-name',
remoteCluster: 'foo',
leaderIndexPatterns: ['foo-*'],
followIndexPattern: 'bar'
};
const esObject = {
name: 'some-name',
pattern: {
remote_cluster: expected.remoteCluster,
leader_index_patterns: expected.leaderIndexPatterns,
follow_index_pattern: expected.followIndexPattern
}
};
expect(deserializeAutoFollowPattern(esObject)).toEqual(expected);
});
});
describe('deserializeListAutoFollowPatterns()', () => {
it('should deserialize list of Elasticsearch objects', () => {
const name1 = 'foo1';
const name2 = 'foo2';
const expected = [
{
name: name1,
remoteCluster: 'foo1',
leaderIndexPatterns: ['foo1-*'],
followIndexPattern: 'bar2'
},
{
name: name2,
remoteCluster: 'foo2',
leaderIndexPatterns: ['foo2-*'],
followIndexPattern: 'bar2'
}
];
const esObjects = {
patterns: [
{
name: name1,
pattern: {
remote_cluster: expected[0].remoteCluster,
leader_index_patterns: expected[0].leaderIndexPatterns,
follow_index_pattern: expected[0].followIndexPattern
}
},
{
name: name2,
pattern: {
remote_cluster: expected[1].remoteCluster,
leader_index_patterns: expected[1].leaderIndexPatterns,
follow_index_pattern: expected[1].followIndexPattern
}
}
]
};
expect(deserializeListAutoFollowPatterns(esObjects.patterns)).toEqual(expected);
});
});
describe('serializeAutoFollowPattern()', () => {
it('should serialize object to Elasticsearch object', () => {
const expected = {
remote_cluster: 'foo',
leader_index_patterns: ['bar-*'],
follow_index_pattern: 'faz'
};
const object = {
remoteCluster: expected.remote_cluster,
leaderIndexPatterns: expected.leader_index_patterns,
followIndexPattern: expected.follow_index_pattern
};
expect(serializeAutoFollowPattern(object)).toEqual(expected);
});
});
});

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { once } from 'lodash';
import { elasticsearchJsPlugin } from '../../client/elasticsearch_ccr';
const callWithRequest = once(server => {
const config = {
plugins: [ elasticsearchJsPlugin ],
...server.config().get('elasticsearch')
};
const cluster = server.plugins.elasticsearch.createCluster('ccr', config);
return cluster.callWithRequest;
});
export const callWithRequestFactory = (server, request) => {
return (...args) => {
return callWithRequest(server)(request, ...args);
};
};

View file

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

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export function checkLicense(xpackLicenseInfo) {
const pluginName = 'Cross Cluster Replication';
// If, for some reason, we cannot get the license information
// from Elasticsearch, assume worst case and disable
if (!xpackLicenseInfo || !xpackLicenseInfo.isAvailable()) {
return {
isAvailable: false,
showLinks: true,
enableLinks: false,
message: `You cannot use ${pluginName} because license information is not available at this time.`,
};
}
const VALID_LICENSE_MODES = ['trial', 'basic', 'standard', 'gold', 'platinum'];
const isLicenseModeValid = xpackLicenseInfo.license.isOneOf(VALID_LICENSE_MODES);
const isLicenseActive = xpackLicenseInfo.license.isActive();
const licenseType = xpackLicenseInfo.license.getType();
// License is not valid
if (!isLicenseModeValid) {
return {
isAvailable: false,
showLinks: false,
message: `Your ${licenseType} license does not support ${pluginName}. Please upgrade your license.`,
};
}
// License is valid but not active
if (!isLicenseActive) {
return {
isAvailable: false,
showLinks: true,
enableLinks: false,
message: `You cannot use ${pluginName} because your ${licenseType} license has expired.`,
};
}
// License is valid and active
return {
isAvailable: true,
showLinks: true,
enableLinks: true,
};
}

View file

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

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from 'expect.js';
import { wrapCustomError } from '../wrap_custom_error';
describe('wrap_custom_error', () => {
describe('#wrapCustomError', () => {
it('should return a Boom object', () => {
const originalError = new Error('I am an error');
const statusCode = 404;
const wrappedError = wrapCustomError(originalError, statusCode);
expect(wrappedError.isBoom).to.be(true);
expect(wrappedError.output.statusCode).to.equal(statusCode);
});
});
});

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from 'expect.js';
import { wrapEsError } from '../wrap_es_error';
describe('wrap_es_error', () => {
describe('#wrapEsError', () => {
let originalError;
beforeEach(() => {
originalError = new Error('I am an error');
originalError.statusCode = 404;
originalError.response = '{}';
});
it('should return a Boom object', () => {
const wrappedError = wrapEsError(originalError);
expect(wrappedError.isBoom).to.be(true);
});
it('should return the correct Boom object', () => {
const wrappedError = wrapEsError(originalError);
expect(wrappedError.output.statusCode).to.be(originalError.statusCode);
expect(wrappedError.output.payload.message).to.be(originalError.message);
});
it('should return the correct Boom object with custom message', () => {
const wrappedError = wrapEsError(originalError, { 404: 'No encontrado!' });
expect(wrappedError.output.statusCode).to.be(originalError.statusCode);
expect(wrappedError.output.payload.message).to.be('No encontrado!');
});
});
});

View file

@ -0,0 +1,19 @@
/*
* 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 expect from 'expect.js';
import { wrapUnknownError } from '../wrap_unknown_error';
describe('wrap_unknown_error', () => {
describe('#wrapUnknownError', () => {
it('should return a Boom object', () => {
const originalError = new Error('I am an error');
const wrappedError = wrapUnknownError(originalError);
expect(wrappedError.isBoom).to.be(true);
});
});
});

View file

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

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
/**
* Wraps a custom error into a Boom error response and returns it
*
* @param err Object error
* @param statusCode Error status code
* @return Object Boom error response
*/
export function wrapCustomError(err, statusCode) {
return Boom.boomify(err, { statusCode });
}

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