[Remote Clusters] Migrate to the New Platform with a shim (#37559) (#38843)

* Extract isEsError from router.
This commit is contained in:
CJ Cenizal 2019-06-12 16:22:15 -07:00 committed by GitHub
parent 101b4d56cc
commit 32a5c22639
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
151 changed files with 3256 additions and 3755 deletions

View file

@ -5,9 +5,9 @@
*/
import { registerTestBed } from '../../../../../test_utils';
import { RemoteClusterAdd } from '../../../public/sections/remote_cluster_add';
import { createRemoteClustersStore } from '../../../public/store';
import { registerRouter } from '../../../public/services/routing';
import { RemoteClusterAdd } from '../../../public/app/sections/remote_cluster_add';
import { createRemoteClustersStore } from '../../../public/app/store';
import { registerRouter } from '../../../public/app/services/routing';
const testBedConfig = {
store: createRemoteClustersStore,

View file

@ -5,9 +5,9 @@
*/
import { registerTestBed } from '../../../../../test_utils';
import { RemoteClusterEdit } from '../../../public/sections/remote_cluster_edit';
import { createRemoteClustersStore } from '../../../public/store';
import { registerRouter } from '../../../public/services/routing';
import { RemoteClusterEdit } from '../../../public/app/sections/remote_cluster_edit';
import { createRemoteClustersStore } from '../../../public/app/store';
import { registerRouter } from '../../../public/app/services/routing';
import { REMOTE_CLUSTER_EDIT_NAME } from './constants';

View file

@ -5,9 +5,9 @@
*/
import { registerTestBed, findTestSubject } from '../../../../../test_utils';
import { RemoteClusterList } from '../../../public/sections/remote_cluster_list';
import { createRemoteClustersStore } from '../../../public/store';
import { registerRouter } from '../../../public/services/routing';
import { RemoteClusterList } from '../../../public/app/sections/remote_cluster_list';
import { createRemoteClustersStore } from '../../../public/app/store';
import { registerRouter } from '../../../public/app/services/routing';
const testBedConfig = {
store: createRemoteClustersStore,

View file

@ -6,13 +6,23 @@
import axios from 'axios';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import chrome from 'ui/chrome'; // eslint-disable-line import/no-unresolved
import { MANAGEMENT_BREADCRUMB } from 'ui/management'; // eslint-disable-line import/no-unresolved
import { fatalError, toastNotifications } from 'ui/notify'; // eslint-disable-line import/no-unresolved
import { setHttpClient } from '../../../public/services/api';
import { init as initBreadcrumb } from '../../../public/app/services/breadcrumb';
import { init as initHttp } from '../../../public/app/services/http';
import { init as initNotification } from '../../../public/app/services/notification';
import { init as initHttpRequests } from './http_requests';
export const setupEnvironment = () => {
chrome.breadcrumbs = {
set: () => {},
};
// axios has a $http like interface so using it to simulate $http
setHttpClient(axios.create({ adapter: axiosXhrAdapter }));
initHttp(axios.create({ adapter: axiosXhrAdapter }), (path) => path);
initBreadcrumb(() => {}, MANAGEMENT_BREADCRUMB);
initNotification(toastNotifications, fatalError);
const { server, httpRequestsMockHelpers } = initHttpRequests();

View file

@ -4,14 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { pageHelpers, nextTick } from './helpers';
import { pageHelpers, nextTick, setupEnvironment } from './helpers';
import { NON_ALPHA_NUMERIC_CHARS, ACCENTED_CHARS } from './helpers/constants';
jest.mock('ui/chrome', () => ({
addBasePath: (path) => path || 'api/cross_cluster_replication',
breadcrumbs: { set: () => {} },
}));
const { setup } = pageHelpers.remoteClustersAdd;
describe('Create Remote cluster', () => {
@ -20,6 +15,15 @@ describe('Create Remote cluster', () => {
let exists;
let actions;
let form;
let server;
beforeAll(() => {
({ server } = setupEnvironment());
});
afterAll(() => {
server.restore();
});
beforeEach(() => {
({ form, exists, find, actions } = setup());

View file

@ -4,15 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { RemoteClusterForm } from '../../public/sections/components/remote_cluster_form';
import { RemoteClusterForm } from '../../public/app/sections/components/remote_cluster_form';
import { pageHelpers, setupEnvironment, nextTick } from './helpers';
import { REMOTE_CLUSTER_EDIT, REMOTE_CLUSTER_EDIT_NAME } from './helpers/constants';
jest.mock('ui/chrome', () => ({
addBasePath: (path) => path || '/api/remote_clusters',
breadcrumbs: { set: () => {} },
}));
const { setup } = pageHelpers.remoteClustersEdit;
const { setup: setupRemoteClustersAdd } = pageHelpers.remoteClustersAdd;

View file

@ -6,28 +6,9 @@
import { pageHelpers, setupEnvironment, nextTick, getRandomString, findTestSubject } from './helpers';
import { getRouter } from '../../public/services';
import { getRouter } from '../../public/app/services';
import { getRemoteClusterMock } from '../../fixtures/remote_cluster';
jest.mock('ui/chrome', () => ({
addBasePath: (path) => path || '/api/remote_clusters',
breadcrumbs: { set: () => {} },
getInjected: (key) => {
if (key === 'uiCapabilities') {
return {
navLinks: {},
management: {},
catalogue: {}
};
}
throw new Error(`Unexpected call to chrome.getInjected with key ${key}`);
}
}));
jest.mock('../../../../../src/legacy/core_plugins/ui_metric/public', () => ({
trackUiMetric: jest.fn(),
}));
const { setup } = pageHelpers.remoteClustersList;
describe('<RemoteClusterList />', () => {

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 { i18n } from '@kbn/i18n';
import { LICENSE_TYPE_BASIC, LicenseType } from '../../../common/constants';
export const PLUGIN = {
ID: 'remote_clusters',
// Remote Clusters are used in both CCS and CCR, and CCS is available for all licenses.
MINIMUM_LICENSE_REQUIRED: LICENSE_TYPE_BASIC as LicenseType,
getI18nName: (): string => {
return i18n.translate('xpack.remoteClusters.appName', {
defaultMessage: 'Remote Clusters',
});
},
};
export const API_BASE_PATH = '/api/remote_clusters';

View file

@ -4,58 +4,64 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Legacy } from 'kibana';
import { resolve } from 'path';
import { PLUGIN } from './common';
import { registerLicenseChecker } from './server/lib/register_license_checker';
import {
registerListRoute,
registerAddRoute,
registerUpdateRoute,
registerDeleteRoute,
} from './server/routes/api/remote_clusters';
import { Plugin as RemoteClustersPlugin } from './plugin';
import { createShim } from './shim';
export function remoteClusters(kibana) {
export function remoteClusters(kibana: any) {
return new kibana.Plugin({
id: PLUGIN.ID,
configPrefix: 'xpack.remote_clusters',
publicDir: resolve(__dirname, 'public'),
// xpack_main is required for license checking.
require: ['kibana', 'elasticsearch', 'xpack_main', 'index_management'],
uiExports: {
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
managementSections: [
'plugins/remote_clusters',
],
injectDefaultVars(server) {
managementSections: ['plugins/remote_clusters'],
injectDefaultVars(server: Legacy.Server) {
const config = server.config();
return {
remoteClustersUiEnabled: config.get('xpack.remote_clusters.ui.enabled'),
};
},
},
config(Joi) {
config(Joi: any) {
return Joi.object({
// display menu item
ui: Joi.object({
enabled: Joi.boolean().default(true)
enabled: Joi.boolean().default(true),
}).default(),
// enable plugin
enabled: Joi.boolean().default(true),
}).default();
},
isEnabled(config) {
isEnabled(config: any) {
return (
config.get('xpack.remote_clusters.enabled') &&
config.get('xpack.index_management.enabled')
config.get('xpack.remote_clusters.enabled') && config.get('xpack.index_management.enabled')
);
},
init(server: Legacy.Server) {
const {
coreSetup,
pluginsSetup: {
license: { registerLicenseChecker },
},
} = createShim(server, PLUGIN.ID);
const remoteClustersPlugin = new RemoteClustersPlugin();
// Set up plugin.
remoteClustersPlugin.setup(coreSetup);
registerLicenseChecker(
server,
PLUGIN.ID,
PLUGIN.getI18nName(),
PLUGIN.MINIMUM_LICENSE_REQUIRED
);
},
init: function (server) {
registerLicenseChecker(server);
registerListRoute(server);
registerAddRoute(server);
registerUpdateRoute(server);
registerDeleteRoute(server);
}
});
}

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 { API_BASE_PATH } from './common';
import { CoreSetup } from './shim';
import {
registerGetRoute,
registerAddRoute,
registerUpdateRoute,
registerDeleteRoute,
} from './server/routes/api';
export class Plugin {
public setup(core: CoreSetup): void {
const {
http: { createRouter, isEsError },
} = core;
const router = createRouter(API_BASE_PATH);
// Register routes.
registerGetRoute(router);
registerAddRoute(router);
registerUpdateRoute(router);
registerDeleteRoute(router, isEsError);
}
}

View file

@ -4,8 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
export {
CRUD_APP_BASE_PATH,
} from './paths';
export { CRUD_APP_BASE_PATH } from './paths';
export * from './ui_metric';

View file

@ -4,6 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const PLUGIN = {
ID: 'remote_clusters'
};
export const CRUD_APP_BASE_PATH: string = '/management/elasticsearch/remote_clusters';

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 const UIM_APP_NAME: string = 'remote_clusters';
export const UIM_APP_LOAD: string = 'app_load';
export const UIM_CLUSTER_ADD: string = 'cluster_add';
export const UIM_CLUSTER_UPDATE: string = 'cluster_update';
export const UIM_CLUSTER_REMOVE: string = 'cluster_remove';
export const UIM_CLUSTER_REMOVE_MANY: string = 'cluster_remove_many';
export const UIM_SHOW_DETAILS_CLICK: string = 'show_details_click';

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render } from 'react-dom';
import { HashRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { App } from './app';
import { remoteClustersStore } from './store';
export const renderReact = async (elem, I18nContext) => {
render(
<I18nContext>
<Provider store={remoteClustersStore}>
<HashRouter>
<App />
</HashRouter>
</Provider>
</I18nContext>
, elem);
};

View file

@ -5,12 +5,13 @@
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiCallOut,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
export function ConfiguredByNodeWarning() {
return (
<EuiCallOut

View file

@ -173,14 +173,12 @@ Array [
id="mockId-help"
>
An IP address or host name, followed by the
<a
<button
class="euiLink euiLink--primary"
href="undefinedguide/en/elasticsearch/reference/undefined/modules-transport.html"
rel="noopener noreferrer"
target="_blank"
type="button"
>
transport port
</a>
</button>
of the remote cluster.
</div>
</div>
@ -218,14 +216,12 @@ Array [
Skip if unavailable
</strong>
.
<a
<button
class="euiLink euiLink--primary"
href="undefinedguide/en/elasticsearch/reference/undefined/modules-cross-cluster-search.html#_skipping_disconnected_clusters"
rel="noopener noreferrer"
target="_blank"
type="button"
>
Learn more.
</a>
</button>
</p>
</div>
</div>
@ -439,14 +435,12 @@ Array [
id="mockId-help"
>
An IP address or host name, followed by the
<a
<button
class="euiLink euiLink--primary"
href="undefinedguide/en/elasticsearch/reference/undefined/modules-transport.html"
rel="noopener noreferrer"
target="_blank"
type="button"
>
transport port
</a>
</button>
of the remote cluster.
</div>
</div>,

View file

@ -0,0 +1,613 @@
/*
* 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 { merge } from 'lodash';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiComboBox,
EuiDescribedFormGroup,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiLink,
EuiLoadingKibana,
EuiLoadingSpinner,
EuiOverlayMask,
EuiSpacer,
EuiSwitch,
EuiText,
EuiTitle,
} from '@elastic/eui';
import {
skippingDisconnectedClustersUrl,
transportPortUrl,
} from '../../../services/documentation';
import { validateName, validateSeeds, validateSeed } from './validators';
const defaultFields = {
name: '',
seeds: [],
skipUnavailable: false,
};
export class RemoteClusterForm extends Component {
static propTypes = {
save: PropTypes.func.isRequired,
cancel: PropTypes.func,
isSaving: PropTypes.bool,
saveError: PropTypes.object,
fields: PropTypes.object,
disabledFields: PropTypes.object,
}
static defaultProps = {
fields: merge({}, defaultFields),
disabledFields: {},
}
constructor(props) {
super(props);
const { fields, disabledFields } = props;
const fieldsState = merge({}, defaultFields, fields);
this.state = {
localSeedErrors: [],
seedInput: '',
fields: fieldsState,
disabledFields,
fieldsErrors: this.getFieldsErrors(fieldsState),
areErrorsVisible: false,
};
}
getFieldsErrors(fields, seedInput = '') {
const { name, seeds } = fields;
return {
name: validateName(name),
seeds: validateSeeds(seeds, seedInput),
};
}
onFieldsChange = (changedFields) => {
this.setState(({ fields: prevFields, seedInput }) => {
const newFields = {
...prevFields,
...changedFields,
};
return ({
fields: newFields,
fieldsErrors: this.getFieldsErrors(newFields, seedInput),
});
});
};
getAllFields() {
const {
fields: {
name,
seeds,
skipUnavailable,
},
} = this.state;
return {
name,
seeds,
skipUnavailable,
};
}
save = () => {
const { save } = this.props;
if (this.hasErrors()) {
this.setState({
areErrorsVisible: true,
});
return;
}
const cluster = this.getAllFields();
save(cluster);
};
onCreateSeed = (newSeed) => {
// If the user just hit enter without typing anything, treat it as a no-op.
if (!newSeed) {
return;
}
const localSeedErrors = validateSeed(newSeed);
if (localSeedErrors.length !== 0) {
this.setState({
localSeedErrors,
});
// Return false to explicitly reject the user's input.
return false;
}
const {
fields: {
seeds,
},
} = this.state;
const newSeeds = seeds.slice(0);
newSeeds.push(newSeed.toLowerCase());
this.onFieldsChange({ seeds: newSeeds });
};
onSeedsInputChange = (seedInput) => {
if (!seedInput) {
// If empty seedInput ("") don't do anything. This happens
// right after a seed is created.
return;
}
this.setState(({ fields, localSeedErrors }) => {
const { seeds } = fields;
// Allow typing to clear the errors, but not to add new ones.
const errors = (!seedInput || validateSeed(seedInput).length === 0) ? [] : localSeedErrors;
// EuiComboBox internally checks for duplicates and prevents calling onCreateOption if the
// input is a duplicate. So we need to surface this error here instead.
const isDuplicate = seeds.includes(seedInput);
if (isDuplicate) {
errors.push(i18n.translate('xpack.remoteClusters.remoteClusterForm.localSeedError.duplicateMessage',
{
defaultMessage: `Duplicate seed nodes aren't allowed.`,
}
));
}
return ({
localSeedErrors: errors,
fieldsErrors: this.getFieldsErrors(fields, seedInput),
seedInput,
});
});
};
onSeedsChange = (seeds) => {
this.onFieldsChange({ seeds: seeds.map(({ label }) => label) });
};
onSkipUnavailableChange = (e) => {
const skipUnavailable = e.target.checked;
this.onFieldsChange({ skipUnavailable });
};
resetToDefault = (fieldName) => {
this.onFieldsChange({
[fieldName]: defaultFields[fieldName],
});
};
hasErrors = () => {
const { fieldsErrors, localSeedErrors } = this.state;
const errorValues = Object.values(fieldsErrors);
const hasErrors = errorValues.some(error => error != null) || localSeedErrors.length;
return hasErrors;
};
renderSeeds() {
const {
areErrorsVisible,
fields: {
seeds,
},
fieldsErrors: {
seeds: errorsSeeds,
},
localSeedErrors,
} = this.state;
// Show errors if there is a general form error or local errors.
const areFormErrorsVisible = Boolean(areErrorsVisible && errorsSeeds);
const showErrors = areFormErrorsVisible || localSeedErrors.length !== 0;
const errors = areFormErrorsVisible ? localSeedErrors.concat(errorsSeeds) : localSeedErrors;
const formattedSeeds = seeds.map(seed => ({ label: seed }));
return (
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSeedsTitle"
defaultMessage="Seed nodes for cluster discovery"
/>
</h4>
</EuiTitle>
)}
description={(
<Fragment>
<p>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSeedsDescription1"
defaultMessage="A list of remote cluster nodes to query for the cluster state.
Specify multiple seed nodes so discovery doesn't fail if a node is unavailable."
/>
</p>
</Fragment>
)}
fullWidth
>
<EuiFormRow
data-test-subj="remoteClusterFormSeedNodesFormRow"
label={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldSeedsLabel"
defaultMessage="Seed nodes"
/>
)}
helpText={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSeedsHelpText"
defaultMessage="An IP address or host name, followed by the {transportPort} of the remote cluster."
values={{
transportPort: (
<EuiLink href={transportPortUrl} target="_blank">
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSeedsHelpText.transportPortLinkText"
defaultMessage="transport port"
/>
</EuiLink>
),
}}
/>
)}
isInvalid={showErrors}
error={errors}
fullWidth
>
<EuiComboBox
noSuggestions
placeholder={i18n.translate('xpack.remoteClusters.remoteClusterForm.fieldSeedsPlaceholder',
{
defaultMessage: 'host:port',
}
)}
selectedOptions={formattedSeeds}
onCreateOption={this.onCreateSeed}
onChange={this.onSeedsChange}
onSearchChange={this.onSeedsInputChange}
isInvalid={showErrors}
fullWidth
data-test-subj="remoteClusterFormSeedsInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
}
renderSkipUnavailable() {
const {
fields: {
skipUnavailable,
},
} = this.state;
return (
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableTitle"
defaultMessage="Make remote cluster optional"
/>
</h4>
</EuiTitle>
)}
description={(
<Fragment>
<p>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableDescription"
defaultMessage="By default, a request fails if any of the queried remote clusters
are unavailable. To continue sending a request to other remote clusters if this
cluster is unavailable, enable {optionName}. {learnMoreLink}"
values={{
optionName: (
<strong>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableDescription.optionNameLabel"
defaultMessage="Skip if unavailable"
/>
</strong>
),
learnMoreLink: (
<EuiLink href={skippingDisconnectedClustersUrl} target="_blank">
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableDescription.learnMoreLinkLabel"
defaultMessage="Learn more."
/>
</EuiLink>
),
}}
/>
</p>
</Fragment>
)}
fullWidth
>
<EuiFormRow
data-test-subj="remoteClusterFormSkipUnavailableFormRow"
className="remoteClusterSkipIfUnavailableSwitch"
hasEmptyLabelSpace
fullWidth
helpText={
skipUnavailable !== defaultFields.skipUnavailable ? (
<EuiLink onClick={() => { this.resetToDefault('skipUnavailable'); }}>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableResetLabel"
defaultMessage="Reset to default"
/>
</EuiLink>
) : null
}
>
<EuiSwitch
label={i18n.translate('xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableLabel', {
defaultMessage: 'Skip if unavailable',
})}
checked={skipUnavailable}
onChange={this.onSkipUnavailableChange}
data-test-subj="remoteClusterFormSkipUnavailableFormToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
}
renderActions() {
const { isSaving, cancel } = this.props;
const { areErrorsVisible } = this.state;
if (isSaving) {
return (
<EuiFlexGroup justifyContent="flexStart" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="l"/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.actions.savingText"
defaultMessage="Saving"
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
let cancelButton;
if (cancel) {
cancelButton = (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="primary"
onClick={cancel}
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
);
}
const isSaveDisabled = areErrorsVisible && this.hasErrors();
return (
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="remoteClusterFormSaveButton"
color="secondary"
iconType="check"
onClick={this.save}
fill
disabled={isSaveDisabled}
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
{cancelButton}
</EuiFlexGroup>
);
}
renderSavingFeedback() {
if (this.props.isSaving) {
return (
<EuiOverlayMask>
<EuiLoadingKibana size="xl"/>
</EuiOverlayMask>
);
}
return null;
}
renderSaveErrorFeedback() {
const { saveError } = this.props;
if (saveError) {
const { message, cause } = saveError;
let errorBody;
if (cause) {
if (cause.length === 1) {
errorBody = (
<p>{cause[0]}</p>
);
} else {
errorBody = (
<ul>
{cause.map(causeValue => <li key={causeValue}>{causeValue}</li>)}
</ul>
);
}
}
return (
<Fragment>
<EuiCallOut
title={message}
icon="cross"
color="danger"
>
{errorBody}
</EuiCallOut>
<EuiSpacer />
</Fragment>
);
}
return null;
}
renderErrors = () => {
const { areErrorsVisible } = this.state;
const hasErrors = this.hasErrors();
if (!areErrorsVisible || !hasErrors) {
return null;
}
return (
<Fragment>
<EuiSpacer size="m" />
<EuiCallOut
data-test-subj="remoteClusterFormGlobalError"
title={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.errorTitle"
defaultMessage="Fix errors before continuing."
/>
)}
color="danger"
iconType="cross"
/>
</Fragment>
);
}
render() {
const {
disabledFields: {
name: disabledName,
},
} = this.props;
const {
areErrorsVisible,
fields: {
name,
},
fieldsErrors: {
name: errorClusterName,
},
} = this.state;
return (
<Fragment>
{this.renderSaveErrorFeedback()}
<EuiForm>
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionNameTitle"
defaultMessage="Name"
/>
</h4>
</EuiTitle>
)}
description={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionNameDescription"
defaultMessage="A unique name for the remote cluster."
/>
)}
fullWidth
>
<EuiFormRow
data-test-subj="remoteClusterFormNameFormRow"
label={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNameLabel"
defaultMessage="Name"
/>
)}
helpText={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNameLabelHelpText"
defaultMessage="Name can only contain letters, numbers, underscores, and dashes."
/>
)}
error={errorClusterName}
isInvalid={Boolean(areErrorsVisible && errorClusterName)}
fullWidth
>
<EuiFieldText
isInvalid={Boolean(areErrorsVisible && errorClusterName)}
value={name}
onChange={e => this.onFieldsChange({ name: e.target.value })}
fullWidth
disabled={disabledName}
data-test-subj="remoteClusterFormNameInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{this.renderSeeds()}
{this.renderSkipUnavailable()}
</EuiForm>
{this.renderErrors()}
<EuiSpacer size="l" />
{this.renderActions()}
{this.renderSavingFeedback()}
</Fragment>
);
}
}

View file

@ -17,7 +17,7 @@ import {
EuiTitle,
} from '@elastic/eui';
import { remoteClustersUrl } from '../../../services/documentation_links';
import { remoteClustersUrl } from '../../../services/documentation';
export const RemoteClusterPageTitle = ({ title }) => (
<Fragment>

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiPageContent,
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH } from '../../constants';
import { getRouter, redirect, extractQueryParams } from '../../services';
import { setBreadcrumbs } from '../../services/breadcrumb';
import { RemoteClusterPageTitle, RemoteClusterForm } from '../components';
export class RemoteClusterAdd extends PureComponent {
static propTypes = {
addCluster: PropTypes.func,
isAddingCluster: PropTypes.bool,
addClusterError: PropTypes.object,
clearAddClusterErrors: PropTypes.func,
}
componentDidMount() {
setBreadcrumbs('add');
}
componentWillUnmount() {
// Clean up after ourselves.
this.props.clearAddClusterErrors();
}
save = (clusterConfig) => {
this.props.addCluster(clusterConfig);
};
cancel = () => {
const { history, route: { location: { search } } } = getRouter();
const { redirect: redirectUrl } = extractQueryParams(search);
if (redirectUrl) {
const decodedRedirect = decodeURIComponent(redirectUrl);
redirect(decodedRedirect);
} else {
history.push(CRUD_APP_BASE_PATH);
}
};
render() {
const { isAddingCluster, addClusterError } = this.props;
return (
<EuiPageContent
horizontalPosition="center"
className="remoteClusterAddPage"
>
<RemoteClusterPageTitle
title={(
<FormattedMessage
id="xpack.remoteClusters.addTitle"
defaultMessage="Add remote cluster"
/>
)}
/>
<RemoteClusterForm
isSaving={isAddingCluster}
saveError={addClusterError}
save={this.save}
cancel={this.cancel}
/>
</EuiPageContent>
);
}
}

View file

@ -0,0 +1,227 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiPageContent,
EuiSpacer,
EuiText,
EuiTextColor,
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH } from '../../constants';
import {
extractQueryParams,
getRouter,
getRouterLinkProps,
redirect,
} from '../../services';
import { setBreadcrumbs } from '../../services/breadcrumb';
import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components';
const disabledFields = {
name: true,
};
export class RemoteClusterEdit extends Component {
static propTypes = {
isLoading: PropTypes.bool,
cluster: PropTypes.object,
startEditingCluster: PropTypes.func,
stopEditingCluster: PropTypes.func,
editCluster: PropTypes.func,
isEditingCluster: PropTypes.bool,
getEditClusterError: PropTypes.string,
clearEditClusterErrors: PropTypes.func,
openDetailPanel: PropTypes.func,
}
constructor(props) {
super(props);
const {
match: {
params: {
name,
},
},
} = props;
setBreadcrumbs('edit', `?cluster=${name}`);
this.state = {
clusterName: name,
};
}
componentDidMount() {
const { startEditingCluster } = this.props;
const { clusterName } = this.state;
startEditingCluster(clusterName);
}
componentWillUnmount() {
// Clean up after ourselves.
this.props.clearEditClusterErrors();
this.props.stopEditingCluster();
}
save = (clusterConfig) => {
this.props.editCluster(clusterConfig);
};
cancel = () => {
const { openDetailPanel } = this.props;
const { clusterName } = this.state;
const { history, route: { location: { search } } } = getRouter();
const { redirect: redirectUrl } = extractQueryParams(search);
if (redirectUrl) {
const decodedRedirect = decodeURIComponent(redirectUrl);
redirect(decodedRedirect);
} else {
history.push(CRUD_APP_BASE_PATH);
openDetailPanel(clusterName);
}
};
renderContent() {
const {
clusterName,
} = this.state;
const {
isLoading,
cluster,
isEditingCluster,
getEditClusterError,
} = this.props;
if (isLoading) {
return (
<EuiFlexGroup
justifyContent="flexStart"
alignItems="center"
gutterSize="s"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.remoteClusters.edit.loadingLabel"
defaultMessage="Loading remote cluster..."
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (!cluster) {
return (
<Fragment>
<EuiCallOut
title={(
<FormattedMessage
id="xpack.remoteClusters.edit.loadingErrorTitle"
defaultMessage="Error loading remote cluster"
/>
)}
color="danger"
iconType="alert"
>
<FormattedMessage
id="xpack.remoteClusters.edit.loadingErrorMessage"
defaultMessage="The remote cluster '{name}' does not exist."
values={{ name: clusterName }}
/>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
{...getRouterLinkProps(CRUD_APP_BASE_PATH)}
iconType="arrowLeft"
flush="left"
>
<FormattedMessage
id="xpack.remoteClusters.edit.viewRemoteClustersButtonLabel"
defaultMessage="View remote clusters"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
}
const { isConfiguredByNode } = cluster;
if (isConfiguredByNode) {
return (
<Fragment>
<ConfiguredByNodeWarning />
<EuiSpacer size="s" />
<EuiButtonEmpty
color="primary"
onClick={this.cancel}
>
<FormattedMessage
id="xpack.remoteClusters.edit.backToRemoteClustersButtonLabel"
defaultMessage="Back to remote clusters"
/>
</EuiButtonEmpty>
</Fragment>
);
}
return (
<RemoteClusterForm
fields={cluster}
disabledFields={disabledFields}
isSaving={isEditingCluster}
saveError={getEditClusterError}
save={this.save}
cancel={this.cancel}
/>
);
}
render() {
return (
<EuiPageContent
horizontalPosition="center"
className="remoteClusterAddPage"
>
<RemoteClusterPageTitle
title={(
<FormattedMessage
id="xpack.remoteClusters.editTitle"
defaultMessage="Edit remote cluster"
/>
)}
/>
{this.renderContent()}
</EuiPageContent>
);
}
}

View file

@ -0,0 +1,123 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiConfirmModal,
EuiOverlayMask,
} from '@elastic/eui';
export class RemoveClusterButtonProvider extends Component {
static propTypes = {
removeClusters: PropTypes.func.isRequired,
clusterNames: PropTypes.array.isRequired,
children: PropTypes.func.isRequired,
};
state = {
isModalOpen: false,
};
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();
};
showConfirmModal = () => {
this.setState({
isModalOpen: true,
});
};
closeConfirmModal = () => {
this.setState({
isModalOpen: false,
});
};
onConfirm = () => {
const { removeClusters, clusterNames } = this.props;
removeClusters(clusterNames);
this.closeConfirmModal();
}
render() {
const { clusterNames, children } = this.props;
const { isModalOpen } = this.state;
const isSingleCluster = clusterNames.length === 1;
let modal;
if (isModalOpen) {
const title = isSingleCluster ? i18n.translate(
'xpack.remoteClusters.removeButton.confirmModal.deleteSingleClusterTitle',
{
defaultMessage: 'Remove remote cluster \'{name}\'?',
values: { name: clusterNames[0] },
}
) : i18n.translate(
'xpack.remoteClusters.removeButton.confirmModal.multipleDeletionTitle',
{
defaultMessage: 'Remove {count} remote clusters?',
values: { count: clusterNames.length },
}
);
const content = (
<Fragment>
<p>
<FormattedMessage
id="xpack.remoteClusters.removeButton.confirmModal.multipleDeletionDescription"
defaultMessage="You are about to remove these remote clusters:"
/>
</p>
<ul>{clusterNames.map(name => <li key={name}>{name}</li>)}</ul>
</Fragment>
);
modal = (
<EuiOverlayMask>
{ /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ }
<EuiConfirmModal
data-test-subj="remoteClustersDeleteConfirmModal"
title={title}
onCancel={this.closeConfirmModal}
onConfirm={this.onConfirm}
cancelButtonText={
i18n.translate('xpack.remoteClusters.removeButton.confirmModal.cancelButtonText',
{
defaultMessage: 'Cancel',
}
)
}
buttonColor="danger"
confirmButtonText={
i18n.translate('xpack.remoteClusters.removeButton.confirmModal.confirmButtonText',
{
defaultMessage: 'Remove',
}
)
}
onMouseOver={this.onMouseOverModal}
>
{!isSingleCluster && content}
</EuiConfirmModal>
</EuiOverlayMask>
);
}
return (
<Fragment>
{children(this.showConfirmModal)}
{modal}
</Fragment>
);
}
}

View file

@ -0,0 +1,352 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiButtonEmpty,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiIcon,
EuiSpacer,
EuiText,
EuiTextColor,
EuiTitle,
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH } from '../../../constants';
import { getRouterLinkProps } from '../../../services';
import { ConfiguredByNodeWarning } from '../../components';
import { ConnectionStatus, RemoveClusterButtonProvider } from '../components';
export class DetailPanel extends Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
isLoading: PropTypes.bool,
cluster: PropTypes.object,
closeDetailPanel: PropTypes.func.isRequired,
clusterName: PropTypes.string,
}
renderSkipUnavailableValue(skipUnavailable) {
if (skipUnavailable === true) {
return (
<FormattedMessage
id="xpack.remoteClusters.detailPanel.skipUnavailableTrueValue"
defaultMessage="Yes"
/>
);
}
if (skipUnavailable === false) {
return (
<FormattedMessage
id="xpack.remoteClusters.detailPanel.skipUnavailableFalseValue"
defaultMessage="No"
/>
);
}
return (
<FormattedMessage
id="xpack.remoteClusters.detailPanel.skipUnavailableNullValue"
defaultMessage="Default"
/>
);
}
renderClusterNotFound() {
return (
<EuiFlexGroup
justifyContent="flexStart"
alignItems="center"
gutterSize="s"
data-test-subj="remoteClusterDetailClusterNotFound"
>
<EuiFlexItem grow={false}>
<EuiIcon size="m" type="alert" color="danger" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.notFoundLabel"
defaultMessage="Remote cluster not found"
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
renderClusterConfiguredByNodeWarning({ isConfiguredByNode }) {
if (!isConfiguredByNode) {
return null;
}
return (
<Fragment>
<ConfiguredByNodeWarning />
<EuiSpacer size="l" />
</Fragment>
);
}
renderCluster({
isConnected,
connectedNodesCount,
skipUnavailable,
seeds,
maxConnectionsPerCluster,
initialConnectTimeout,
}) {
return (
<section
aria-labelledby="xpack.remoteClusters.detailPanel.statusTitle"
data-test-subj="remoteClusterDetailPanelStatusSection"
>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.remoteClusters.detailPanel.statusTitle"
defaultMessage="Status"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList data-test-subj="remoteClusterDetailPanelStatusValues">
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.connectedLabel"
defaultMessage="Connection"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailIsConnected">
<ConnectionStatus isConnected={isConnected} />
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.connectedNodesLabel"
defaultMessage="Connected nodes"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailConnectedNodesCount">
{connectedNodesCount}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.seedsLabel"
defaultMessage="Seeds"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailSeeds">
{seeds.map(seed => <EuiText key={seed}>{seed}</EuiText>)}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.skipUnavailableLabel"
defaultMessage="Skip unavailable"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailSkipUnavailable">
{this.renderSkipUnavailableValue(skipUnavailable)}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.maxConnectionsPerClusterLabel"
defaultMessage="Maximum number of connections"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailMaxConnections">
{maxConnectionsPerCluster}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.initialConnectTimeoutLabel"
defaultMessage="Initial connect timeout"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailInitialConnectTimeout">
{initialConnectTimeout}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionList>
</section>
);
}
renderFlyoutBody() {
const { cluster } = this.props;
return (
<EuiFlyoutBody>
{!cluster && (
this.renderClusterNotFound()
)}
{cluster && (
<Fragment>
{this.renderClusterConfiguredByNodeWarning(cluster)}
{this.renderCluster(cluster)}
</Fragment>
)}
</EuiFlyoutBody>
);
}
renderFlyoutFooter() {
const {
cluster,
clusterName,
closeDetailPanel,
} = this.props;
return (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={closeDetailPanel}
data-test-subj="remoteClusterDetailsPanelCloseButton"
>
<FormattedMessage
id="xpack.remoteClusters.detailPanel.closeButtonLabel"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
{cluster && !cluster.isConfiguredByNode && (
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<RemoveClusterButtonProvider clusterNames={[clusterName]}>
{(removeCluster) => (
<EuiButtonEmpty
color="danger"
onClick={removeCluster}
data-test-subj="remoteClusterDetailPanelRemoveButton"
>
<FormattedMessage
id="xpack.remoteClusters.detailPanel.removeButtonLabel"
defaultMessage="Remove"
/>
</EuiButtonEmpty>
)}
</RemoveClusterButtonProvider>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
{...getRouterLinkProps(`${CRUD_APP_BASE_PATH}/edit/${clusterName}`)}
fill
color="primary"
data-test-subj="remoteClusterDetailPanelEditButton"
>
<FormattedMessage
id="xpack.remoteClusters.detailPanel.editButtonLabel"
defaultMessage="Edit"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlyoutFooter>
);
}
render() {
const { isOpen, closeDetailPanel, clusterName } = this.props;
if (!isOpen) {
return null;
}
return (
<EuiFlyout
data-test-subj="remoteClusterDetailFlyout"
onClose={closeDetailPanel}
aria-labelledby="remoteClusterDetailsFlyoutTitle"
size="m"
maxWidth={400}
>
<EuiFlyoutHeader>
<EuiTitle
size="m"
id="remoteClusterDetailsFlyoutTitle"
data-test-subj="remoteClusterDetailsFlyoutTitle"
>
<h2>{clusterName}</h2>
</EuiTitle>
</EuiFlyoutHeader>
{this.renderFlyoutBody()}
{this.renderFlyoutFooter()}
</EuiFlyout>
);
}
}

View file

@ -0,0 +1,289 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingKibana,
EuiLoadingSpinner,
EuiOverlayMask,
EuiPageBody,
EuiPageContent,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiSpacer,
EuiText,
EuiTextColor,
EuiTitle,
EuiCallOut,
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH } from '../../constants';
import { getRouterLinkProps, extractQueryParams } from '../../services';
import { setBreadcrumbs } from '../../services/breadcrumb';
import {
RemoteClusterTable,
} from './remote_cluster_table';
import {
DetailPanel,
} from './detail_panel';
const REFRESH_RATE_MS = 30000;
export class RemoteClusterList extends Component {
static propTypes = {
loadClusters: PropTypes.func.isRequired,
refreshClusters: PropTypes.func.isRequired,
openDetailPanel: PropTypes.func.isRequired,
closeDetailPanel: PropTypes.func.isRequired,
isDetailPanelOpen: PropTypes.bool,
clusters: PropTypes.array,
isLoading: PropTypes.bool,
isCopyingCluster: PropTypes.bool,
isRemovingCluster: PropTypes.bool,
}
componentDidUpdate() {
const {
openDetailPanel,
closeDetailPanel,
isDetailPanelOpen,
history: {
location: {
search,
},
},
} = this.props;
const { cluster: clusterName } = extractQueryParams(search);
// Show deeplinked remoteCluster whenever remoteClusters get loaded or the URL changes.
if (clusterName != null) {
openDetailPanel(clusterName);
} else if (isDetailPanelOpen) {
closeDetailPanel();
}
}
componentDidMount() {
this.props.loadClusters();
this.interval = setInterval(this.props.refreshClusters, REFRESH_RATE_MS);
setBreadcrumbs('home');
}
componentWillUnmount() {
clearInterval(this.interval);
}
getHeaderSection(isAuthorized) {
return (
<Fragment>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterListTitle"
defaultMessage="Remote Clusters"
/>
</h1>
</EuiTitle>
</EuiPageContentHeaderSection>
{ isAuthorized && (
<EuiPageContentHeaderSection>
<EuiButton
{...getRouterLinkProps(`${CRUD_APP_BASE_PATH}/add`)}
fill
data-test-subj="remoteClusterCreateButton"
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.connectButtonLabel"
defaultMessage="Add a remote cluster"
/>
</EuiButton>
</EuiPageContentHeaderSection>
)}
</EuiPageContentHeader>
<EuiSpacer size="m" />
</Fragment>
);
}
renderBlockingAction() {
const { isCopyingCluster, isRemovingCluster } = this.props;
if (isCopyingCluster || isRemovingCluster) {
return (
<EuiOverlayMask>
<EuiLoadingKibana size="xl"/>
</EuiOverlayMask>
);
}
return null;
}
renderNoPermission() {
const title = i18n.translate('xpack.remoteClusters.remoteClusterList.noPermissionTitle', {
defaultMessage: 'Permission error',
});
return (
<EuiCallOut
title={title}
color="warning"
iconType="help"
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.noPermissionText"
defaultMessage="You do not have permission to view or add remote clusters."
/>
</EuiCallOut>
);
}
renderError(error) {
// We can safely depend upon the shape of this error coming from Angular $http, because we
// handle unexpected error shapes in the API action.
const {
statusCode,
error: errorString,
} = error.data;
const title = i18n.translate('xpack.remoteClusters.remoteClusterList.loadingErrorTitle', {
defaultMessage: 'Error loading remote clusters',
});
return (
<EuiCallOut
title={title}
color="danger"
iconType="alert"
>
{statusCode} {errorString}
</EuiCallOut>
);
}
renderEmpty() {
return (
<EuiEmptyPrompt
data-test-subj="remoteClusterListEmptyPrompt"
iconType="managementApp"
title={(
<h1>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.emptyPromptTitle"
defaultMessage="Add your first remote cluster"
/>
</h1>
)}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.emptyPromptDescription"
defaultMessage="Remote clusters create a uni-directional connection from your
local cluster to other clusters."
/>
</p>
</Fragment>
}
actions={
<EuiButton
{...getRouterLinkProps(`${CRUD_APP_BASE_PATH}/add`)}
fill
iconType="plusInCircle"
data-test-subj="remoteClusterEmptyPromptCreateButton"
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.emptyPrompt.connectButtonLabel"
defaultMessage="Add a remote cluster"
/>
</EuiButton>
}
/>
);
}
renderLoading() {
return (
<EuiFlexGroup
justifyContent="flexStart"
alignItems="center"
gutterSize="s"
data-test-subj="remoteClustersTableLoading"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.loadingTitle"
defaultMessage="Loading remote clusters..."
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
renderList() {
const { clusters } = this.props;
return (
<Fragment>
<RemoteClusterTable clusters={clusters} />
<DetailPanel />
</Fragment>
);
}
render() {
const { isLoading, clusters, clusterLoadError } = this.props;
const isEmpty = !isLoading && !clusters.length;
const isAuthorized = !clusterLoadError || clusterLoadError.status !== 403;
const isHeaderVisible = clusterLoadError || !isEmpty;
let content;
if (clusterLoadError) {
if (!isAuthorized) {
content = this.renderNoPermission();
} else {
content = this.renderError(clusterLoadError);
}
} else if (isEmpty) {
content = this.renderEmpty();
} else if (isLoading) {
content = this.renderLoading();
} else {
content = this.renderList();
}
return (
<EuiPageBody>
<EuiPageContent>
{(isHeaderVisible) && this.getHeaderSection(isAuthorized)}
{content}
{this.renderBlockingAction()}
</EuiPageContent>
</EuiPageBody>
);
}
}

View file

@ -0,0 +1,271 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiIconTip,
EuiInMemoryTable,
EuiLink,
EuiToolTip,
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH, UIM_SHOW_DETAILS_CLICK } from '../../../constants';
import { getRouterLinkProps, trackUiMetric } from '../../../services';
import { ConnectionStatus, RemoveClusterButtonProvider } from '../components';
export class RemoteClusterTable extends Component {
static propTypes = {
clusters: PropTypes.array,
openDetailPanel: PropTypes.func.isRequired,
}
static defaultProps = {
clusters: [],
}
constructor(props) {
super(props);
this.state = {
queryText: undefined,
selectedItems: [],
};
}
onSearch = ({ query }) => {
const { text } = query;
const normalizedSearchText = text.toLowerCase();
this.setState({
queryText: normalizedSearchText,
});
};
getFilteredClusters = () => {
const { clusters } = this.props;
const { queryText } = this.state;
if (queryText) {
return clusters.filter(cluster => {
const { name, seeds } = cluster;
const normalizedName = name.toLowerCase();
if (normalizedName.toLowerCase().includes(queryText)) {
return true;
}
return seeds.some(seed => seed.includes(queryText));
});
} else {
return clusters.slice(0);
}
};
render() {
const {
openDetailPanel,
} = this.props;
const {
selectedItems,
} = this.state;
const columns = [{
field: 'name',
name: i18n.translate('xpack.remoteClusters.remoteClusterList.table.nameColumnTitle', {
defaultMessage: 'Name',
}),
sortable: true,
truncateText: false,
render: (name, { isConfiguredByNode }) => {
const link = (
<EuiLink
data-test-subj="remoteClustersTableListClusterLink"
onClick={() => {
trackUiMetric(UIM_SHOW_DETAILS_CLICK);
openDetailPanel(name);
}}
>
{name}
</EuiLink>
);
if (isConfiguredByNode) {
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
{link}
</EuiFlexItem>
<EuiFlexItem grow={false} data-test-subj="remoteClustersTableListClusterDefinedByNodeTooltip">
<EuiIconTip
type="iInCircle"
color="subdued"
content={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.table.isConfiguredByNodeMessage"
defaultMessage="Defined in elasticsearch.yml"
/>
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return link;
}
}, {
field: 'seeds',
name: i18n.translate('xpack.remoteClusters.remoteClusterList.table.seedsColumnTitle', {
defaultMessage: 'Seeds',
}),
truncateText: true,
render: (seeds) => seeds.join(', '),
}, {
field: 'isConnected',
name: i18n.translate('xpack.remoteClusters.remoteClusterList.table.connectedColumnTitle', {
defaultMessage: 'Connection',
}),
sortable: true,
render: (isConnected) => <ConnectionStatus isConnected={isConnected} />,
width: '240px',
}, {
field: 'connectedNodesCount',
name: i18n.translate('xpack.remoteClusters.remoteClusterList.table.connectedNodesColumnTitle', {
defaultMessage: 'Connected nodes',
}),
sortable: true,
width: '160px',
}, {
name: i18n.translate('xpack.remoteClusters.remoteClusterList.table.actionsColumnTitle', {
defaultMessage: 'Actions',
}),
width: '100px',
actions: [{
render: ({ name, isConfiguredByNode }) => {
const label = isConfiguredByNode
? i18n.translate('xpack.remoteClusters.remoteClusterList.table.actionBlockedDeleteDescription', {
defaultMessage: `Remote clusters defined in elasticsearch.yml can't be deleted`,
}) : i18n.translate('xpack.remoteClusters.remoteClusterList.table.actionDeleteDescription', {
defaultMessage: 'Delete remote cluster',
});
return (
<EuiToolTip
content={label}
delay="long"
>
<RemoveClusterButtonProvider clusterNames={[name]}>
{(removeCluster) => (
<EuiButtonIcon
data-test-subj="remoteClusterTableRowRemoveButton"
aria-label={label}
iconType="trash"
color="danger"
isDisabled={isConfiguredByNode}
onClick={removeCluster}
/>
)}
</RemoveClusterButtonProvider>
</EuiToolTip>
);
},
}, {
render: ({ name, isConfiguredByNode }) => {
const label = isConfiguredByNode
? i18n.translate('xpack.remoteClusters.remoteClusterList.table.actionBlockedEditDescription', {
defaultMessage: `Remote clusters defined in elasticsearch.yml can't be edited`,
}) : i18n.translate('xpack.remoteClusters.remoteClusterList.table.actionEditDescription', {
defaultMessage: 'Edit remote cluster',
});
return (
<EuiToolTip
content={label}
delay="long"
>
<EuiButtonIcon
data-test-subj="remoteClusterTableRowEditButton"
aria-label={label}
iconType="pencil"
color="primary"
isDisabled={isConfiguredByNode}
{...getRouterLinkProps(`${CRUD_APP_BASE_PATH}/edit/${name}`)}
disabled={isConfiguredByNode}
/>
</EuiToolTip>
);
},
}],
}];
const sorting = {
sort: {
field: 'name',
direction: 'asc',
}
};
const search = {
toolsLeft: selectedItems.length ? (
<RemoveClusterButtonProvider clusterNames={selectedItems.map(({ name }) => name)}>
{(removeCluster) => (
<EuiButton
color="danger"
onClick={removeCluster}
data-test-subj="remoteClusterBulkDeleteButton"
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.table.removeButtonLabel"
defaultMessage="Remove {count, plural, one {remote cluster} other {{count} remote clusters}}"
values={{
count: selectedItems.length
}}
/>
</EuiButton>
)}
</RemoveClusterButtonProvider>
) : undefined,
onChange: this.onSearch,
box: {
incremental: true,
},
};
const pagination = {
initialPageSize: 20,
pageSizeOptions: [10, 20, 50]
};
const selection = {
onSelectionChange: (selectedItems) => this.setState({ selectedItems }),
selectable: ({ isConfiguredByNode }) => !isConfiguredByNode,
};
const filteredClusters = this.getFilteredClusters();
return (
<EuiInMemoryTable
items={filteredClusters}
itemId="name"
columns={columns}
search={search}
pagination={pagination}
sorting={sorting}
selection={selection}
isSelectable={true}
data-test-subj="remoteClusterListTable"
/>
);
}
}

View file

@ -4,29 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
import chrome from 'ui/chrome';
import { UIM_CLUSTER_ADD, UIM_CLUSTER_UPDATE } from '../constants';
import { trackUserRequest } from './track_ui_metric';
let httpClient;
export const setHttpClient = (client) => {
httpClient = client;
};
export const getHttpClient = () => {
return httpClient;
};
const apiPrefix = chrome.addBasePath('/api/remote_clusters');
import { sendGet, sendPost, sendPut, sendDelete } from './http';
export async function loadClusters() {
const response = await httpClient.get(apiPrefix);
const response = await sendGet();
return response.data;
}
export async function addCluster(cluster) {
const request = httpClient.post(apiPrefix, cluster);
const request = sendPost('', cluster);
return await trackUserRequest(request, UIM_CLUSTER_ADD);
}
@ -36,10 +24,10 @@ export async function editCluster(cluster) {
...rest
} = cluster;
const request = httpClient.put(`${apiPrefix}/${name}`, rest);
const request = sendPut(name, rest);
return await trackUserRequest(request, UIM_CLUSTER_UPDATE);
}
export function removeClusterRequest(name) {
return httpClient.delete(`${apiPrefix}/${name}`);
return sendDelete(name);
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { fatalError, toastNotifications } from 'ui/notify';
import { fatalError, toasts } from './notification';
function createToastConfig(error, errorTitle) {
// Expect an error in the shape provided by Angular's $http service.
@ -21,7 +21,7 @@ export function showApiWarning(error, errorTitle) {
const toastConfig = createToastConfig(error, errorTitle);
if (toastConfig) {
return toastNotifications.addWarning(toastConfig);
return toasts.addWarning(toastConfig);
}
// This error isn't an HTTP error, so let the fatal error screen tell the user something
@ -33,7 +33,7 @@ export function showApiError(error, errorTitle) {
const toastConfig = createToastConfig(error, errorTitle);
if (toastConfig) {
return toastNotifications.addDanger(toastConfig);
return toasts.addDanger(toastConfig);
}
// This error isn't an HTTP error, so let the fatal error screen tell the user something

View file

@ -0,0 +1,53 @@
/*
* 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 { CRUD_APP_BASE_PATH } from '../constants';
let _setBreadcrumbs: any;
let _breadcrumbs: any;
export function init(setGlobalBreadcrumbs: any, managementBreadcrumb: any): void {
_setBreadcrumbs = setGlobalBreadcrumbs;
_breadcrumbs = {
management: managementBreadcrumb,
home: {
text: i18n.translate('xpack.remoteClusters.listBreadcrumbTitle', {
defaultMessage: 'Remote Clusters',
}),
href: `#${CRUD_APP_BASE_PATH}/list`,
},
add: {
text: i18n.translate('xpack.remoteClusters.addBreadcrumbTitle', {
defaultMessage: 'Add',
}),
},
edit: {
text: i18n.translate('xpack.remoteClusters.editBreadcrumbTitle', {
defaultMessage: 'Edit',
}),
},
};
}
export function setBreadcrumbs(type: string, queryParams?: string): void {
if (!_breadcrumbs[type]) {
return;
}
if (type === 'home') {
_setBreadcrumbs([_breadcrumbs.management, _breadcrumbs.home]);
} else {
// Support deep-linking back to a remote cluster in the detail panel.
const homeBreadcrumb = {
text: _breadcrumbs.home.text,
href: `${_breadcrumbs.home.href}${queryParams}`,
};
_setBreadcrumbs([_breadcrumbs.management, homeBreadcrumb, _breadcrumbs[type]]);
}
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export let skippingDisconnectedClustersUrl: string;
export let remoteClustersUrl: string;
export let transportPortUrl: string;
export function init(esDocBasePath: string): void {
skippingDisconnectedClustersUrl = `${esDocBasePath}/modules-cross-cluster-search.html#_skipping_disconnected_clusters`;
remoteClustersUrl = `${esDocBasePath}/modules-remote-clusters.html`;
transportPortUrl = `${esDocBasePath}/modules-transport.html`;
}

View file

@ -0,0 +1,39 @@
/*
* 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.
*/
let _httpClient: any;
let _prependBasePath: any;
export function init(httpClient: any, prependBasePath: any): void {
_httpClient = httpClient;
_prependBasePath = prependBasePath;
}
export function getFullPath(path: string): string {
const apiPrefix = _prependBasePath('/api/remote_clusters');
if (path) {
return `${apiPrefix}/${path}`;
}
return apiPrefix;
}
export function sendPost(path: string, payload: any): any {
return _httpClient.post(getFullPath(path), payload);
}
export function sendGet(path: string): any {
return _httpClient.get(getFullPath(path));
}
export function sendPut(path: string, payload: any): any {
return _httpClient.put(getFullPath(path), payload);
}
export function sendDelete(path: string): any {
return _httpClient.delete(getFullPath(path));
}

View file

@ -5,7 +5,6 @@
*/
export {
setHttpClient,
loadClusters,
addCluster,
editCluster,
@ -17,13 +16,6 @@ export {
showApiWarning,
} from './api_errors';
export {
listBreadcrumb,
buildListBreadcrumb,
addBreadcrumb,
editBreadcrumb,
} from './breadcrumbs';
export {
setRedirect,
redirect,

View file

@ -4,4 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { callWithRequestFactory } from './call_with_request_factory';
export let toasts: any;
export let fatalError: any;
export function init(_toasts: any, _fatalError: any): void {
toasts = _toasts;
fatalError = _fatalError;
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { trackUiMetric as track } from '../../../../../src/legacy/core_plugins/ui_metric/public';
import { trackUiMetric as track } from '../../../../../../src/legacy/core_plugins/ui_metric/public';
import { UIM_APP_NAME } from '../constants';
export function trackUiMetric(actionType) {

View file

@ -4,4 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const CRUD_APP_BASE_PATH = '/management/elasticsearch/remote_clusters';
import { UIM_APP_NAME } from '../constants';
export let track: any;
export function init(_track: any): void {
track = _track;
}
export function trackUiMetric(actionType: string): any {
return track(UIM_APP_NAME, actionType);
}

View file

@ -5,7 +5,6 @@
*/
import { i18n } from '@kbn/i18n';
import { fatalError, toastNotifications } from 'ui/notify';
import { CRUD_APP_BASE_PATH } from '../../constants';
import {
@ -14,6 +13,7 @@ import {
extractQueryParams,
redirect,
} from '../../services';
import { fatalError, toasts } from '../../services/notification';
import {
ADD_CLUSTER_START,
@ -27,10 +27,8 @@ export const addCluster = (cluster) => async (dispatch) => {
type: ADD_CLUSTER_START,
});
let newCluster;
try {
[ newCluster ] = await Promise.all([
await Promise.all([
sendAddClusterRequest(cluster),
// Wait at least half a second to avoid a weird flicker of the saving feedback.
new Promise(resolve => setTimeout(resolve, 500)),
@ -80,7 +78,6 @@ export const addCluster = (cluster) => async (dispatch) => {
dispatch({
type: ADD_CLUSTER_SUCCESS,
payload: { cluster: newCluster.data },
});
const { history, route: { location: { search } } } = getRouter();
@ -88,7 +85,7 @@ export const addCluster = (cluster) => async (dispatch) => {
if (redirectUrl) {
// A toast is only needed if we're leaving the app.
toastNotifications.addSuccess(i18n.translate('xpack.remoteClusters.addAction.successTitle', {
toasts.addSuccess(i18n.translate('xpack.remoteClusters.addAction.successTitle', {
defaultMessage: `Added remote cluster '{name}'`,
values: { name: cluster.name },
}));

View file

@ -5,7 +5,8 @@
*/
import { i18n } from '@kbn/i18n';
import { fatalError, toastNotifications } from 'ui/notify';
import { fatalError, toasts } from '../../services/notification';
import { CRUD_APP_BASE_PATH } from '../../constants';
import { loadClusters } from './load_clusters';
@ -72,7 +73,7 @@ export const editCluster = (cluster) => async (dispatch) => {
if (redirectUrl) {
// A toast is only needed if we're leaving the app.
toastNotifications.addSuccess(i18n.translate('xpack.remoteClusters.editAction.successTitle', {
toasts.addSuccess(i18n.translate('xpack.remoteClusters.editAction.successTitle', {
defaultMessage: `Edited remote cluster '{name}'`,
values: { name: cluster.name },
}));

View file

@ -10,6 +10,7 @@ import {
loadClusters as sendLoadClustersRequest,
showApiError,
} from '../../services';
import {
LOAD_CLUSTERS_START,
LOAD_CLUSTERS_SUCCESS,

View file

@ -10,6 +10,7 @@ import {
loadClusters as sendLoadClustersRequest,
showApiWarning,
} from '../../services';
import {
REFRESH_CLUSTERS_SUCCESS,
} from '../action_types';

View file

@ -5,8 +5,8 @@
*/
import { i18n } from '@kbn/i18n';
import { toastNotifications } from 'ui/notify';
import { toasts } from '../../services/notification';
import { UIM_CLUSTER_REMOVE, UIM_CLUSTER_REMOVE_MANY } from '../../constants';
import {
@ -56,7 +56,7 @@ export const removeClusters = (names) => async (dispatch, getState) => {
new Promise(resolve => setTimeout(resolve, 500)),
]).catch(error => {
const errorTitle = getErrorTitle(names.length, names[0]);
toastNotifications.addDanger({
toasts.addDanger({
title: errorTitle,
text: error.data.message,
});
@ -75,7 +75,7 @@ export const removeClusters = (names) => async (dispatch, getState) => {
} = errors[0];
const title = getErrorTitle(errors.length, name);
toastNotifications.addDanger({
toasts.addDanger({
title,
text: message,
});
@ -86,12 +86,12 @@ export const removeClusters = (names) => async (dispatch, getState) => {
trackUiMetric(names.length > 1 ? UIM_CLUSTER_REMOVE_MANY : UIM_CLUSTER_REMOVE);
if (itemsDeleted.length === 1) {
toastNotifications.addSuccess(i18n.translate('xpack.remoteClusters.removeAction.successSingleNotificationTitle', {
toasts.addSuccess(i18n.translate('xpack.remoteClusters.removeAction.successSingleNotificationTitle', {
defaultMessage: `Remote cluster '{name}' was removed`,
values: { name: itemsDeleted[0] },
}));
} else {
toastNotifications.addSuccess(i18n.translate('xpack.remoteClusters.removeAction.successMultipleNotificationTitle', {
toasts.addSuccess(i18n.translate('xpack.remoteClusters.removeAction.successMultipleNotificationTitle', {
defaultMessage: '{count} remote clusters were removed',
values: { count: itemsDeleted.length },
}));

View file

@ -1,14 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const UIM_APP_NAME = 'remote_clusters';
export const UIM_APP_LOAD = 'app_load';
export const UIM_CLUSTER_ADD = 'cluster_add';
export const UIM_CLUSTER_UPDATE = 'cluster_update';
export const UIM_CLUSTER_REMOVE = 'cluster_remove';
export const UIM_CLUSTER_REMOVE_MANY = 'cluster_remove_many';
export const UIM_SHOW_DETAILS_CLICK = 'show_details_click';

View file

@ -4,97 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Provider } from 'react-redux';
import { HashRouter } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { I18nContext } from 'ui/i18n';
import { management } from 'ui/management';
import routes from 'ui/routes';
import chrome from 'ui/chrome';
import { Plugin as RemoteClustersPlugin } from './plugin';
import { createShim } from './shim';
import { CRUD_APP_BASE_PATH } from './constants';
import { setHttpClient, setUserHasLeftApp, setRedirect } from './services';
import { App } from './app';
import template from './main.html';
import { remoteClustersStore } from './store';
if (chrome.getInjected('remoteClustersUiEnabled')) {
const esSection = management.getSection('elasticsearch');
esSection.register('remote_clusters', {
visible: true,
display: i18n.translate('xpack.remoteClusters.appTitle', { defaultMessage: 'Remote Clusters' }),
order: 5,
url: `#${CRUD_APP_BASE_PATH}/list`,
});
let appElement;
const renderReact = async (elem) => {
render(
<I18nContext>
<Provider store={remoteClustersStore}>
<HashRouter>
<App />
</HashRouter>
</Provider>
</I18nContext>,
elem
);
};
routes.when(`${CRUD_APP_BASE_PATH}/:view?/:id?`, {
template: template,
controllerAs: 'remoteClusters',
controller: class RemoteClustersController {
constructor($scope, $route, $http, kbnUrl) {
if (appElement) {
// React-router's <Redirect> will cause this controller to re-execute without the $destroy
// handler being called. This means the app will re-mount, so we need to unmount it first
// here.
unmountComponentAtNode(appElement);
}
// NOTE: We depend upon Angular's $http service because it's decorated with interceptors,
// e.g. to check license status per request.
setHttpClient($http);
setRedirect((path) => {
$scope.$evalAsync(() => {
kbnUrl.redirect(path);
});
});
// If returning to the app, we'll need to reset this state.
setUserHasLeftApp(false);
$scope.$$postDigest(() => {
appElement = document.getElementById('remoteClustersReactRoot');
renderReact(appElement);
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 rollups, prevent Angular from re-matching the route and
// rebuilding the app.
if (isNavigationInApp) {
$route.current = appRoute;
} else {
// Set internal flag so we can prevent reacting to the route change internally.
setUserHasLeftApp(true);
}
});
$scope.$on('$destroy', () => {
stopListeningForLocationChange();
unmountComponentAtNode(appElement);
});
});
}
}
});
}
const { coreStart, pluginsStart } = createShim();
const remoteClustersPlugin = new RemoteClustersPlugin();
remoteClustersPlugin.start(coreStart, pluginsStart);

View file

@ -1,6 +1,6 @@
// Import the EUI global scope so we can use EUI constants
@import 'src/legacy/ui/public/styles/_styling_constants';
@import './sections/remote_cluster_list/components/connection_status/index';
@import './app/sections/remote_cluster_list/components/connection_status/index';
// Index management plugin styles

View file

@ -0,0 +1,118 @@
/*
* 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 { unmountComponentAtNode } from 'react-dom';
import { i18n } from '@kbn/i18n';
import routes from 'ui/routes';
import template from './index.html';
import { renderReact } from './app';
import { CRUD_APP_BASE_PATH } from './app/constants';
import { setUserHasLeftApp, setRedirect } from './app/services';
import { init as initBreadcrumbs } from './app/services/breadcrumb';
import { init as initDocumentation } from './app/services/documentation';
import { init as initHttp } from './app/services/http';
import { init as initUiMetric } from './app/services/ui_metric';
import { init as initNotification } from './app/services/notification';
const REACT_ROOT_ID = 'remoteClustersReactRoot';
export class Plugin {
start(coreStart, pluginsStart) {
const {
i18n: { Context },
chrome: { setBreadcrumbs },
notifications: { toasts },
fatalError,
http: { prependBasePath },
injectedMetadata: { getInjectedVar },
documentation: { elasticWebsiteUrl, docLinkVersion },
} = coreStart;
if (getInjectedVar('remoteClustersUiEnabled')) {
const {
management: { getSection, breadcrumb: managementBreadcrumb },
uiMetric: { track },
} = pluginsStart;
const esSection = getSection('elasticsearch');
esSection.register('remote_clusters', {
visible: true,
display: i18n.translate('xpack.remoteClusters.appTitle', { defaultMessage: 'Remote Clusters' }),
order: 5,
url: `#${CRUD_APP_BASE_PATH}/list`,
});
// Initialize services
initBreadcrumbs(setBreadcrumbs, managementBreadcrumb);
initDocumentation(`${elasticWebsiteUrl}guide/en/elasticsearch/reference/${docLinkVersion}/`);
initUiMetric(track);
initNotification(toasts, fatalError);
const unmountReactApp = () => {
const appElement = document.getElementById(REACT_ROOT_ID);
if (appElement) {
unmountComponentAtNode(appElement);
}
};
// NOTE: The New Platform will implicitly handle much of this logic by mounting the app at
// the base route.
routes.when(`${CRUD_APP_BASE_PATH}/:view?/:id?`, {
template,
controllerAs: 'remoteClusters',
controller: class RemoteClustersController {
constructor($scope, $route, $http, kbnUrl) {
// NOTE: We depend upon Angular's $http service because it's decorated with interceptors,
// e.g. to check license status per request.
initHttp($http, prependBasePath);
setRedirect((path) => {
$scope.$evalAsync(() => {
kbnUrl.redirect(path);
});
});
// If returning to the app, we'll need to reset this state.
setUserHasLeftApp(false);
// React-router's <Redirect> will cause this controller to re-execute without the $destroy
// handler being called. This means the app will re-mount, so we need to unmount it first
// here.
unmountReactApp();
$scope.$$postDigest(() => {
const appElement = document.getElementById(REACT_ROOT_ID);
if (appElement) {
renderReact(appElement, Context);
}
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 the app, prevent Angular from re-matching the route and
// rebuilding the app.
if (isNavigationInApp) {
$route.current = appRoute;
} else {
// Set internal flag so we can prevent reacting to the route change internally.
setUserHasLeftApp(true);
}
});
$scope.$on('$destroy', () => {
stopListeningForLocationChange();
unmountReactApp();
});
});
}
}
});
}
}
}

View file

@ -1,616 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { merge } from 'lodash';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiComboBox,
EuiDescribedFormGroup,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiLink,
EuiLoadingKibana,
EuiLoadingSpinner,
EuiOverlayMask,
EuiSpacer,
EuiSwitch,
EuiText,
EuiTitle,
} from '@elastic/eui';
import {
skippingDisconnectedClustersUrl,
transportPortUrl,
} from '../../../services/documentation_links';
import { validateName, validateSeeds, validateSeed } from './validators';
const defaultFields = {
name: '',
seeds: [],
skipUnavailable: false,
};
export const RemoteClusterForm = injectI18n(
class extends Component {
static propTypes = {
save: PropTypes.func.isRequired,
cancel: PropTypes.func,
isSaving: PropTypes.bool,
saveError: PropTypes.object,
fields: PropTypes.object,
disabledFields: PropTypes.object,
}
static defaultProps = {
fields: merge({}, defaultFields),
disabledFields: {},
}
constructor(props) {
super(props);
const { fields, disabledFields } = props;
const fieldsState = merge({}, defaultFields, fields);
this.state = {
localSeedErrors: [],
seedInput: '',
fields: fieldsState,
disabledFields,
fieldsErrors: this.getFieldsErrors(fieldsState),
areErrorsVisible: false,
};
}
getFieldsErrors(fields, seedInput = '') {
const { name, seeds } = fields;
return {
name: validateName(name),
seeds: validateSeeds(seeds, seedInput),
};
}
onFieldsChange = (changedFields) => {
this.setState(({ fields: prevFields, seedInput }) => {
const newFields = {
...prevFields,
...changedFields,
};
return ({
fields: newFields,
fieldsErrors: this.getFieldsErrors(newFields, seedInput),
});
});
};
getAllFields() {
const {
fields: {
name,
seeds,
skipUnavailable,
},
} = this.state;
return {
name,
seeds,
skipUnavailable,
};
}
save = () => {
const { save } = this.props;
if (this.hasErrors()) {
this.setState({
areErrorsVisible: true,
});
return;
}
const cluster = this.getAllFields();
save(cluster);
};
onCreateSeed = (newSeed) => {
// If the user just hit enter without typing anything, treat it as a no-op.
if (!newSeed) {
return;
}
const localSeedErrors = validateSeed(newSeed);
if (localSeedErrors.length !== 0) {
this.setState({
localSeedErrors,
});
// Return false to explicitly reject the user's input.
return false;
}
const {
fields: {
seeds,
},
} = this.state;
const newSeeds = seeds.slice(0);
newSeeds.push(newSeed.toLowerCase());
this.onFieldsChange({ seeds: newSeeds });
};
onSeedsInputChange = (seedInput) => {
if (!seedInput) {
// If empty seedInput ("") don't do anything. This happens
// right after a seed is created.
return;
}
const { intl } = this.props;
this.setState(({ fields, localSeedErrors }) => {
const { seeds } = fields;
// Allow typing to clear the errors, but not to add new ones.
const errors = (!seedInput || validateSeed(seedInput).length === 0) ? [] : localSeedErrors;
// EuiComboBox internally checks for duplicates and prevents calling onCreateOption if the
// input is a duplicate. So we need to surface this error here instead.
const isDuplicate = seeds.includes(seedInput);
if (isDuplicate) {
errors.push(intl.formatMessage({
id: 'xpack.remoteClusters.remoteClusterForm.localSeedError.duplicateMessage',
defaultMessage: `Duplicate seed nodes aren't allowed.`,
}));
}
return ({
localSeedErrors: errors,
fieldsErrors: this.getFieldsErrors(fields, seedInput),
seedInput,
});
});
};
onSeedsChange = (seeds) => {
this.onFieldsChange({ seeds: seeds.map(({ label }) => label) });
};
onSkipUnavailableChange = (e) => {
const skipUnavailable = e.target.checked;
this.onFieldsChange({ skipUnavailable });
};
resetToDefault = (fieldName) => {
this.onFieldsChange({
[fieldName]: defaultFields[fieldName],
});
};
hasErrors = () => {
const { fieldsErrors, localSeedErrors } = this.state;
const errorValues = Object.values(fieldsErrors);
const hasErrors = errorValues.some(error => error != null) || localSeedErrors.length;
return hasErrors;
};
renderSeeds() {
const {
areErrorsVisible,
fields: {
seeds,
},
fieldsErrors: {
seeds: errorsSeeds,
},
localSeedErrors,
} = this.state;
const { intl } = this.props;
// Show errors if there is a general form error or local errors.
const areFormErrorsVisible = Boolean(areErrorsVisible && errorsSeeds);
const showErrors = areFormErrorsVisible || localSeedErrors.length !== 0;
const errors = areFormErrorsVisible ? localSeedErrors.concat(errorsSeeds) : localSeedErrors;
const formattedSeeds = seeds.map(seed => ({ label: seed }));
return (
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSeedsTitle"
defaultMessage="Seed nodes for cluster discovery"
/>
</h4>
</EuiTitle>
)}
description={(
<Fragment>
<p>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSeedsDescription1"
defaultMessage="A list of remote cluster nodes to query for the cluster state.
Specify multiple seed nodes so discovery doesn't fail if a node is unavailable."
/>
</p>
</Fragment>
)}
fullWidth
>
<EuiFormRow
data-test-subj="remoteClusterFormSeedNodesFormRow"
label={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldSeedsLabel"
defaultMessage="Seed nodes"
/>
)}
helpText={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSeedsHelpText"
defaultMessage="An IP address or host name, followed by the {transportPort} of the remote cluster."
values={{
transportPort: (
<EuiLink href={transportPortUrl} target="_blank">
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSeedsHelpText.transportPortLinkText"
defaultMessage="transport port"
/>
</EuiLink>
),
}}
/>
)}
isInvalid={showErrors}
error={errors}
fullWidth
>
<EuiComboBox
noSuggestions
placeholder={intl.formatMessage({
id: 'xpack.remoteClusters.remoteClusterForm.fieldSeedsPlaceholder',
defaultMessage: 'host:port',
})}
selectedOptions={formattedSeeds}
onCreateOption={this.onCreateSeed}
onChange={this.onSeedsChange}
onSearchChange={this.onSeedsInputChange}
isInvalid={showErrors}
fullWidth
data-test-subj="remoteClusterFormSeedsInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
}
renderSkipUnavailable() {
const {
fields: {
skipUnavailable,
},
} = this.state;
return (
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableTitle"
defaultMessage="Make remote cluster optional"
/>
</h4>
</EuiTitle>
)}
description={(
<Fragment>
<p>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableDescription"
defaultMessage="By default, a request fails if any of the queried remote clusters
are unavailable. To continue sending a request to other remote clusters if this
cluster is unavailable, enable {optionName}. {learnMoreLink}"
values={{
optionName: (
<strong>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableDescription.optionNameLabel"
defaultMessage="Skip if unavailable"
/>
</strong>
),
learnMoreLink: (
<EuiLink href={skippingDisconnectedClustersUrl} target="_blank">
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableDescription.learnMoreLinkLabel"
defaultMessage="Learn more."
/>
</EuiLink>
),
}}
/>
</p>
</Fragment>
)}
fullWidth
>
<EuiFormRow
data-test-subj="remoteClusterFormSkipUnavailableFormRow"
className="remoteClusterSkipIfUnavailableSwitch"
hasEmptyLabelSpace
fullWidth
helpText={
skipUnavailable !== defaultFields.skipUnavailable ? (
<EuiLink onClick={() => { this.resetToDefault('skipUnavailable'); }}>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableResetLabel"
defaultMessage="Reset to default"
/>
</EuiLink>
) : null
}
>
<EuiSwitch
label={i18n.translate('xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableLabel', {
defaultMessage: 'Skip if unavailable',
})}
checked={skipUnavailable}
onChange={this.onSkipUnavailableChange}
data-test-subj="remoteClusterFormSkipUnavailableFormToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
}
renderActions() {
const { isSaving, cancel } = this.props;
const { areErrorsVisible } = this.state;
if (isSaving) {
return (
<EuiFlexGroup justifyContent="flexStart" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="l"/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.actions.savingText"
defaultMessage="Saving"
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
let cancelButton;
if (cancel) {
cancelButton = (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="primary"
onClick={cancel}
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
);
}
const isSaveDisabled = areErrorsVisible && this.hasErrors();
return (
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="remoteClusterFormSaveButton"
color="secondary"
iconType="check"
onClick={this.save}
fill
disabled={isSaveDisabled}
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
{cancelButton}
</EuiFlexGroup>
);
}
renderSavingFeedback() {
if (this.props.isSaving) {
return (
<EuiOverlayMask>
<EuiLoadingKibana size="xl"/>
</EuiOverlayMask>
);
}
return null;
}
renderSaveErrorFeedback() {
const { saveError } = this.props;
if (saveError) {
const { message, cause } = saveError;
let errorBody;
if (cause) {
if (cause.length === 1) {
errorBody = (
<p>{cause[0]}</p>
);
} else {
errorBody = (
<ul>
{cause.map(causeValue => <li key={causeValue}>{causeValue}</li>)}
</ul>
);
}
}
return (
<Fragment>
<EuiCallOut
title={message}
icon="cross"
color="danger"
>
{errorBody}
</EuiCallOut>
<EuiSpacer />
</Fragment>
);
}
return null;
}
renderErrors = () => {
const { areErrorsVisible } = this.state;
const hasErrors = this.hasErrors();
if (!areErrorsVisible || !hasErrors) {
return null;
}
return (
<Fragment>
<EuiSpacer size="m" />
<EuiCallOut
data-test-subj="remoteClusterFormGlobalError"
title={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.errorTitle"
defaultMessage="Fix errors before continuing."
/>
)}
color="danger"
iconType="cross"
/>
</Fragment>
);
}
render() {
const {
disabledFields: {
name: disabledName,
},
} = this.props;
const {
areErrorsVisible,
fields: {
name,
},
fieldsErrors: {
name: errorClusterName,
},
} = this.state;
return (
<Fragment>
{this.renderSaveErrorFeedback()}
<EuiForm>
<EuiDescribedFormGroup
title={(
<EuiTitle size="s">
<h4>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionNameTitle"
defaultMessage="Name"
/>
</h4>
</EuiTitle>
)}
description={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionNameDescription"
defaultMessage="A unique name for the remote cluster."
/>
)}
fullWidth
>
<EuiFormRow
data-test-subj="remoteClusterFormNameFormRow"
label={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNameLabel"
defaultMessage="Name"
/>
)}
helpText={(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNameLabelHelpText"
defaultMessage="Name can only contain letters, numbers, underscores, and dashes."
/>
)}
error={errorClusterName}
isInvalid={Boolean(areErrorsVisible && errorClusterName)}
fullWidth
>
<EuiFieldText
isInvalid={Boolean(areErrorsVisible && errorClusterName)}
value={name}
onChange={e => this.onFieldsChange({ name: e.target.value })}
fullWidth
disabled={disabledName}
data-test-subj="remoteClusterFormNameInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{this.renderSeeds()}
{this.renderSkipUnavailable()}
</EuiForm>
{this.renderErrors()}
<EuiSpacer size="l" />
{this.renderActions()}
{this.renderSavingFeedback()}
</Fragment>
);
}
}
);

View file

@ -1,82 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import chrome from 'ui/chrome';
import { MANAGEMENT_BREADCRUMB } from 'ui/management';
import {
EuiPageContent,
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH } from '../../constants';
import { listBreadcrumb, addBreadcrumb, getRouter, redirect, extractQueryParams } from '../../services';
import { RemoteClusterPageTitle, RemoteClusterForm } from '../components';
export const RemoteClusterAdd = injectI18n(
class extends PureComponent {
static propTypes = {
addCluster: PropTypes.func,
isAddingCluster: PropTypes.bool,
addClusterError: PropTypes.object,
clearAddClusterErrors: PropTypes.func,
}
componentDidMount() {
chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb ]);
}
componentWillUnmount() {
// Clean up after ourselves.
this.props.clearAddClusterErrors();
}
save = (clusterConfig) => {
this.props.addCluster(clusterConfig);
};
cancel = () => {
const { history, route: { location: { search } } } = getRouter();
const { redirect: redirectUrl } = extractQueryParams(search);
if (redirectUrl) {
const decodedRedirect = decodeURIComponent(redirectUrl);
redirect(decodedRedirect);
} else {
history.push(CRUD_APP_BASE_PATH);
}
};
render() {
const { isAddingCluster, addClusterError } = this.props;
return (
<EuiPageContent
horizontalPosition="center"
className="remoteClusterAddPage"
>
<RemoteClusterPageTitle
title={(
<FormattedMessage
id="xpack.remoteClusters.addTitle"
defaultMessage="Add remote cluster"
/>
)}
/>
<RemoteClusterForm
isSaving={isAddingCluster}
saveError={addClusterError}
save={this.save}
cancel={this.cancel}
/>
</EuiPageContent>
);
}
}
);

View file

@ -1,236 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import chrome from 'ui/chrome';
import { MANAGEMENT_BREADCRUMB } from 'ui/management';
import {
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiPageContent,
EuiSpacer,
EuiText,
EuiTextColor,
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH } from '../../constants';
import {
buildListBreadcrumb,
editBreadcrumb,
extractQueryParams,
getRouter,
getRouterLinkProps,
redirect,
} from '../../services';
import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components';
const disabledFields = {
name: true,
};
export const RemoteClusterEdit = injectI18n(
class extends Component {
static propTypes = {
isLoading: PropTypes.bool,
cluster: PropTypes.object,
startEditingCluster: PropTypes.func,
stopEditingCluster: PropTypes.func,
editCluster: PropTypes.func,
isEditingCluster: PropTypes.bool,
getEditClusterError: PropTypes.string,
clearEditClusterErrors: PropTypes.func,
openDetailPanel: PropTypes.func,
}
constructor(props) {
super(props);
const {
match: {
params: {
name,
},
},
} = props;
chrome.breadcrumbs.set([
MANAGEMENT_BREADCRUMB,
buildListBreadcrumb(`?cluster=${name}`),
editBreadcrumb,
]);
this.state = {
clusterName: name,
};
}
componentDidMount() {
const { startEditingCluster } = this.props;
const { clusterName } = this.state;
startEditingCluster(clusterName);
}
componentWillUnmount() {
// Clean up after ourselves.
this.props.clearEditClusterErrors();
this.props.stopEditingCluster();
}
save = (clusterConfig) => {
this.props.editCluster(clusterConfig);
};
cancel = () => {
const { openDetailPanel } = this.props;
const { clusterName } = this.state;
const { history, route: { location: { search } } } = getRouter();
const { redirect: redirectUrl } = extractQueryParams(search);
if (redirectUrl) {
const decodedRedirect = decodeURIComponent(redirectUrl);
redirect(decodedRedirect);
} else {
history.push(CRUD_APP_BASE_PATH);
openDetailPanel(clusterName);
}
};
renderContent() {
const {
clusterName,
} = this.state;
const {
isLoading,
cluster,
isEditingCluster,
getEditClusterError,
} = this.props;
if (isLoading) {
return (
<EuiFlexGroup
justifyContent="flexStart"
alignItems="center"
gutterSize="s"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.remoteClusters.edit.loadingLabel"
defaultMessage="Loading remote cluster..."
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (!cluster) {
return (
<Fragment>
<EuiCallOut
title={(
<FormattedMessage
id="xpack.remoteClusters.edit.loadingErrorTitle"
defaultMessage="Error loading remote cluster"
/>
)}
color="danger"
iconType="alert"
>
<FormattedMessage
id="xpack.remoteClusters.edit.loadingErrorMessage"
defaultMessage="The remote cluster '{name}' does not exist."
values={{ name: clusterName }}
/>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
{...getRouterLinkProps(CRUD_APP_BASE_PATH)}
iconType="arrowLeft"
flush="left"
>
<FormattedMessage
id="xpack.remoteClusters.edit.viewRemoteClustersButtonLabel"
defaultMessage="View remote clusters"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
}
const { isConfiguredByNode } = cluster;
if (isConfiguredByNode) {
return (
<Fragment>
<ConfiguredByNodeWarning />
<EuiSpacer size="s" />
<EuiButtonEmpty
color="primary"
onClick={this.cancel}
>
<FormattedMessage
id="xpack.remoteClusters.edit.backToRemoteClustersButtonLabel"
defaultMessage="Back to remote clusters"
/>
</EuiButtonEmpty>
</Fragment>
);
}
return (
<RemoteClusterForm
fields={cluster}
disabledFields={disabledFields}
isSaving={isEditingCluster}
saveError={getEditClusterError}
save={this.save}
cancel={this.cancel}
/>
);
}
render() {
return (
<EuiPageContent
horizontalPosition="center"
className="remoteClusterAddPage"
>
<RemoteClusterPageTitle
title={(
<FormattedMessage
id="xpack.remoteClusters.editTitle"
defaultMessage="Edit remote cluster"
/>
)}
/>
{this.renderContent()}
</EuiPageContent>
);
}
}
);

View file

@ -1,116 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiConfirmModal,
EuiOverlayMask,
} from '@elastic/eui';
export const RemoveClusterButtonProvider = injectI18n(
class extends Component {
static propTypes = {
removeClusters: PropTypes.func.isRequired,
clusterNames: PropTypes.array.isRequired,
children: PropTypes.func.isRequired,
};
state = {
isModalOpen: false,
};
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();
};
showConfirmModal = () => {
this.setState({
isModalOpen: true,
});
};
closeConfirmModal = () => {
this.setState({
isModalOpen: false,
});
};
onConfirm = () => {
const { removeClusters, clusterNames } = this.props;
removeClusters(clusterNames);
this.closeConfirmModal();
}
render() {
const { intl, clusterNames, children } = this.props;
const { isModalOpen } = this.state;
const isSingleCluster = clusterNames.length === 1;
let modal;
if (isModalOpen) {
const title = isSingleCluster ? intl.formatMessage({
id: 'xpack.remoteClusters.removeButton.confirmModal.deleteSingleClusterTitle',
defaultMessage: 'Remove remote cluster \'{name}\'?',
}, { name: clusterNames[0] }) : intl.formatMessage({
id: 'xpack.remoteClusters.removeButton.confirmModal.multipleDeletionTitle',
defaultMessage: 'Remove {count} remote clusters?',
}, { count: clusterNames.length });
const content = (
<Fragment>
<p>
<FormattedMessage
id="xpack.remoteClusters.removeButton.confirmModal.multipleDeletionDescription"
defaultMessage="You are about to remove these remote clusters:"
/>
</p>
<ul>{clusterNames.map(name => <li key={name}>{name}</li>)}</ul>
</Fragment>
);
modal = (
<EuiOverlayMask>
{ /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ }
<EuiConfirmModal
data-test-subj="remoteClustersDeleteConfirmModal"
title={title}
onCancel={this.closeConfirmModal}
onConfirm={this.onConfirm}
cancelButtonText={
intl.formatMessage({
id: 'xpack.remoteClusters.removeButton.confirmModal.cancelButtonText',
defaultMessage: 'Cancel',
})
}
buttonColor="danger"
confirmButtonText={
intl.formatMessage({
id: 'xpack.remoteClusters.removeButton.confirmModal.confirmButtonText',
defaultMessage: 'Remove',
})
}
onMouseOver={this.onMouseOverModal}
>
{!isSingleCluster && content}
</EuiConfirmModal>
</EuiOverlayMask>
);
}
return (
<Fragment>
{children(this.showConfirmModal)}
{modal}
</Fragment>
);
}
}
);

View file

@ -1,354 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiButtonEmpty,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiIcon,
EuiSpacer,
EuiText,
EuiTextColor,
EuiTitle,
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH } from '../../../constants';
import { getRouterLinkProps } from '../../../services';
import { ConfiguredByNodeWarning } from '../../components';
import { ConnectionStatus, RemoveClusterButtonProvider } from '../components';
export const DetailPanel = injectI18n(
class extends Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
isLoading: PropTypes.bool,
cluster: PropTypes.object,
closeDetailPanel: PropTypes.func.isRequired,
clusterName: PropTypes.string,
}
renderSkipUnavailableValue(skipUnavailable) {
if (skipUnavailable === true) {
return (
<FormattedMessage
id="xpack.remoteClusters.detailPanel.skipUnavailableTrueValue"
defaultMessage="Yes"
/>
);
}
if (skipUnavailable === false) {
return (
<FormattedMessage
id="xpack.remoteClusters.detailPanel.skipUnavailableFalseValue"
defaultMessage="No"
/>
);
}
return (
<FormattedMessage
id="xpack.remoteClusters.detailPanel.skipUnavailableNullValue"
defaultMessage="Default"
/>
);
}
renderClusterNotFound() {
return (
<EuiFlexGroup
justifyContent="flexStart"
alignItems="center"
gutterSize="s"
data-test-subj="remoteClusterDetailClusterNotFound"
>
<EuiFlexItem grow={false}>
<EuiIcon size="m" type="alert" color="danger" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.notFoundLabel"
defaultMessage="Remote cluster not found"
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
renderClusterConfiguredByNodeWarning({ isConfiguredByNode }) {
if (!isConfiguredByNode) {
return null;
}
return (
<Fragment>
<ConfiguredByNodeWarning />
<EuiSpacer size="l" />
</Fragment>
);
}
renderCluster({
isConnected,
connectedNodesCount,
skipUnavailable,
seeds,
maxConnectionsPerCluster,
initialConnectTimeout,
}) {
return (
<section
aria-labelledby="xpack.remoteClusters.detailPanel.statusTitle"
data-test-subj="remoteClusterDetailPanelStatusSection"
>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.remoteClusters.detailPanel.statusTitle"
defaultMessage="Status"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList data-test-subj="remoteClusterDetailPanelStatusValues">
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.connectedLabel"
defaultMessage="Connection"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailIsConnected">
<ConnectionStatus isConnected={isConnected} />
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.connectedNodesLabel"
defaultMessage="Connected nodes"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailConnectedNodesCount">
{connectedNodesCount}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.seedsLabel"
defaultMessage="Seeds"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailSeeds">
{seeds.map(seed => <EuiText key={seed}>{seed}</EuiText>)}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.skipUnavailableLabel"
defaultMessage="Skip unavailable"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailSkipUnavailable">
{this.renderSkipUnavailableValue(skipUnavailable)}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.maxConnectionsPerClusterLabel"
defaultMessage="Maximum number of connections"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailMaxConnections">
{maxConnectionsPerCluster}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiTitle size="xs">
<FormattedMessage
id="xpack.remoteClusters.detailPanel.initialConnectTimeoutLabel"
defaultMessage="Initial connect timeout"
/>
</EuiTitle>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription data-test-subj="remoteClusterDetailInitialConnectTimeout">
{initialConnectTimeout}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionList>
</section>
);
}
renderFlyoutBody() {
const { cluster } = this.props;
return (
<EuiFlyoutBody>
{!cluster && (
this.renderClusterNotFound()
)}
{cluster && (
<Fragment>
{this.renderClusterConfiguredByNodeWarning(cluster)}
{this.renderCluster(cluster)}
</Fragment>
)}
</EuiFlyoutBody>
);
}
renderFlyoutFooter() {
const {
cluster,
clusterName,
closeDetailPanel,
} = this.props;
return (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={closeDetailPanel}
data-test-subj="remoteClusterDetailsPanelCloseButton"
>
<FormattedMessage
id="xpack.remoteClusters.detailPanel.closeButtonLabel"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
{cluster && !cluster.isConfiguredByNode && (
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<RemoveClusterButtonProvider clusterNames={[clusterName]}>
{(removeCluster) => (
<EuiButtonEmpty
color="danger"
onClick={removeCluster}
data-test-subj="remoteClusterDetailPanelRemoveButton"
>
<FormattedMessage
id="xpack.remoteClusters.detailPanel.removeButtonLabel"
defaultMessage="Remove"
/>
</EuiButtonEmpty>
)}
</RemoveClusterButtonProvider>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
{...getRouterLinkProps(`${CRUD_APP_BASE_PATH}/edit/${clusterName}`)}
fill
color="primary"
data-test-subj="remoteClusterDetailPanelEditButton"
>
<FormattedMessage
id="xpack.remoteClusters.detailPanel.editButtonLabel"
defaultMessage="Edit"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlyoutFooter>
);
}
render() {
const { isOpen, closeDetailPanel, clusterName } = this.props;
if (!isOpen) {
return null;
}
return (
<EuiFlyout
data-test-subj="remoteClusterDetailFlyout"
onClose={closeDetailPanel}
aria-labelledby="remoteClusterDetailsFlyoutTitle"
size="m"
maxWidth={400}
>
<EuiFlyoutHeader>
<EuiTitle
size="m"
id="remoteClusterDetailsFlyoutTitle"
data-test-subj="remoteClusterDetailsFlyoutTitle"
>
<h2>{clusterName}</h2>
</EuiTitle>
</EuiFlyoutHeader>
{this.renderFlyoutBody()}
{this.renderFlyoutFooter()}
</EuiFlyout>
);
}
}
);

View file

@ -1,295 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component, 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 {
EuiButton,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingKibana,
EuiLoadingSpinner,
EuiOverlayMask,
EuiPageBody,
EuiPageContent,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiSpacer,
EuiText,
EuiTextColor,
EuiTitle,
EuiCallOut,
} from '@elastic/eui';
import { CRUD_APP_BASE_PATH } from '../../constants';
import { getRouterLinkProps, extractQueryParams, listBreadcrumb } from '../../services';
import {
RemoteClusterTable,
} from './remote_cluster_table';
import {
DetailPanel,
} from './detail_panel';
const REFRESH_RATE_MS = 30000;
export const RemoteClusterList = injectI18n(
class extends Component {
static propTypes = {
loadClusters: PropTypes.func.isRequired,
refreshClusters: PropTypes.func.isRequired,
openDetailPanel: PropTypes.func.isRequired,
closeDetailPanel: PropTypes.func.isRequired,
isDetailPanelOpen: PropTypes.bool,
clusters: PropTypes.array,
isLoading: PropTypes.bool,
isCopyingCluster: PropTypes.bool,
isRemovingCluster: PropTypes.bool,
}
componentDidUpdate() {
const {
openDetailPanel,
closeDetailPanel,
isDetailPanelOpen,
history: {
location: {
search,
},
},
} = this.props;
const { cluster: clusterName } = extractQueryParams(search);
// Show deeplinked remoteCluster whenever remoteClusters get loaded or the URL changes.
if (clusterName != null) {
openDetailPanel(clusterName);
} else if (isDetailPanelOpen) {
closeDetailPanel();
}
}
componentDidMount() {
this.props.loadClusters();
this.interval = setInterval(this.props.refreshClusters, REFRESH_RATE_MS);
chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb ]);
}
componentWillUnmount() {
clearInterval(this.interval);
}
getHeaderSection(isAuthorized) {
return (
<Fragment>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterListTitle"
defaultMessage="Remote Clusters"
/>
</h1>
</EuiTitle>
</EuiPageContentHeaderSection>
{ isAuthorized && (
<EuiPageContentHeaderSection>
<EuiButton
{...getRouterLinkProps(`${CRUD_APP_BASE_PATH}/add`)}
fill
data-test-subj="remoteClusterCreateButton"
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.connectButtonLabel"
defaultMessage="Add a remote cluster"
/>
</EuiButton>
</EuiPageContentHeaderSection>
)}
</EuiPageContentHeader>
<EuiSpacer size="m" />
</Fragment>
);
}
renderBlockingAction() {
const { isCopyingCluster, isRemovingCluster } = this.props;
if (isCopyingCluster || isRemovingCluster) {
return (
<EuiOverlayMask>
<EuiLoadingKibana size="xl"/>
</EuiOverlayMask>
);
}
return null;
}
renderNoPermission() {
const { intl } = this.props;
const title = intl.formatMessage({
id: 'xpack.remoteClusters.remoteClusterList.noPermissionTitle',
defaultMessage: 'Permission error',
});
return (
<EuiCallOut
title={title}
color="warning"
iconType="help"
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.noPermissionText"
defaultMessage="You do not have permission to view or add remote clusters."
/>
</EuiCallOut>
);
}
renderError(error) {
// We can safely depend upon the shape of this error coming from Angular $http, because we
// handle unexpected error shapes in the API action.
const {
statusCode,
error: errorString,
} = error.data;
const { intl } = this.props;
const title = intl.formatMessage({
id: 'xpack.remoteClusters.remoteClusterList.loadingErrorTitle',
defaultMessage: 'Error loading remote clusters',
});
return (
<EuiCallOut
title={title}
color="danger"
iconType="alert"
>
{statusCode} {errorString}
</EuiCallOut>
);
}
renderEmpty() {
return (
<EuiEmptyPrompt
data-test-subj="remoteClusterListEmptyPrompt"
iconType="managementApp"
title={(
<h1>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.emptyPromptTitle"
defaultMessage="Add your first remote cluster"
/>
</h1>
)}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.emptyPromptDescription"
defaultMessage="Remote clusters create a uni-directional connection from your
local cluster to other clusters."
/>
</p>
</Fragment>
}
actions={
<EuiButton
{...getRouterLinkProps(`${CRUD_APP_BASE_PATH}/add`)}
fill
iconType="plusInCircle"
data-test-subj="remoteClusterEmptyPromptCreateButton"
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.emptyPrompt.connectButtonLabel"
defaultMessage="Add a remote cluster"
/>
</EuiButton>
}
/>
);
}
renderLoading() {
return (
<EuiFlexGroup
justifyContent="flexStart"
alignItems="center"
gutterSize="s"
data-test-subj="remoteClustersTableLoading"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.remoteClusters.remoteClusterList.loadingTitle"
defaultMessage="Loading remote clusters..."
/>
</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
renderList() {
const { clusters } = this.props;
return (
<Fragment>
<RemoteClusterTable clusters={clusters} />
<DetailPanel />
</Fragment>
);
}
render() {
const { isLoading, clusters, clusterLoadError } = this.props;
const isEmpty = !isLoading && !clusters.length;
const isAuthorized = !clusterLoadError || clusterLoadError.status !== 403;
const isHeaderVisible = clusterLoadError || !isEmpty;
let content;
if (clusterLoadError) {
if (!isAuthorized) {
content = this.renderNoPermission();
} else {
content = this.renderError(clusterLoadError);
}
} else if (isEmpty) {
content = this.renderEmpty();
} else if (isLoading) {
content = this.renderLoading();
} else {
content = this.renderList();
}
return (
<EuiPageBody>
<EuiPageContent>
{(isHeaderVisible) && this.getHeaderSection(isAuthorized)}
{content}
{this.renderBlockingAction()}
</EuiPageContent>
</EuiPageBody>
);
}
}
);

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