[Remote clusters] Cloud deployment form when adding new cluster (#94450)

* Implemented in-form Cloud deployment url input

* Fixed i18n files and added mode switch back for non-Cloud

* Added cloud docs link to the documentation service, fixed snapshot tests

* Fixed docs build

* Added jest test for the new cloud url input

* Added unit test for cloud validation

* Fixed eslint error

* Fixed ts errors

* Added ts-ignore

* Fixed ts errors

* Refactored connection mode component and component tests

* Fixed import

* Fixed copy

* Fixed copy

* Reverted docs changes

* Reverted docs changes

* Replaced the screenshot with a popover and refactored integration tests

* Added todo for cloud deployments link

* Changed cloud URL help text copy

* Added cloud base url and deleted unnecessary base path

* Fixed es error

* Fixed es error

* Changed wording

* Reverted docs changes

* Updated the help popover

* Deleted unneeded fragment component

* Deleted unneeded fragment component

* Updated tests descriptions to be more detailed

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yulia Čech 2021-04-08 14:18:18 +02:00 committed by GitHub
parent 03a51f4eec
commit 0316787ead
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 2184 additions and 3503 deletions

View file

@ -1,43 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act } from 'react-dom/test-utils';
import { registerTestBed } from '@kbn/test/jest';
import { RemoteClusterAdd } from '../../../public/application/sections/remote_cluster_add';
import { createRemoteClustersStore } from '../../../public/application/store';
import { registerRouter } from '../../../public/application/services/routing';
const testBedConfig = {
store: createRemoteClustersStore,
memoryRouter: {
onRouter: (router) => registerRouter(router),
},
};
const initTestBed = registerTestBed(RemoteClusterAdd, testBedConfig);
export const setup = (props) => {
const testBed = initTestBed(props);
// User actions
const clickSaveForm = async () => {
await act(async () => {
testBed.find('remoteClusterFormSaveButton').simulate('click');
});
testBed.component.update();
};
return {
...testBed,
actions: {
clickSaveForm,
},
};
};

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { registerTestBed } from '@kbn/test/jest';
import { RemoteClusterAdd } from '../../../public/application/sections';
import { createRemoteClustersStore } from '../../../public/application/store';
import { AppRouter, registerRouter } from '../../../public/application/services';
import { createRemoteClustersActions } from '../helpers';
import { AppContextProvider } from '../../../public/application/app_context';
const ComponentWithContext = ({ isCloudEnabled }: { isCloudEnabled: boolean }) => {
return (
<AppContextProvider context={{ isCloudEnabled, cloudBaseUrl: 'test.com' }}>
<RemoteClusterAdd />
</AppContextProvider>
);
};
const testBedConfig = ({ isCloudEnabled }: { isCloudEnabled: boolean }) => {
return {
store: createRemoteClustersStore,
memoryRouter: {
onRouter: (router: AppRouter) => registerRouter(router),
},
defaultProps: { isCloudEnabled },
};
};
const initTestBed = (isCloudEnabled: boolean) =>
registerTestBed(ComponentWithContext, testBedConfig({ isCloudEnabled }))();
export const setup = async (isCloudEnabled = false) => {
const testBed = await initTestBed(isCloudEnabled);
return {
...testBed,
actions: {
...createRemoteClustersActions(testBed),
},
};
};

View file

@ -1,230 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act } from 'react-dom/test-utils';
import { setupEnvironment } from '../helpers';
import { NON_ALPHA_NUMERIC_CHARS, ACCENTED_CHARS } from './special_characters';
import { setup } from './remote_clusters_add.helpers';
describe('Create Remote cluster', () => {
describe('on component mount', () => {
let find;
let exists;
let actions;
let form;
let server;
let component;
beforeAll(() => {
({ server } = setupEnvironment());
});
afterAll(() => {
server.restore();
});
beforeEach(async () => {
await act(async () => {
({ form, exists, find, actions, component } = setup());
});
component.update();
});
test('should have the title of the page set correctly', () => {
expect(exists('remoteClusterPageTitle')).toBe(true);
expect(find('remoteClusterPageTitle').text()).toEqual('Add remote cluster');
});
test('should have a link to the documentation', () => {
expect(exists('remoteClusterDocsButton')).toBe(true);
});
test('should have a toggle to Skip unavailable remote cluster', () => {
expect(exists('remoteClusterFormSkipUnavailableFormToggle')).toBe(true);
// By default it should be set to "false"
expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe(
false
);
act(() => {
form.toggleEuiSwitch('remoteClusterFormSkipUnavailableFormToggle');
});
component.update();
expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe(true);
});
test('should have a toggle to enable "proxy" mode for a remote cluster', () => {
expect(exists('remoteClusterFormConnectionModeToggle')).toBe(true);
// By default it should be set to "false"
expect(find('remoteClusterFormConnectionModeToggle').props()['aria-checked']).toBe(false);
act(() => {
form.toggleEuiSwitch('remoteClusterFormConnectionModeToggle');
});
component.update();
expect(find('remoteClusterFormConnectionModeToggle').props()['aria-checked']).toBe(true);
});
test('should display errors and disable the save button when clicking "save" without filling the form', async () => {
expect(exists('remoteClusterFormGlobalError')).toBe(false);
expect(find('remoteClusterFormSaveButton').props().disabled).toBe(false);
await actions.clickSaveForm();
expect(exists('remoteClusterFormGlobalError')).toBe(true);
expect(form.getErrorsMessages()).toEqual([
'Name is required.',
'At least one seed node is required.',
]);
expect(find('remoteClusterFormSaveButton').props().disabled).toBe(true);
});
});
describe('form validation', () => {
describe('remote cluster name', () => {
let component;
let actions;
let form;
beforeEach(async () => {
await act(async () => {
({ component, form, actions } = setup());
});
component.update();
});
test('should not allow spaces', async () => {
form.setInputValue('remoteClusterFormNameInput', 'with space');
await actions.clickSaveForm();
expect(form.getErrorsMessages()).toContain('Spaces are not allowed in the name.');
});
test('should only allow alpha-numeric characters, "-" (dash) and "_" (underscore)', async () => {
const expectInvalidChar = (char) => {
if (char === '-' || char === '_') {
return;
}
try {
form.setInputValue('remoteClusterFormNameInput', `with${char}`);
expect(form.getErrorsMessages()).toContain(
`Remove the character ${char} from the name.`
);
} catch {
throw Error(`Char "${char}" expected invalid but was allowed`);
}
};
await actions.clickSaveForm(); // display form errors
[...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS].forEach(expectInvalidChar);
});
});
describe('seeds', () => {
let actions;
let form;
let component;
beforeEach(async () => {
await act(async () => {
({ form, actions, component } = setup());
});
component.update();
form.setInputValue('remoteClusterFormNameInput', 'remote_cluster_test');
});
test('should only allow alpha-numeric characters and "-" (dash) in the node "host" part', async () => {
await actions.clickSaveForm(); // display form errors
const notInArray = (array) => (value) => array.indexOf(value) < 0;
const expectInvalidChar = (char) => {
form.setComboBoxValue('remoteClusterFormSeedsInput', `192.16${char}:3000`);
expect(form.getErrorsMessages()).toContain(
`Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.`
);
};
[...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS]
.filter(notInArray(['-', '_', ':']))
.forEach(expectInvalidChar);
});
test('should require a numeric "port" to be set', async () => {
await actions.clickSaveForm();
form.setComboBoxValue('remoteClusterFormSeedsInput', '192.168.1.1');
expect(form.getErrorsMessages()).toContain('A port is required.');
form.setComboBoxValue('remoteClusterFormSeedsInput', '192.168.1.1:abc');
expect(form.getErrorsMessages()).toContain('A port is required.');
});
});
describe('proxy address', () => {
let actions;
let form;
let component;
beforeEach(async () => {
await act(async () => {
({ form, actions, component } = setup());
});
component.update();
act(() => {
// Enable "proxy" mode
form.toggleEuiSwitch('remoteClusterFormConnectionModeToggle');
});
component.update();
});
test('should only allow alpha-numeric characters and "-" (dash) in the proxy address "host" part', async () => {
await actions.clickSaveForm(); // display form errors
const notInArray = (array) => (value) => array.indexOf(value) < 0;
const expectInvalidChar = (char) => {
form.setInputValue('remoteClusterFormProxyAddressInput', `192.16${char}:3000`);
expect(form.getErrorsMessages()).toContain(
'Address must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.'
);
};
[...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS]
.filter(notInArray(['-', '_', ':']))
.forEach(expectInvalidChar);
});
test('should require a numeric "port" to be set', async () => {
await actions.clickSaveForm();
form.setInputValue('remoteClusterFormProxyAddressInput', '192.168.1.1');
expect(form.getErrorsMessages()).toContain('A port is required.');
form.setInputValue('remoteClusterFormProxyAddressInput', '192.168.1.1:abc');
expect(form.getErrorsMessages()).toContain('A port is required.');
});
});
});
});

View file

@ -0,0 +1,260 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SinonFakeServer } from 'sinon';
import { TestBed } from '@kbn/test/jest';
import { act } from 'react-dom/test-utils';
import { setupEnvironment, RemoteClustersActions } from '../helpers';
import { setup } from './remote_clusters_add.helpers';
import { NON_ALPHA_NUMERIC_CHARS, ACCENTED_CHARS } from './special_characters';
const notInArray = (array: string[]) => (value: string) => array.indexOf(value) < 0;
let component: TestBed['component'];
let actions: RemoteClustersActions;
let server: SinonFakeServer;
describe('Create Remote cluster', () => {
beforeAll(() => {
({ server } = setupEnvironment());
});
afterAll(() => {
server.restore();
});
beforeEach(async () => {
await act(async () => {
({ actions, component } = await setup());
});
component.update();
});
describe('on component mount', () => {
test('should have the title of the page set correctly', () => {
expect(actions.pageTitle.exists()).toBe(true);
expect(actions.pageTitle.text()).toEqual('Add remote cluster');
});
test('should have a link to the documentation', () => {
expect(actions.docsButtonExists()).toBe(true);
});
test('should have a toggle to Skip unavailable remote cluster', () => {
expect(actions.skipUnavailableSwitch.exists()).toBe(true);
// By default it should be set to "false"
expect(actions.skipUnavailableSwitch.isChecked()).toBe(false);
actions.skipUnavailableSwitch.toggle();
expect(actions.skipUnavailableSwitch.isChecked()).toBe(true);
});
describe('on prem', () => {
test('should have a toggle to enable "proxy" mode for a remote cluster', () => {
expect(actions.connectionModeSwitch.exists()).toBe(true);
// By default it should be set to "false"
expect(actions.connectionModeSwitch.isChecked()).toBe(false);
actions.connectionModeSwitch.toggle();
expect(actions.connectionModeSwitch.isChecked()).toBe(true);
});
test('server name has optional label', () => {
actions.connectionModeSwitch.toggle();
expect(actions.serverNameInput.getLabel()).toBe('Server name (optional)');
});
test('should display errors and disable the save button when clicking "save" without filling the form', async () => {
expect(actions.globalErrorExists()).toBe(false);
expect(actions.saveButton.isDisabled()).toBe(false);
await actions.saveButton.click();
expect(actions.globalErrorExists()).toBe(true);
expect(actions.getErrorMessages()).toEqual([
'Name is required.',
// seeds input is switched on by default on prem and is required
'At least one seed node is required.',
]);
expect(actions.saveButton.isDisabled()).toBe(true);
});
test('renders no switch for cloud url input and proxy address + server name input modes', () => {
expect(actions.cloudUrlSwitch.exists()).toBe(false);
});
});
describe('on cloud', () => {
beforeEach(async () => {
await act(async () => {
({ actions, component } = await setup(true));
});
component.update();
});
test('renders a switch between cloud url input and proxy address + server name input for proxy connection', () => {
expect(actions.cloudUrlSwitch.exists()).toBe(true);
});
test('renders no switch between sniff and proxy modes', () => {
expect(actions.connectionModeSwitch.exists()).toBe(false);
});
test('defaults to cloud url input for proxy connection', () => {
expect(actions.cloudUrlSwitch.isChecked()).toBe(false);
});
test('server name has no optional label', () => {
actions.cloudUrlSwitch.toggle();
expect(actions.serverNameInput.getLabel()).toBe('Server name');
});
});
});
describe('form validation', () => {
describe('remote cluster name', () => {
test('should not allow spaces', async () => {
actions.nameInput.setValue('with space');
await actions.saveButton.click();
expect(actions.getErrorMessages()).toContain('Spaces are not allowed in the name.');
});
test('should only allow alpha-numeric characters, "-" (dash) and "_" (underscore)', async () => {
const expectInvalidChar = (char: string) => {
if (char === '-' || char === '_') {
return;
}
try {
actions.nameInput.setValue(`with${char}`);
expect(actions.getErrorMessages()).toContain(
`Remove the character ${char} from the name.`
);
} catch {
throw Error(`Char "${char}" expected invalid but was allowed`);
}
};
await actions.saveButton.click(); // display form errors
[...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS].forEach(expectInvalidChar);
});
});
describe('proxy address', () => {
beforeEach(async () => {
await act(async () => {
({ actions, component } = await setup());
});
component.update();
actions.connectionModeSwitch.toggle();
});
test('should only allow alpha-numeric characters and "-" (dash) in the proxy address "host" part', async () => {
await actions.saveButton.click(); // display form errors
const expectInvalidChar = (char: string) => {
actions.proxyAddressInput.setValue(`192.16${char}:3000`);
expect(actions.getErrorMessages()).toContain(
'Address must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.'
);
};
[...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS]
.filter(notInArray(['-', '_', ':']))
.forEach(expectInvalidChar);
});
test('should require a numeric "port" to be set', async () => {
await actions.saveButton.click();
actions.proxyAddressInput.setValue('192.168.1.1');
expect(actions.getErrorMessages()).toContain('A port is required.');
actions.proxyAddressInput.setValue('192.168.1.1:abc');
expect(actions.getErrorMessages()).toContain('A port is required.');
});
});
describe('on prem', () => {
beforeEach(async () => {
await act(async () => {
({ actions, component } = await setup());
});
component.update();
actions.nameInput.setValue('remote_cluster_test');
});
describe('seeds', () => {
test('should only allow alpha-numeric characters and "-" (dash) in the node "host" part', async () => {
await actions.saveButton.click(); // display form errors
const expectInvalidChar = (char: string) => {
actions.seedsInput.setValue(`192.16${char}:3000`);
expect(actions.getErrorMessages()).toContain(
`Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes.`
);
};
[...NON_ALPHA_NUMERIC_CHARS, ...ACCENTED_CHARS]
.filter(notInArray(['-', '_', ':']))
.forEach(expectInvalidChar);
});
test('should require a numeric "port" to be set', async () => {
await actions.saveButton.click();
actions.seedsInput.setValue('192.168.1.1');
expect(actions.getErrorMessages()).toContain('A port is required.');
actions.seedsInput.setValue('192.168.1.1:abc');
expect(actions.getErrorMessages()).toContain('A port is required.');
});
});
test('server name is optional (proxy connection)', () => {
actions.connectionModeSwitch.toggle();
actions.saveButton.click();
expect(actions.getErrorMessages()).toEqual(['A proxy address is required.']);
});
});
describe('on cloud', () => {
beforeEach(async () => {
await act(async () => {
({ actions, component } = await setup(true));
});
component.update();
});
test('cloud url is required since cloud url input is enabled by default', () => {
actions.saveButton.click();
expect(actions.getErrorMessages()).toContain('A url is required.');
});
test('proxy address and server name are required when cloud url input is disabled', () => {
actions.cloudUrlSwitch.toggle();
actions.saveButton.click();
expect(actions.getErrorMessages()).toEqual([
'Name is required.',
'A proxy address is required.',
'A server name is required.',
]);
});
});
});
});

View file

@ -1,34 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { registerTestBed } from '@kbn/test/jest';
import { RemoteClusterEdit } from '../../../public/application/sections/remote_cluster_edit';
import { createRemoteClustersStore } from '../../../public/application/store';
import { registerRouter } from '../../../public/application/services/routing';
export const REMOTE_CLUSTER_EDIT_NAME = 'new-york';
export const REMOTE_CLUSTER_EDIT = {
name: REMOTE_CLUSTER_EDIT_NAME,
seeds: ['localhost:9400'],
skipUnavailable: true,
};
const testBedConfig = {
store: createRemoteClustersStore,
memoryRouter: {
onRouter: (router) => registerRouter(router),
// The remote cluster name to edit is read from the router ":id" param
// so we first set it in our initial entries
initialEntries: [`/${REMOTE_CLUSTER_EDIT_NAME}`],
// and then we declarae the :id param on the component route path
componentRoutePath: '/:name',
},
};
export const setup = registerTestBed(RemoteClusterEdit, testBedConfig);

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { registerTestBed, TestBedConfig } from '@kbn/test/jest';
import React from 'react';
import { RemoteClusterEdit } from '../../../public/application/sections';
import { createRemoteClustersStore } from '../../../public/application/store';
import { AppRouter, registerRouter } from '../../../public/application/services';
import { createRemoteClustersActions } from '../helpers';
import { AppContextProvider } from '../../../public/application/app_context';
export const REMOTE_CLUSTER_EDIT_NAME = 'new-york';
export const REMOTE_CLUSTER_EDIT = {
name: REMOTE_CLUSTER_EDIT_NAME,
seeds: ['localhost:9400'],
skipUnavailable: true,
};
const ComponentWithContext = (props: { isCloudEnabled: boolean }) => {
const { isCloudEnabled, ...rest } = props;
return (
<AppContextProvider context={{ isCloudEnabled, cloudBaseUrl: 'test.com' }}>
<RemoteClusterEdit {...rest} />
</AppContextProvider>
);
};
const testBedConfig: TestBedConfig = {
store: createRemoteClustersStore,
memoryRouter: {
onRouter: (router: AppRouter) => registerRouter(router),
// The remote cluster name to edit is read from the router ":id" param
// so we first set it in our initial entries
initialEntries: [`/${REMOTE_CLUSTER_EDIT_NAME}`],
// and then we declare the :id param on the component route path
componentRoutePath: '/:name',
},
};
const initTestBed = (isCloudEnabled: boolean) =>
registerTestBed(ComponentWithContext, testBedConfig)({ isCloudEnabled });
export const setup = async (isCloudEnabled = false) => {
const testBed = await initTestBed(isCloudEnabled);
return {
...testBed,
actions: {
...createRemoteClustersActions(testBed),
},
};
};

View file

@ -1,80 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act } from 'react-dom/test-utils';
import { RemoteClusterForm } from '../../../public/application/sections/components/remote_cluster_form';
import { setupEnvironment } from '../helpers';
import { setup as setupRemoteClustersAdd } from '../add/remote_clusters_add.helpers';
import {
setup,
REMOTE_CLUSTER_EDIT,
REMOTE_CLUSTER_EDIT_NAME,
} from './remote_clusters_edit.helpers';
describe('Edit Remote cluster', () => {
let component;
let find;
let exists;
const { server, httpRequestsMockHelpers } = setupEnvironment();
afterAll(() => {
server.restore();
});
httpRequestsMockHelpers.setLoadRemoteClustersResponse([REMOTE_CLUSTER_EDIT]);
beforeEach(async () => {
await act(async () => {
({ component, find, exists } = setup());
});
component.update();
});
test('should have the title of the page set correctly', () => {
expect(exists('remoteClusterPageTitle')).toBe(true);
expect(find('remoteClusterPageTitle').text()).toEqual('Edit remote cluster');
});
test('should have a link to the documentation', () => {
expect(exists('remoteClusterDocsButton')).toBe(true);
});
/**
* As the "edit" remote cluster component uses the same form underneath that
* the "create" remote cluster, we won't test it again but simply make sure that
* the form component is indeed shared between the 2 app sections.
*/
test('should use the same Form component as the "<RemoteClusterAdd />" component', async () => {
let addRemoteClusterTestBed;
await act(async () => {
addRemoteClusterTestBed = setupRemoteClustersAdd();
});
addRemoteClusterTestBed.component.update();
const formEdit = component.find(RemoteClusterForm);
const formAdd = addRemoteClusterTestBed.component.find(RemoteClusterForm);
expect(formEdit.length).toBe(1);
expect(formAdd.length).toBe(1);
});
test('should populate the form fields with the values from the remote cluster loaded', () => {
expect(find('remoteClusterFormNameInput').props().value).toBe(REMOTE_CLUSTER_EDIT_NAME);
expect(find('remoteClusterFormSeedsInput').text()).toBe(REMOTE_CLUSTER_EDIT.seeds.join(''));
expect(find('remoteClusterFormSkipUnavailableFormToggle').props()['aria-checked']).toBe(
REMOTE_CLUSTER_EDIT.skipUnavailable
);
});
test('should disable the form name input', () => {
expect(find('remoteClusterFormNameInput').props().disabled).toBe(true);
});
});

View file

@ -0,0 +1,141 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act } from 'react-dom/test-utils';
import { TestBed } from '@kbn/test/jest';
import { RemoteClusterForm } from '../../../public/application/sections/components/remote_cluster_form';
import { RemoteClustersActions, setupEnvironment } from '../helpers';
import { setup as setupRemoteClustersAdd } from '../add/remote_clusters_add.helpers';
import {
setup,
REMOTE_CLUSTER_EDIT,
REMOTE_CLUSTER_EDIT_NAME,
} from './remote_clusters_edit.helpers';
import { Cluster } from '../../../common/lib';
let component: TestBed['component'];
let actions: RemoteClustersActions;
const { server, httpRequestsMockHelpers } = setupEnvironment();
describe('Edit Remote cluster', () => {
afterAll(() => {
server.restore();
});
httpRequestsMockHelpers.setLoadRemoteClustersResponse([REMOTE_CLUSTER_EDIT]);
beforeEach(async () => {
await act(async () => {
({ component, actions } = await setup());
});
component.update();
});
test('should have the title of the page set correctly', () => {
expect(actions.pageTitle.exists()).toBe(true);
expect(actions.pageTitle.text()).toEqual('Edit remote cluster');
});
test('should have a link to the documentation', () => {
expect(actions.docsButtonExists()).toBe(true);
});
/**
* As the "edit" remote cluster component uses the same form underneath that
* the "create" remote cluster, we won't test it again but simply make sure that
* the form component is indeed shared between the 2 app sections.
*/
test('should use the same Form component as the "<RemoteClusterAdd />" component', async () => {
let addRemoteClusterTestBed: TestBed;
await act(async () => {
addRemoteClusterTestBed = await setupRemoteClustersAdd();
});
addRemoteClusterTestBed!.component.update();
const formEdit = component.find(RemoteClusterForm);
const formAdd = addRemoteClusterTestBed!.component.find(RemoteClusterForm);
expect(formEdit.length).toBe(1);
expect(formAdd.length).toBe(1);
});
test('should populate the form fields with the values from the remote cluster loaded', () => {
expect(actions.nameInput.getValue()).toBe(REMOTE_CLUSTER_EDIT_NAME);
// seeds input for sniff connection is not shown on Cloud
expect(actions.seedsInput.getValue()).toBe(REMOTE_CLUSTER_EDIT.seeds.join(''));
expect(actions.skipUnavailableSwitch.isChecked()).toBe(REMOTE_CLUSTER_EDIT.skipUnavailable);
});
test('should disable the form name input', () => {
expect(actions.nameInput.isDisabled()).toBe(true);
});
describe('on cloud', () => {
const cloudUrl = 'cloud-url';
const defaultCloudPort = '9400';
test('existing cluster that defaults to cloud url (default port)', async () => {
const cluster: Cluster = {
name: REMOTE_CLUSTER_EDIT_NAME,
mode: 'proxy',
proxyAddress: `${cloudUrl}:${defaultCloudPort}`,
serverName: cloudUrl,
};
httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]);
await act(async () => {
({ component, actions } = await setup(true));
});
component.update();
expect(actions.cloudUrlInput.exists()).toBe(true);
expect(actions.cloudUrlInput.getValue()).toBe(cloudUrl);
});
test('existing cluster that defaults to manual input (non-default port)', async () => {
const cluster: Cluster = {
name: REMOTE_CLUSTER_EDIT_NAME,
mode: 'proxy',
proxyAddress: `${cloudUrl}:9500`,
serverName: cloudUrl,
};
httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]);
await act(async () => {
({ component, actions } = await setup(true));
});
component.update();
expect(actions.cloudUrlInput.exists()).toBe(false);
expect(actions.proxyAddressInput.exists()).toBe(true);
expect(actions.serverNameInput.exists()).toBe(true);
});
test('existing cluster that defaults to manual input (proxy address is different from server name)', async () => {
const cluster: Cluster = {
name: REMOTE_CLUSTER_EDIT_NAME,
mode: 'proxy',
proxyAddress: `${cloudUrl}:${defaultCloudPort}`,
serverName: 'another-value',
};
httpRequestsMockHelpers.setLoadRemoteClustersResponse([cluster]);
await act(async () => {
({ component, actions } = await setup(true));
});
component.update();
expect(actions.cloudUrlInput.exists()).toBe(false);
expect(actions.proxyAddressInput.exists()).toBe(true);
expect(actions.serverNameInput.exists()).toBe(true);
});
});
});

View file

@ -5,25 +5,24 @@
* 2.0.
*/
import sinon from 'sinon';
import sinon, { SinonFakeServer } from 'sinon';
import { Cluster } from '../../../common/lib';
// Register helpers to mock HTTP Requests
const registerHttpRequestMockHelpers = (server) => {
const mockResponse = (response) => [
const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
const mockResponse = (response: Cluster[] | { itemsDeleted: string[]; errors: string[] }) => [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify(response),
];
const setLoadRemoteClustersResponse = (response) => {
server.respondWith('GET', '/api/remote_clusters', [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify(response),
]);
const setLoadRemoteClustersResponse = (response: Cluster[] = []) => {
server.respondWith('GET', '/api/remote_clusters', mockResponse(response));
};
const setDeleteRemoteClusterResponse = (response) => {
const setDeleteRemoteClusterResponse = (
response: { itemsDeleted: string[]; errors: string[] } = { itemsDeleted: [], errors: [] }
) => {
server.respondWith('DELETE', /api\/remote_clusters/, mockResponse(response));
};

View file

@ -7,3 +7,4 @@
export { nextTick, getRandomString, findTestSubject } from '@kbn/test/jest';
export { setupEnvironment } from './setup_environment';
export { createRemoteClustersActions, RemoteClustersActions } from './remote_clusters_actions';

View file

@ -0,0 +1,199 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TestBed } from '@kbn/test/jest';
import { act } from 'react-dom/test-utils';
export interface RemoteClustersActions {
docsButtonExists: () => boolean;
pageTitle: {
exists: () => boolean;
text: () => string;
};
nameInput: {
setValue: (name: string) => void;
getValue: () => string;
isDisabled: () => boolean;
};
skipUnavailableSwitch: {
exists: () => boolean;
toggle: () => void;
isChecked: () => boolean;
};
connectionModeSwitch: {
exists: () => boolean;
toggle: () => void;
isChecked: () => boolean;
};
cloudUrlSwitch: {
toggle: () => void;
exists: () => boolean;
isChecked: () => boolean;
};
cloudUrlInput: {
exists: () => boolean;
getValue: () => string;
};
seedsInput: {
setValue: (seed: string) => void;
getValue: () => string;
};
proxyAddressInput: {
setValue: (proxyAddress: string) => void;
exists: () => boolean;
};
serverNameInput: {
getLabel: () => string;
exists: () => boolean;
};
saveButton: {
click: () => void;
isDisabled: () => boolean;
};
getErrorMessages: () => string[];
globalErrorExists: () => boolean;
}
export const createRemoteClustersActions = (testBed: TestBed): RemoteClustersActions => {
const { form, exists, find, component } = testBed;
const docsButtonExists = () => exists('remoteClusterDocsButton');
const createPageTitleActions = () => {
const pageTitleSelector = 'remoteClusterPageTitle';
return {
pageTitle: {
exists: () => exists(pageTitleSelector),
text: () => find(pageTitleSelector).text(),
},
};
};
const createNameInputActions = () => {
const nameInputSelector = 'remoteClusterFormNameInput';
return {
nameInput: {
setValue: (name: string) => form.setInputValue(nameInputSelector, name),
getValue: () => find(nameInputSelector).props().value,
isDisabled: () => find(nameInputSelector).props().disabled,
},
};
};
const createSkipUnavailableActions = () => {
const skipUnavailableToggleSelector = 'remoteClusterFormSkipUnavailableFormToggle';
return {
skipUnavailableSwitch: {
exists: () => exists(skipUnavailableToggleSelector),
toggle: () => {
act(() => {
form.toggleEuiSwitch(skipUnavailableToggleSelector);
});
component.update();
},
isChecked: () => find(skipUnavailableToggleSelector).props()['aria-checked'],
},
};
};
const createConnectionModeActions = () => {
const connectionModeToggleSelector = 'remoteClusterFormConnectionModeToggle';
return {
connectionModeSwitch: {
exists: () => exists(connectionModeToggleSelector),
toggle: () => {
act(() => {
form.toggleEuiSwitch(connectionModeToggleSelector);
});
component.update();
},
isChecked: () => find(connectionModeToggleSelector).props()['aria-checked'],
},
};
};
const createCloudUrlSwitchActions = () => {
const cloudUrlSelector = 'remoteClusterFormCloudUrlToggle';
return {
cloudUrlSwitch: {
exists: () => exists(cloudUrlSelector),
toggle: () => {
act(() => {
form.toggleEuiSwitch(cloudUrlSelector);
});
component.update();
},
isChecked: () => find(cloudUrlSelector).props()['aria-checked'],
},
};
};
const createSeedsInputActions = () => {
const seedsInputSelector = 'remoteClusterFormSeedsInput';
return {
seedsInput: {
setValue: (seed: string) => form.setComboBoxValue(seedsInputSelector, seed),
getValue: () => find(seedsInputSelector).text(),
},
};
};
const createProxyAddressActions = () => {
const proxyAddressSelector = 'remoteClusterFormProxyAddressInput';
return {
proxyAddressInput: {
setValue: (proxyAddress: string) => form.setInputValue(proxyAddressSelector, proxyAddress),
exists: () => exists(proxyAddressSelector),
},
};
};
const createSaveButtonActions = () => {
const click = () => {
act(() => {
find('remoteClusterFormSaveButton').simulate('click');
});
component.update();
};
const isDisabled = () => find('remoteClusterFormSaveButton').props().disabled;
return { saveButton: { click, isDisabled } };
};
const createServerNameActions = () => {
const serverNameSelector = 'remoteClusterFormServerNameFormRow';
return {
serverNameInput: {
getLabel: () => find('remoteClusterFormServerNameFormRow').find('label').text(),
exists: () => exists(serverNameSelector),
},
};
};
const globalErrorExists = () => exists('remoteClusterFormGlobalError');
const createCloudUrlInputActions = () => {
const cloudUrlInputSelector = 'remoteClusterFormCloudUrlInput';
return {
cloudUrlInput: {
exists: () => exists(cloudUrlInputSelector),
getValue: () => find(cloudUrlInputSelector).props().value,
},
};
};
return {
docsButtonExists,
...createPageTitleActions(),
...createNameInputActions(),
...createSkipUnavailableActions(),
...createConnectionModeActions(),
...createCloudUrlSwitchActions(),
...createSeedsInputActions(),
...createCloudUrlInputActions(),
...createProxyAddressActions(),
...createServerNameActions(),
...createSaveButtonActions(),
getErrorMessages: form.getErrorsMessages,
globalErrorExists,
};
};

View file

@ -36,6 +36,8 @@ export const setupEnvironment = () => {
notificationServiceMock.createSetupContract().toasts,
fatalErrorsServiceMock.createSetupContract()
);
// This expects HttpSetup but we're giving it AxiosInstance.
// @ts-ignore
initHttp(mockHttpClient);
const { server, httpRequestsMockHelpers } = initHttpRequests();

View file

@ -5,10 +5,11 @@
* 2.0.
*/
import React, { createContext } from 'react';
import React, { createContext, useContext } from 'react';
export interface Context {
isCloudEnabled: boolean;
cloudBaseUrl: string;
}
export const AppContext = createContext<Context>({} as any);
@ -22,3 +23,10 @@ export const AppContextProvider = ({
}) => {
return <AppContext.Provider value={context}>{children}</AppContext.Provider>;
};
export const useAppContext = () => {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('Cannot use outside of app context');
return ctx;
};

View file

@ -12,7 +12,8 @@ export declare const renderApp: (
elem: HTMLElement | null,
I18nContext: I18nStart['Context'],
appDependencies: {
isCloudEnabled?: boolean;
isCloudEnabled: boolean;
cloudBaseUrl: string;
},
history: ScopedHistory
) => ReturnType<RegisterManagementAppArgs['mount']>;

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiLink, EuiPopover, EuiPopoverTitle, EuiText } from '@elastic/eui';
import { useAppContext } from '../../../../app_context';
export const CloudUrlHelp: FunctionComponent = () => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const { cloudBaseUrl } = useAppContext();
return (
<EuiPopover
button={
<EuiText size="xs">
<EuiLink
onClick={() => {
setIsOpen(!isOpen);
}}
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.buttonLabel"
defaultMessage="Need help?"
/>
</EuiLink>
</EuiText>
}
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
anchorPosition="upCenter"
>
<EuiPopoverTitle>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.popoverTitle"
defaultMessage="How to find your Elasticsearch endpoint URL"
/>
</EuiPopoverTitle>
<EuiText size="s" style={{ maxWidth: 500 }}>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.cloudUrlHelp.stepOneText"
defaultMessage="Open the {deploymentsLink}, select the remote deployment and copy the {elasticsearch} endpoint URL."
values={{
deploymentsLink: (
<EuiLink external={true} href={`${cloudBaseUrl}/deployments`} target="_blank">
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.cloudUrlHelpModal.deploymentsLink"
defaultMessage="deployments page"
/>
</EuiLink>
),
elasticsearch: <strong> Elasticsearch</strong>,
}}
/>
</EuiText>
</EuiPopover>
);
};

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiDescribedFormGroup, EuiTitle, EuiFormRow, EuiSwitch, EuiSpacer } from '@elastic/eui';
import { SNIFF_MODE, PROXY_MODE } from '../../../../../../common/constants';
import { useAppContext } from '../../../../app_context';
import { ClusterErrors } from '../validators';
import { SniffConnection } from './sniff_connection';
import { ProxyConnection } from './proxy_connection';
import { FormFields } from '../remote_cluster_form';
export interface Props {
fields: FormFields;
onFieldsChange: (fields: Partial<FormFields>) => void;
fieldsErrors: ClusterErrors;
areErrorsVisible: boolean;
}
export const ConnectionMode: FunctionComponent<Props> = (props) => {
const { fields, onFieldsChange } = props;
const { mode, cloudUrlEnabled } = fields;
const { isCloudEnabled } = useAppContext();
return (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h2>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionModeTitle"
defaultMessage="Connection mode"
/>
</h2>
</EuiTitle>
}
description={
<>
{isCloudEnabled ? (
<>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionModeCloudDescription"
defaultMessage="Automatically configure the remote cluster by using the
Elasticsearch endpoint URL of the remote deployment or enter the proxy address and server name manually."
/>
<EuiFormRow hasEmptyLabelSpace fullWidth>
<EuiSwitch
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.manualModeFieldLabel"
defaultMessage="Manually enter proxy address and server name"
/>
}
checked={!cloudUrlEnabled}
data-test-subj="remoteClusterFormCloudUrlToggle"
onChange={(e) => onFieldsChange({ cloudUrlEnabled: !e.target.checked })}
/>
</EuiFormRow>
<EuiSpacer size="s" />
</>
) : (
<>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionModeDescription"
defaultMessage="Use seed nodes by default, or switch to proxy mode."
/>
<EuiFormRow hasEmptyLabelSpace fullWidth>
<EuiSwitch
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldModeLabel"
defaultMessage="Use proxy mode"
/>
}
checked={mode === PROXY_MODE}
data-test-subj="remoteClusterFormConnectionModeToggle"
onChange={(e) =>
onFieldsChange({ mode: e.target.checked ? PROXY_MODE : SNIFF_MODE })
}
/>
</EuiFormRow>
</>
)}
</>
}
fullWidth
>
{mode === SNIFF_MODE ? <SniffConnection {...props} /> : <ProxyConnection {...props} />}
</EuiDescribedFormGroup>
);
};

View file

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

View file

@ -0,0 +1,162 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { EuiFieldNumber, EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui';
import { useAppContext } from '../../../../app_context';
import { proxySettingsUrl } from '../../../../services/documentation';
import { Props } from './connection_mode';
import { CloudUrlHelp } from './cloud_url_help';
export const ProxyConnection: FunctionComponent<Props> = (props) => {
const { fields, fieldsErrors, areErrorsVisible, onFieldsChange } = props;
const { isCloudEnabled } = useAppContext();
const { proxyAddress, serverName, proxySocketConnections, cloudUrl, cloudUrlEnabled } = fields;
const {
proxyAddress: proxyAddressError,
serverName: serverNameError,
cloudUrl: cloudUrlError,
} = fieldsErrors;
return (
<>
{cloudUrlEnabled ? (
<>
<EuiFormRow
data-test-subj="remoteClusterFormCloudUrlFormRow"
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldCloudUrlLabel"
defaultMessage="Elasticsearch endpoint URL"
/>
}
labelAppend={<CloudUrlHelp />}
isInvalid={Boolean(areErrorsVisible && cloudUrlError)}
error={cloudUrlError}
fullWidth
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldCloudUrlHelpText"
defaultMessage="The protocol (https://) and port values are optional."
/>
}
>
<EuiFieldText
value={cloudUrl}
onChange={(e) => onFieldsChange({ cloudUrl: e.target.value })}
isInvalid={Boolean(areErrorsVisible && cloudUrlError)}
data-test-subj="remoteClusterFormCloudUrlInput"
fullWidth
/>
</EuiFormRow>
</>
) : (
<>
<EuiFormRow
data-test-subj="remoteClusterFormProxyAddressFormRow"
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldProxyAddressLabel"
defaultMessage="Proxy address"
/>
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldProxyAddressHelpText"
defaultMessage="The address to use for remote connections."
/>
}
isInvalid={Boolean(areErrorsVisible && proxyAddressError)}
error={proxyAddressError}
fullWidth
>
<EuiFieldText
value={proxyAddress}
placeholder={i18n.translate(
'xpack.remoteClusters.remoteClusterForm.fieldProxyAddressPlaceholder',
{
defaultMessage: 'host:port',
}
)}
onChange={(e) => onFieldsChange({ proxyAddress: e.target.value })}
isInvalid={Boolean(areErrorsVisible && proxyAddressError)}
data-test-subj="remoteClusterFormProxyAddressInput"
fullWidth
/>
</EuiFormRow>
<EuiFormRow
data-test-subj="remoteClusterFormServerNameFormRow"
isInvalid={Boolean(areErrorsVisible && serverNameError)}
error={serverNameError}
label={
isCloudEnabled ? (
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel"
defaultMessage="Server name"
/>
) : (
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldServerNameOptionalLabel"
defaultMessage="Server name (optional)"
/>
)
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldServerNameHelpText"
defaultMessage="A string sent in the server_name field of the TLS Server Name Indication extension if TLS is enabled. {learnMoreLink}"
values={{
learnMoreLink: (
<EuiLink href={proxySettingsUrl} target="_blank">
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldServerNameHelpText.learnMoreLinkLabel"
defaultMessage="Learn more."
/>
</EuiLink>
),
}}
/>
}
fullWidth
>
<EuiFieldText
value={serverName}
onChange={(e) => onFieldsChange({ serverName: e.target.value })}
isInvalid={Boolean(areErrorsVisible && serverNameError)}
fullWidth
/>
</EuiFormRow>
</>
)}
<EuiFormRow
data-test-subj="remoteClusterFormProxySocketConnectionsFormRow"
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldProxySocketConnectionsLabel"
defaultMessage="Socket connections"
/>
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText"
defaultMessage="The number of connections to open per remote cluster."
/>
}
fullWidth
>
<EuiFieldNumber
value={proxySocketConnections || ''}
onChange={(e) => onFieldsChange({ proxySocketConnections: Number(e.target.value) })}
fullWidth
/>
</EuiFormRow>
</>
);
};

View file

@ -0,0 +1,158 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FunctionComponent, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import {
EuiComboBox,
EuiComboBoxOptionOption,
EuiFieldNumber,
EuiFormRow,
EuiLink,
} from '@elastic/eui';
import { transportPortUrl } from '../../../../services/documentation';
import { validateSeed } from '../validators';
import { Props } from './connection_mode';
export const SniffConnection: FunctionComponent<Props> = ({
fields,
fieldsErrors,
areErrorsVisible,
onFieldsChange,
}) => {
const [localSeedErrors, setLocalSeedErrors] = useState<JSX.Element[]>([]);
const { seeds = [], nodeConnections } = fields;
const { seeds: seedsError } = fieldsErrors;
// Show errors if there is a general form error or local errors.
const areFormErrorsVisible = Boolean(areErrorsVisible && seedsError);
const showErrors = areFormErrorsVisible || localSeedErrors.length !== 0;
const errors =
areFormErrorsVisible && seedsError ? localSeedErrors.concat(seedsError) : localSeedErrors;
const formattedSeeds: EuiComboBoxOptionOption[] = seeds.map((seed: string) => ({ label: seed }));
const onCreateSeed = (newSeed?: string) => {
// If the user just hit enter without typing anything, treat it as a no-op.
if (!newSeed) {
return;
}
const validationErrors = validateSeed(newSeed);
if (validationErrors.length !== 0) {
setLocalSeedErrors(validationErrors);
// Return false to explicitly reject the user's input.
return false;
}
const newSeeds = seeds.slice(0);
newSeeds.push(newSeed.toLowerCase());
onFieldsChange({ seeds: newSeeds });
};
const onSeedsInputChange = (seedInput?: string) => {
if (!seedInput) {
// If empty seedInput ("") don't do anything. This happens
// right after a seed is created.
return;
}
// Allow typing to clear the errors, but not to add new ones.
const validationErrors =
!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) {
validationErrors.push(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.localSeedError.duplicateMessage"
defaultMessage="Duplicate seed nodes aren't allowed.`"
/>
);
}
setLocalSeedErrors(validationErrors);
};
return (
<>
<EuiFormRow
data-test-subj="remoteClusterFormSeedNodesFormRow"
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldSeedsLabel"
defaultMessage="Seed nodes"
/>
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldSeedsHelpText"
defaultMessage="An IP address or host name, followed by the {transportPort} of the remote cluster. Specify multiple seed nodes so discovery doesn't fail if a node is unavailable."
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={onCreateSeed}
onChange={(options: EuiComboBoxOptionOption[]) =>
onFieldsChange({ seeds: options.map(({ label }) => label) })
}
onSearchChange={onSeedsInputChange}
isInvalid={showErrors}
fullWidth
data-test-subj="remoteClusterFormSeedsInput"
/>
</EuiFormRow>
<EuiFormRow
data-test-subj="remoteClusterFormNodeConnectionsFormRow"
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNodeConnectionsLabel"
defaultMessage="Node connections"
/>
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNodeConnectionsHelpText"
defaultMessage="The number of gateway nodes to connect to for this cluster."
/>
}
fullWidth
>
<EuiFieldNumber
value={nodeConnections || ''}
onChange={(e) => onFieldsChange({ nodeConnections: Number(e.target.value) })}
fullWidth
/>
</EuiFormRow>
</>
);
};

View file

@ -1,962 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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,
EuiFieldNumber,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiLink,
EuiLoadingKibana,
EuiLoadingSpinner,
EuiOverlayMask,
EuiSpacer,
EuiSwitch,
EuiText,
EuiTitle,
EuiDelayRender,
EuiScreenReaderOnly,
htmlIdGenerator,
} from '@elastic/eui';
import {
skippingDisconnectedClustersUrl,
transportPortUrl,
proxySettingsUrl,
} from '../../../services/documentation';
import { RequestFlyout } from './request_flyout';
import {
validateName,
validateSeeds,
validateProxy,
validateSeed,
validateServerName,
} from './validators';
import { SNIFF_MODE, PROXY_MODE } from '../../../../../common/constants';
import { AppContext } from '../../../app_context';
const defaultFields = {
name: '',
seeds: [],
skipUnavailable: false,
nodeConnections: 3,
proxyAddress: '',
proxySocketConnections: 18,
serverName: '',
};
const ERROR_TITLE_ID = 'removeClustersErrorTitle';
const ERROR_LIST_ID = 'removeClustersErrorList';
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: {},
};
static contextType = AppContext;
constructor(props, context) {
super(props, context);
const { fields, disabledFields } = props;
const { isCloudEnabled } = context;
// Connection mode should default to "proxy" in cloud
const defaultMode = isCloudEnabled ? PROXY_MODE : SNIFF_MODE;
const fieldsState = merge({}, { ...defaultFields, mode: defaultMode }, fields);
this.generateId = htmlIdGenerator();
this.state = {
localSeedErrors: [],
seedInput: '',
fields: fieldsState,
disabledFields,
fieldsErrors: this.getFieldsErrors(fieldsState),
areErrorsVisible: false,
isRequestVisible: false,
};
}
toggleRequest = () => {
this.setState(({ isRequestVisible }) => ({
isRequestVisible: !isRequestVisible,
}));
};
getFieldsErrors(fields, seedInput = '') {
const { name, seeds, mode, proxyAddress, serverName } = fields;
const { isCloudEnabled } = this.context;
return {
name: validateName(name),
seeds: mode === SNIFF_MODE ? validateSeeds(seeds, seedInput) : null,
proxyAddress: mode === PROXY_MODE ? validateProxy(proxyAddress) : null,
// server name is only required in cloud when proxy mode is enabled
serverName: isCloudEnabled && mode === PROXY_MODE ? validateServerName(serverName) : null,
};
}
onFieldsChange = (changedFields) => {
this.setState(({ fields: prevFields, seedInput }) => {
const newFields = {
...prevFields,
...changedFields,
};
return {
fields: newFields,
fieldsErrors: this.getFieldsErrors(newFields, seedInput),
};
});
};
getAllFields() {
const {
fields: {
name,
mode,
seeds,
nodeConnections,
proxyAddress,
proxySocketConnections,
serverName,
skipUnavailable,
},
} = this.state;
const { fields } = this.props;
let modeSettings;
if (mode === PROXY_MODE) {
modeSettings = {
proxyAddress,
proxySocketConnections,
serverName,
};
} else {
modeSettings = {
seeds,
nodeConnections,
};
}
return {
name,
skipUnavailable,
mode,
hasDeprecatedProxySetting: fields.hasDeprecatedProxySetting,
...modeSettings,
};
}
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;
};
renderSniffModeSettings() {
const {
areErrorsVisible,
fields: { seeds, nodeConnections },
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 (
<>
<EuiFormRow
data-test-subj="remoteClusterFormSeedNodesFormRow"
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldSeedsLabel"
defaultMessage="Seed nodes"
/>
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldSeedsHelpText"
defaultMessage="An IP address or host name, followed by the {transportPort} of the remote cluster. Specify multiple seed nodes so discovery doesn't fail if a node is unavailable."
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>
<EuiFormRow
data-test-subj="remoteClusterFormNodeConnectionsFormRow"
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNodeConnectionsLabel"
defaultMessage="Node connections"
/>
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNodeConnectionsHelpText"
defaultMessage="The number of gateway nodes to connect to for this cluster."
/>
}
fullWidth
>
<EuiFieldNumber
value={nodeConnections || ''}
onChange={(e) =>
this.onFieldsChange({ nodeConnections: Number(e.target.value) || null })
}
fullWidth
/>
</EuiFormRow>
</>
);
}
renderProxyModeSettings() {
const {
areErrorsVisible,
fields: { proxyAddress, proxySocketConnections, serverName },
fieldsErrors: { proxyAddress: errorProxyAddress, serverName: errorServerName },
} = this.state;
const { isCloudEnabled } = this.context;
return (
<>
<EuiFormRow
data-test-subj="remoteClusterFormProxyAddressFormRow"
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldProxyAddressLabel"
defaultMessage="Proxy address"
/>
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldProxyAddressHelpText"
defaultMessage="The address to use for remote connections."
/>
}
isInvalid={Boolean(areErrorsVisible && errorProxyAddress)}
error={errorProxyAddress}
fullWidth
>
<EuiFieldText
value={proxyAddress}
placeholder={i18n.translate(
'xpack.remoteClusters.remoteClusterForm.fieldProxyAddressPlaceholder',
{
defaultMessage: 'host:port',
}
)}
onChange={(e) => this.onFieldsChange({ proxyAddress: e.target.value })}
isInvalid={Boolean(areErrorsVisible && errorProxyAddress)}
data-test-subj="remoteClusterFormProxyAddressInput"
fullWidth
/>
</EuiFormRow>
<EuiFormRow
data-test-subj="remoteClusterFormServerNameFormRow"
isInvalid={Boolean(areErrorsVisible && errorServerName)}
error={errorServerName}
label={
isCloudEnabled ? (
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel"
defaultMessage="Server name"
/>
) : (
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldServerNameOptionalLabel"
defaultMessage="Server name (optional)"
/>
)
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldServerNameHelpText"
defaultMessage="A string sent in the server_name field of the TLS Server Name Indication extension if TLS is enabled. {learnMoreLink}"
values={{
learnMoreLink: (
<EuiLink href={proxySettingsUrl} target="_blank">
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldServerNameHelpText.learnMoreLinkLabel"
defaultMessage="Learn more."
/>
</EuiLink>
),
}}
/>
}
fullWidth
>
<EuiFieldText
value={serverName}
onChange={(e) => this.onFieldsChange({ serverName: e.target.value })}
isInvalid={Boolean(areErrorsVisible && errorServerName)}
fullWidth
/>
</EuiFormRow>
<EuiFormRow
data-test-subj="remoteClusterFormProxySocketConnectionsFormRow"
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldProxySocketConnectionsLabel"
defaultMessage="Socket connections"
/>
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText"
defaultMessage="The number of socket connections to open per remote cluster."
/>
}
fullWidth
>
<EuiFieldNumber
value={proxySocketConnections || ''}
onChange={(e) =>
this.onFieldsChange({ proxySocketConnections: Number(e.target.value) || null })
}
fullWidth
/>
</EuiFormRow>
</>
);
}
renderMode() {
const {
fields: { mode },
} = this.state;
const { isCloudEnabled } = this.context;
return (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h2>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionModeTitle"
defaultMessage="Connection mode"
/>
</h2>
</EuiTitle>
}
description={
<>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionModeDescription"
defaultMessage="Use seed nodes by default, or switch to proxy mode."
/>
<EuiFormRow hasEmptyLabelSpace fullWidth>
<EuiSwitch
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldModeLabel"
defaultMessage="Use proxy mode"
/>
}
checked={mode === PROXY_MODE}
data-test-subj="remoteClusterFormConnectionModeToggle"
onChange={(e) =>
this.onFieldsChange({ mode: e.target.checked ? PROXY_MODE : SNIFF_MODE })
}
/>
</EuiFormRow>
{isCloudEnabled && mode === PROXY_MODE ? (
<>
<EuiSpacer size="s" />
<EuiCallOut
iconType="pin"
size="s"
title={
<FormattedMessage
id="xpack.remoteClusters.cloudClusterInformationTitle"
defaultMessage="Use proxy mode for Elastic Cloud deployment"
/>
}
>
<FormattedMessage
id="xpack.remoteClusters.cloudClusterInformationDescription"
defaultMessage="To find the proxy address and server name of your cluster, go to the {security} page of your deployment menu and search for {searchString}."
values={{
security: (
<strong>
<FormattedMessage
id="xpack.remoteClusters.cloudClusterSecurityDescription"
defaultMessage="Security"
/>
</strong>
),
searchString: (
<strong>
<FormattedMessage
id="xpack.remoteClusters.cloudClusterSearchDescription"
defaultMessage="Remote cluster parameters"
/>
</strong>
),
}}
/>
</EuiCallOut>
</>
) : null}
</>
}
fullWidth
>
{mode === PROXY_MODE ? this.renderProxyModeSettings() : this.renderSniffModeSettings()}
</EuiDescribedFormGroup>
);
}
renderSkipUnavailable() {
const {
fields: { skipUnavailable },
} = this.state;
return (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h2>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableTitle"
defaultMessage="Make remote cluster optional"
/>
</h2>
</EuiTitle>
}
description={
<Fragment>
<p>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableDescription"
defaultMessage="A request fails if any of the queried remote clusters are unavailable. To send requests 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, isRequestVisible } = 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 justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="remoteClusterFormSaveButton"
color="secondary"
iconType="check"
onClick={this.save}
fill
disabled={isSaveDisabled}
aria-describedby={`${this.generateId(ERROR_TITLE_ID)} ${this.generateId(
ERROR_LIST_ID
)}`}
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
{cancelButton}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={this.toggleRequest}>
{isRequestVisible ? (
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel"
defaultMessage="Hide request"
/>
) : (
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.showRequestButtonLabel"
defaultMessage="Show request"
/>
)}
</EuiButtonEmpty>
</EuiFlexItem>
</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 && Array.isArray(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="warning">
{errorBody}
</EuiCallOut>
<EuiSpacer />
</Fragment>
);
}
return null;
}
renderErrors = () => {
const {
areErrorsVisible,
fieldsErrors: { name: errorClusterName, seeds: errorsSeeds, proxyAddress: errorProxyAddress },
localSeedErrors,
} = this.state;
const hasErrors = this.hasErrors();
if (!areErrorsVisible || !hasErrors) {
return null;
}
const errorExplanations = [];
if (errorClusterName) {
errorExplanations.push({
key: 'nameExplanation',
field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage', {
defaultMessage: 'The "Name" field is invalid.',
}),
error: errorClusterName,
});
}
if (errorsSeeds) {
errorExplanations.push({
key: 'seedsExplanation',
field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage', {
defaultMessage: 'The "Seed nodes" field is invalid.',
}),
error: errorsSeeds,
});
}
if (localSeedErrors && localSeedErrors.length) {
errorExplanations.push({
key: 'localSeedExplanation',
field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage', {
defaultMessage: 'The "Seed nodes" field is invalid.',
}),
error: localSeedErrors.join(' '),
});
}
if (errorProxyAddress) {
errorExplanations.push({
key: 'seedsExplanation',
field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage', {
defaultMessage: 'The "Proxy address" field is invalid.',
}),
error: errorProxyAddress,
});
}
const messagesToBeRendered = errorExplanations.length && (
<EuiScreenReaderOnly>
<dl id={this.generateId(ERROR_LIST_ID)} aria-labelledby={this.generateId(ERROR_TITLE_ID)}>
{errorExplanations.map(({ key, field, error }) => (
<div key={key}>
<dt>{field}</dt>
<dd>{error}</dd>
</div>
))}
</dl>
</EuiScreenReaderOnly>
);
return (
<Fragment>
<EuiSpacer size="m" data-test-subj="remoteClusterFormGlobalError" />
<EuiCallOut
title={
<h3 id={this.generateId(ERROR_TITLE_ID)}>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.errorTitle"
defaultMessage="Fix errors before continuing."
/>
</h3>
}
color="danger"
iconType="cross"
/>
<EuiDelayRender>{messagesToBeRendered}</EuiDelayRender>
</Fragment>
);
};
render() {
const {
disabledFields: { name: disabledName },
} = this.props;
const {
isRequestVisible,
areErrorsVisible,
fields: { name },
fieldsErrors: { name: errorClusterName },
} = this.state;
return (
<Fragment>
{this.renderSaveErrorFeedback()}
<EuiForm data-test-subj="remoteClusterForm">
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h2>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionNameTitle"
defaultMessage="Name"
/>
</h2>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionNameDescription"
defaultMessage="A unique name for the 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.renderMode()}
{this.renderSkipUnavailable()}
</EuiForm>
{this.renderErrors()}
<EuiSpacer size="l" />
{this.renderActions()}
{this.renderSavingFeedback()}
{isRequestVisible ? (
<RequestFlyout
name={name}
cluster={this.getAllFields()}
close={() => this.setState({ isRequestVisible: false })}
/>
) : null}
</Fragment>
);
}
}

View file

@ -1,53 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mountWithIntl, renderWithIntl } from '@kbn/test/jest';
import { findTestSubject, takeMountedSnapshot } from '@elastic/eui/lib/test';
import { RemoteClusterForm } from './remote_cluster_form';
// Make sure we have deterministic aria IDs.
jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
htmlIdGenerator: (prefix = 'staticGenerator') => (suffix = 'staticId') => `${prefix}_${suffix}`,
}));
describe('RemoteClusterForm', () => {
test(`renders untouched state`, () => {
const component = renderWithIntl(<RemoteClusterForm save={() => {}} />);
expect(component).toMatchSnapshot();
});
describe('proxy mode', () => {
test('renders correct connection settings when user enables proxy mode', () => {
const component = mountWithIntl(<RemoteClusterForm save={() => {}} />);
findTestSubject(component, 'remoteClusterFormConnectionModeToggle').simulate('click');
expect(component).toMatchSnapshot();
});
});
describe('validation', () => {
test('renders invalid state and a global form error when the user tries to submit an invalid form', () => {
const component = mountWithIntl(<RemoteClusterForm save={() => {}} />);
findTestSubject(component, 'remoteClusterFormSaveButton').simulate('click');
const fieldsSnapshot = [
'remoteClusterFormNameFormRow',
'remoteClusterFormSeedNodesFormRow',
'remoteClusterFormSkipUnavailableFormRow',
'remoteClusterFormGlobalError',
].map((testSubject) => {
const mountedField = findTestSubject(component, testSubject);
return takeMountedSnapshot(mountedField);
});
expect(fieldsSnapshot).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,629 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Component, Fragment } from 'react';
import { merge } from 'lodash';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiDescribedFormGroup,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiLink,
EuiLoadingKibana,
EuiLoadingSpinner,
EuiOverlayMask,
EuiSpacer,
EuiSwitch,
EuiText,
EuiTitle,
EuiDelayRender,
EuiScreenReaderOnly,
htmlIdGenerator,
EuiSwitchEvent,
} from '@elastic/eui';
import { Cluster } from '../../../../../common/lib';
import { SNIFF_MODE, PROXY_MODE } from '../../../../../common/constants';
import { AppContext, Context } from '../../../app_context';
import { skippingDisconnectedClustersUrl } from '../../../services/documentation';
import { RequestFlyout } from './request_flyout';
import { ConnectionMode } from './components';
import {
ClusterErrors,
convertCloudUrlToProxyConnection,
convertProxyConnectionToCloudUrl,
validateCluster,
} from './validators';
import { isCloudUrlEnabled } from './validators/validate_cloud_url';
const defaultClusterValues: Cluster = {
name: '',
seeds: [],
skipUnavailable: false,
nodeConnections: 3,
proxyAddress: '',
proxySocketConnections: 18,
serverName: '',
};
const ERROR_TITLE_ID = 'removeClustersErrorTitle';
const ERROR_LIST_ID = 'removeClustersErrorList';
interface Props {
save: (cluster: Cluster) => void;
cancel?: () => void;
isSaving?: boolean;
saveError?: any;
cluster?: Cluster;
}
export type FormFields = Cluster & { cloudUrl: string; cloudUrlEnabled: boolean };
interface State {
fields: FormFields;
fieldsErrors: ClusterErrors;
areErrorsVisible: boolean;
isRequestVisible: boolean;
}
export class RemoteClusterForm extends Component<Props, State> {
static defaultProps = {
fields: merge({}, defaultClusterValues),
};
static contextType = AppContext;
private readonly generateId: (idSuffix?: string) => string;
constructor(props: Props, context: Context) {
super(props, context);
const { cluster } = props;
const { isCloudEnabled } = context;
// Connection mode should default to "proxy" in cloud
const defaultMode = isCloudEnabled ? PROXY_MODE : SNIFF_MODE;
const fieldsState: FormFields = merge(
{},
{
...defaultClusterValues,
mode: defaultMode,
cloudUrl: convertProxyConnectionToCloudUrl(cluster),
cloudUrlEnabled: isCloudEnabled && isCloudUrlEnabled(cluster),
},
cluster
);
this.generateId = htmlIdGenerator();
this.state = {
fields: fieldsState,
fieldsErrors: validateCluster(fieldsState, isCloudEnabled),
areErrorsVisible: false,
isRequestVisible: false,
};
}
toggleRequest = () => {
this.setState(({ isRequestVisible }) => ({
isRequestVisible: !isRequestVisible,
}));
};
onFieldsChange = (changedFields: Partial<FormFields>) => {
const { isCloudEnabled } = this.context;
// when cloudUrl changes, fill proxy address and server name
const { cloudUrl } = changedFields;
if (cloudUrl) {
const { proxyAddress, serverName } = convertCloudUrlToProxyConnection(cloudUrl);
changedFields = {
...changedFields,
proxyAddress,
serverName,
};
}
this.setState(({ fields: prevFields }) => {
const newFields = {
...prevFields,
...changedFields,
};
return {
fields: newFields,
fieldsErrors: validateCluster(newFields, isCloudEnabled),
};
});
};
getCluster(): Cluster {
const {
fields: {
name,
mode,
seeds,
nodeConnections,
proxyAddress,
proxySocketConnections,
serverName,
skipUnavailable,
},
} = this.state;
const { cluster } = this.props;
let modeSettings;
if (mode === PROXY_MODE) {
modeSettings = {
proxyAddress,
proxySocketConnections,
serverName,
};
} else {
modeSettings = {
seeds,
nodeConnections,
};
}
return {
name,
skipUnavailable,
mode,
hasDeprecatedProxySetting: cluster?.hasDeprecatedProxySetting,
...modeSettings,
};
}
save = () => {
const { save } = this.props;
if (this.hasErrors()) {
this.setState({
areErrorsVisible: true,
});
return;
}
const cluster = this.getCluster();
save(cluster);
};
onSkipUnavailableChange = (e: EuiSwitchEvent) => {
const skipUnavailable = e.target.checked;
this.onFieldsChange({ skipUnavailable });
};
resetToDefault = (fieldName: keyof Cluster) => {
this.onFieldsChange({
[fieldName]: defaultClusterValues[fieldName],
});
};
hasErrors = () => {
const { fieldsErrors } = this.state;
const errorValues = Object.values(fieldsErrors);
return errorValues.some((error) => error != null);
};
renderSkipUnavailable() {
const {
fields: { skipUnavailable },
} = this.state;
return (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h2>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableTitle"
defaultMessage="Make remote cluster optional"
/>
</h2>
</EuiTitle>
}
description={
<Fragment>
<p>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionSkipUnavailableDescription"
defaultMessage="If any of the remote clusters are unavailable, the query request fails. To avoid this and continue to send requests to other clusters, 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 !== defaultClusterValues.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, isRequestVisible } = 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 justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="remoteClusterFormSaveButton"
color="secondary"
iconType="check"
onClick={this.save}
fill
isDisabled={isSaveDisabled}
aria-describedby={`${this.generateId(ERROR_TITLE_ID)} ${this.generateId(
ERROR_LIST_ID
)}`}
>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.saveButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
{cancelButton}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={this.toggleRequest}>
{isRequestVisible ? (
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel"
defaultMessage="Hide request"
/>
) : (
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.showRequestButtonLabel"
defaultMessage="Show request"
/>
)}
</EuiButtonEmpty>
</EuiFlexItem>
</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 && Array.isArray(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} iconType="cross" color="warning">
{errorBody}
</EuiCallOut>
<EuiSpacer />
</Fragment>
);
}
return null;
}
renderErrors = () => {
const {
areErrorsVisible,
fieldsErrors: {
name: errorClusterName,
seeds: errorsSeeds,
proxyAddress: errorProxyAddress,
serverName: errorServerName,
cloudUrl: errorCloudUrl,
},
} = this.state;
const hasErrors = this.hasErrors();
if (!areErrorsVisible || !hasErrors) {
return null;
}
const errorExplanations = [];
if (errorClusterName) {
errorExplanations.push({
key: 'nameExplanation',
field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage', {
defaultMessage: 'The "Name" field is invalid.',
}),
error: errorClusterName,
});
}
if (errorsSeeds) {
errorExplanations.push({
key: 'seedsExplanation',
field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage', {
defaultMessage: 'The "Seed nodes" field is invalid.',
}),
error: errorsSeeds,
});
}
if (errorProxyAddress) {
errorExplanations.push({
key: 'proxyAddressExplanation',
field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage', {
defaultMessage: 'The "Proxy address" field is invalid.',
}),
error: errorProxyAddress,
});
}
if (errorServerName) {
errorExplanations.push({
key: 'serverNameExplanation',
field: i18n.translate(
'xpack.remoteClusters.remoteClusterForm.inputServerNameErrorMessage',
{
defaultMessage: 'The "Server name" field is invalid.',
}
),
error: errorServerName,
});
}
if (errorCloudUrl) {
errorExplanations.push({
key: 'cloudUrlExplanation',
field: i18n.translate('xpack.remoteClusters.remoteClusterForm.inputcloudUrlErrorMessage', {
defaultMessage: 'The "Elasticsearch endpoint URL" field is invalid.',
}),
error: errorCloudUrl,
});
}
const messagesToBeRendered = errorExplanations.length && (
<EuiScreenReaderOnly>
<dl id={this.generateId(ERROR_LIST_ID)} aria-labelledby={this.generateId(ERROR_TITLE_ID)}>
{errorExplanations.map(({ key, field, error }) => (
<div key={key}>
<dt>{field}</dt>
<dd>{error}</dd>
</div>
))}
</dl>
</EuiScreenReaderOnly>
);
return (
<Fragment>
<EuiSpacer size="m" data-test-subj="remoteClusterFormGlobalError" />
<EuiCallOut
title={
<h3 id={this.generateId(ERROR_TITLE_ID)}>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.errorTitle"
defaultMessage="Fix errors before continuing."
/>
</h3>
}
color="danger"
iconType="cross"
/>
<EuiDelayRender>{messagesToBeRendered}</EuiDelayRender>
</Fragment>
);
};
render() {
const { isRequestVisible, areErrorsVisible, fields, fieldsErrors } = this.state;
const { name: errorClusterName } = fieldsErrors;
const { cluster } = this.props;
const isNew = !cluster;
return (
<Fragment>
{this.renderSaveErrorFeedback()}
<EuiForm data-test-subj="remoteClusterForm">
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h2>
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionNameTitle"
defaultMessage="Name"
/>
</h2>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.sectionNameDescription"
defaultMessage="A unique name for the cluster."
/>
}
fullWidth
>
<EuiFormRow
data-test-subj="remoteClusterFormNameFormRow"
label={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNameLabel"
defaultMessage="Name"
/>
}
helpText={
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.fieldNameLabelHelpText"
defaultMessage="Must contain only letters, numbers, underscores, and dashes."
/>
}
error={errorClusterName}
isInvalid={Boolean(areErrorsVisible && errorClusterName)}
fullWidth
>
<EuiFieldText
isInvalid={Boolean(areErrorsVisible && errorClusterName)}
value={fields.name}
onChange={(e) => this.onFieldsChange({ name: e.target.value })}
fullWidth
disabled={!isNew}
data-test-subj="remoteClusterFormNameInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
<ConnectionMode
fields={fields}
fieldsErrors={fieldsErrors}
onFieldsChange={this.onFieldsChange}
areErrorsVisible={areErrorsVisible}
/>
{this.renderSkipUnavailable()}
</EuiForm>
{this.renderErrors()}
<EuiSpacer size="l" />
{this.renderActions()}
{this.renderSavingFeedback()}
{isRequestVisible ? (
<RequestFlyout
cluster={this.getCluster()}
close={() => this.setState({ isRequestVisible: false })}
/>
) : null}
</Fragment>
);
}
}

View file

@ -24,13 +24,13 @@ import { Cluster, serializeCluster } from '../../../../../common/lib';
interface Props {
close: () => void;
name: string;
cluster: Cluster;
}
export class RequestFlyout extends PureComponent<Props> {
render() {
const { name, close, cluster } = this.props;
const { close, cluster } = this.props;
const { name } = cluster;
const endpoint = 'PUT _cluster/settings';
const payload = JSON.stringify(serializeCluster(cluster), null, 2);
const request = `${endpoint}\n${payload}`;

View file

@ -10,3 +10,10 @@ export { validateProxy } from './validate_proxy';
export { validateSeeds } from './validate_seeds';
export { validateSeed } from './validate_seed';
export { validateServerName } from './validate_server_name';
export { validateCluster, ClusterErrors } from './validate_cluster';
export {
isCloudUrlEnabled,
validateCloudUrl,
convertProxyConnectionToCloudUrl,
convertCloudUrlToProxyConnection,
} from './validate_cloud_url';

View file

@ -0,0 +1,128 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
isCloudUrlEnabled,
validateCloudUrl,
convertCloudUrlToProxyConnection,
convertProxyConnectionToCloudUrl,
i18nTexts,
} from './validate_cloud_url';
describe('Cloud url', () => {
describe('validation', () => {
it('errors when the url is empty', () => {
const actual = validateCloudUrl('');
expect(actual).toBe(i18nTexts.urlEmpty);
});
it('errors when the url is invalid', () => {
const actual = validateCloudUrl('invalid%url');
expect(actual).toBe(i18nTexts.urlInvalid);
});
});
describe('is cloud url', () => {
it('true for a new cluster', () => {
const actual = isCloudUrlEnabled();
expect(actual).toBe(true);
});
it('true when proxy connection is empty', () => {
const actual = isCloudUrlEnabled({ name: 'test', proxyAddress: '', serverName: '' });
expect(actual).toBe(true);
});
it('true when proxy address is the same as server name and default port', () => {
const actual = isCloudUrlEnabled({
name: 'test',
proxyAddress: 'some-proxy:9400',
serverName: 'some-proxy',
});
expect(actual).toBe(true);
});
it('false when proxy address is the same as server name but not default port', () => {
const actual = isCloudUrlEnabled({
name: 'test',
proxyAddress: 'some-proxy:1234',
serverName: 'some-proxy',
});
expect(actual).toBe(false);
});
it('true when proxy address is not the same as server name', () => {
const actual = isCloudUrlEnabled({
name: 'test',
proxyAddress: 'some-proxy:9400',
serverName: 'some-server-name',
});
expect(actual).toBe(false);
});
});
describe('conversion from cloud url', () => {
it('empty url to empty proxy connection values', () => {
const actual = convertCloudUrlToProxyConnection('');
expect(actual).toEqual({ proxyAddress: '', serverName: '' });
});
it('url with protocol and port to proxy connection values', () => {
const actual = convertCloudUrlToProxyConnection('http://test.com:1234');
expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' });
});
it('url with protocol and no port to proxy connection values', () => {
const actual = convertCloudUrlToProxyConnection('http://test.com');
expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' });
});
it('url with no protocol to proxy connection values', () => {
const actual = convertCloudUrlToProxyConnection('test.com');
expect(actual).toEqual({ proxyAddress: 'test.com:9400', serverName: 'test.com' });
});
it('invalid url to empty proxy connection values', () => {
const actual = convertCloudUrlToProxyConnection('invalid%url');
expect(actual).toEqual({ proxyAddress: '', serverName: '' });
});
});
describe('conversion to cloud url', () => {
it('empty proxy address to empty cloud url', () => {
const actual = convertProxyConnectionToCloudUrl({
name: 'test',
proxyAddress: '',
serverName: 'test',
});
expect(actual).toEqual('');
});
it('empty server name to empty cloud url', () => {
const actual = convertProxyConnectionToCloudUrl({
name: 'test',
proxyAddress: 'test',
serverName: '',
});
expect(actual).toEqual('');
});
it('different proxy address and server name to empty cloud url', () => {
const actual = convertProxyConnectionToCloudUrl({
name: 'test',
proxyAddress: 'test',
serverName: 'another-test',
});
expect(actual).toEqual('');
});
it('valid proxy connection to cloud url', () => {
const actual = convertProxyConnectionToCloudUrl({
name: 'test',
proxyAddress: 'test-proxy:9400',
serverName: 'test-proxy',
});
expect(actual).toEqual('test-proxy');
});
});
});

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { Cluster } from '../../../../../../common/lib';
import { isAddressValid } from './validate_address';
export const i18nTexts = {
urlEmpty: (
<FormattedMessage
id="xpack.remoteClusters.cloudDeploymentForm.urlRequiredError"
defaultMessage="A url is required."
/>
),
urlInvalid: (
<FormattedMessage
id="xpack.remoteClusters.cloudDeploymentForm.urlInvalidError"
defaultMessage="Url is invalid"
/>
),
};
const CLOUD_DEFAULT_PROXY_PORT = '9400';
const EMPTY_PROXY_VALUES = { proxyAddress: '', serverName: '' };
const PROTOCOL_REGEX = new RegExp(/^https?:\/\//);
export const isCloudUrlEnabled = (cluster?: Cluster): boolean => {
// enable cloud url for new clusters
if (!cluster) {
return true;
}
const { proxyAddress, serverName } = cluster;
if (!proxyAddress && !serverName) {
return true;
}
const portParts = (proxyAddress ?? '').split(':');
const proxyAddressWithoutPort = portParts[0];
const port = portParts[1];
return port === CLOUD_DEFAULT_PROXY_PORT && proxyAddressWithoutPort === serverName;
};
const formatUrl = (url: string) => {
url = (url ?? '').trim().toLowerCase();
// delete http(s):// protocol string if any
url = url.replace(PROTOCOL_REGEX, '');
return url;
};
export const convertProxyConnectionToCloudUrl = (cluster?: Cluster): string => {
if (!isCloudUrlEnabled(cluster)) {
return '';
}
return cluster?.serverName ?? '';
};
export const convertCloudUrlToProxyConnection = (
cloudUrl: string = ''
): { proxyAddress: string; serverName: string } => {
cloudUrl = formatUrl(cloudUrl);
if (!cloudUrl || !isAddressValid(cloudUrl)) {
return EMPTY_PROXY_VALUES;
}
const address = cloudUrl.split(':')[0];
return { proxyAddress: `${address}:${CLOUD_DEFAULT_PROXY_PORT}`, serverName: address };
};
export const validateCloudUrl = (cloudUrl: string): JSX.Element | null => {
if (!cloudUrl) {
return i18nTexts.urlEmpty;
}
cloudUrl = formatUrl(cloudUrl);
if (!isAddressValid(cloudUrl)) {
return i18nTexts.urlInvalid;
}
return null;
};

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { validateName } from './validate_name';
import { PROXY_MODE, SNIFF_MODE } from '../../../../../../common/constants';
import { validateSeeds } from './validate_seeds';
import { validateProxy } from './validate_proxy';
import { validateServerName } from './validate_server_name';
import { validateCloudUrl } from './validate_cloud_url';
import { FormFields } from '../remote_cluster_form';
type ClusterError = JSX.Element | null;
export interface ClusterErrors {
name?: ClusterError;
seeds?: ClusterError;
proxyAddress?: ClusterError;
serverName?: ClusterError;
cloudUrl?: ClusterError;
}
export const validateCluster = (fields: FormFields, isCloudEnabled: boolean): ClusterErrors => {
const { name, seeds = [], mode, proxyAddress, serverName, cloudUrlEnabled, cloudUrl } = fields;
return {
name: validateName(name),
seeds: mode === SNIFF_MODE ? validateSeeds(seeds) : null,
proxyAddress: !cloudUrlEnabled && mode === PROXY_MODE ? validateProxy(proxyAddress) : null,
// server name is only required in cloud when proxy mode is enabled
serverName:
!cloudUrlEnabled && isCloudEnabled && mode === PROXY_MODE
? validateServerName(serverName)
: null,
cloudUrl: cloudUrlEnabled ? validateCloudUrl(cloudUrl) : null,
};
};

View file

@ -1,43 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { isAddressValid, isPortValid } from './validate_address';
export function validateSeed(seed?: string): string[] {
const errors: string[] = [];
if (!seed) {
return errors;
}
const isValid = isAddressValid(seed);
if (!isValid) {
errors.push(
i18n.translate(
'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage',
{
defaultMessage:
'Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. ' +
'Hosts can only consist of letters, numbers, and dashes.',
}
)
);
}
if (!isPortValid(seed)) {
errors.push(
i18n.translate('xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage', {
defaultMessage: 'A port is required.',
})
);
}
return errors;
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { isAddressValid, isPortValid } from './validate_address';
export function validateSeed(seed?: string): JSX.Element[] {
const errors: JSX.Element[] = [];
if (!seed) {
return errors;
}
const isValid = isAddressValid(seed);
if (!isValid) {
errors.push(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage"
defaultMessage="Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. Hosts can only consist of letters, numbers, and dashes."
/>
);
}
if (!isPortValid(seed)) {
errors.push(
<FormattedMessage
id="xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage"
defaultMessage="A port is required."
/>
);
}
return errors;
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ComponentType } from 'react';
export declare const RemoteClusterEdit: ComponentType;
export declare const RemoteClusterAdd: ComponentType;
export declare const RemoteClusterList: ComponentType;

View file

@ -73,7 +73,7 @@ export class RemoteClusterAdd extends PureComponent {
description={
<FormattedMessage
id="xpack.remoteClusters.remoteClustersDescription"
defaultMessage="Add a remote cluster that connects to seed nodes or a single proxy address."
defaultMessage="Add a remote cluster that connects to seed nodes or to a single proxy address."
/>
}
/>

View file

@ -27,10 +27,6 @@ import { getRouter, 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,
@ -202,8 +198,7 @@ export class RemoteClusterEdit extends Component {
</>
) : null}
<RemoteClusterForm
fields={cluster}
disabledFields={disabledFields}
cluster={cluster}
isSaving={isEditingCluster}
saveError={getEditClusterError}
save={this.save}

View file

@ -10,7 +10,7 @@ import { trackUserRequest } from './ui_metric';
import { sendGet, sendPost, sendPut, sendDelete, SendGetOptions } from './http';
import { Cluster } from '../../../common/lib';
export async function loadClusters(options: SendGetOptions) {
export async function loadClusters(options?: SendGetOptions) {
return await sendGet(undefined, options);
}

View file

@ -14,9 +14,9 @@ export let proxyModeUrl: string;
export let proxySettingsUrl: string;
export function init({ links }: DocLinksStart): void {
skippingDisconnectedClustersUrl = `${links.ccs.skippingDisconnectedClusters}`;
remoteClustersUrl = `${links.elasticsearch.remoteClusters}`;
transportPortUrl = `${links.elasticsearch.transportSettings}`;
proxyModeUrl = `${links.elasticsearch.remoteClustersProxy}`;
proxySettingsUrl = `${links.elasticsearch.remoteClusersProxySettings}`;
skippingDisconnectedClustersUrl = links.ccs.skippingDisconnectedClusters;
remoteClustersUrl = links.elasticsearch.remoteClusters;
transportPortUrl = links.elasticsearch.transportSettings;
proxyModeUrl = links.elasticsearch.remoteClustersProxy;
proxySettingsUrl = links.elasticsearch.remoteClusersProxySettings;
}

View file

@ -13,6 +13,12 @@ export { setBreadcrumbs } from './breadcrumb';
export { redirect } from './redirect';
export { setUserHasLeftApp, getUserHasLeftApp, registerRouter, getRouter } from './routing';
export {
setUserHasLeftApp,
getUserHasLeftApp,
registerRouter,
getRouter,
AppRouter,
} from './routing';
export { trackUiMetric, METRIC_TYPE } from './ui_metric';

View file

@ -21,7 +21,7 @@ export function getUserHasLeftApp() {
return _userHasLeftApp;
}
interface AppRouter {
export interface AppRouter {
history: ScopedHistory;
route: { location: ScopedHistory['location'] };
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Store } from 'redux';
export declare const remoteClustersStore: Store;
export declare const createRemoteClustersStore: () => Store;

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View file

@ -60,13 +60,14 @@ export class RemoteClustersUIPlugin
initNotification(toasts, fatalErrors);
initHttp(http);
const isCloudEnabled = Boolean(cloud?.isCloudEnabled);
const isCloudEnabled: boolean = Boolean(cloud?.isCloudEnabled);
const cloudBaseUrl: string = cloud?.baseUrl ?? '';
const { renderApp } = await import('./application');
const unmountAppCallback = await renderApp(
element,
i18nContext,
{ isCloudEnabled },
{ isCloudEnabled, cloudBaseUrl },
history
);

View file

@ -8,10 +8,12 @@
"declarationMap": true
},
"include": [
"__jest__/**/*",
"common/**/*",
"fixtures/**/*",
"public/**/*",
"server/**/*",
"../../../typings/**/*",
],
"references": [
{ "path": "../../../src/core/tsconfig.json" },

View file

@ -16761,10 +16761,6 @@
"xpack.remoteClusters.addTitle": "リモートクラスターを追加",
"xpack.remoteClusters.appName": "リモートクラスター",
"xpack.remoteClusters.appTitle": "リモートクラスター",
"xpack.remoteClusters.cloudClusterInformationDescription": "クラスターのプロキシアドレスとサーバー名を見つけるには、デプロイメニューの{security}ページに移動し、{searchString}を検索します。",
"xpack.remoteClusters.cloudClusterInformationTitle": "Elasticsearch Cloudデプロイのプロキシモードを使用",
"xpack.remoteClusters.cloudClusterSearchDescription": "リモートクラスターパラメーター",
"xpack.remoteClusters.cloudClusterSecurityDescription": "セキュリティ",
"xpack.remoteClusters.configuredByNodeWarningTitle": "このリモートクラスターはノードの elasticsearch.yml 構成ファイルで定義されているため、編集または削除できません。",
"xpack.remoteClusters.connectedStatus.connectedAriaLabel": "接続済み",
"xpack.remoteClusters.connectedStatus.notConnectedAriaLabel": "未接続",
@ -16838,7 +16834,6 @@
"xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "サーバー名",
"xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "リモートクラスターごとに開くソケット接続の数。",
"xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "リクエストを非表示",
"xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage": "「シードノード」フィールドが無効です。",
"xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "「名前」フィールドが無効です。",
"xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "「プロキシアドレス」フィールドが無効です。",
"xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "「シードノード」フィールドが無効です。",

View file

@ -16987,10 +16987,6 @@
"xpack.remoteClusters.addTitle": "添加远程集群",
"xpack.remoteClusters.appName": "远程集群",
"xpack.remoteClusters.appTitle": "远程集群",
"xpack.remoteClusters.cloudClusterInformationDescription": "要查找您的集群的代理地址和服务器名称,请前往部署菜单的{security}页面并搜索“{searchString}”。",
"xpack.remoteClusters.cloudClusterInformationTitle": "将代理模式用于 Elastic Cloud 部署",
"xpack.remoteClusters.cloudClusterSearchDescription": "远程集群参数",
"xpack.remoteClusters.cloudClusterSecurityDescription": "安全",
"xpack.remoteClusters.configuredByNodeWarningTitle": "您无法编辑或删除此远程集群,因为它是在节点的 elasticsearch.yml 配置文件中定义的。",
"xpack.remoteClusters.connectedStatus.connectedAriaLabel": "已连接",
"xpack.remoteClusters.connectedStatus.notConnectedAriaLabel": "未连接",
@ -17065,7 +17061,6 @@
"xpack.remoteClusters.remoteClusterForm.fieldServerNameRequiredLabel": "服务器名",
"xpack.remoteClusters.remoteClusterForm.fieldSocketConnectionsHelpText": "每个远程集群要打开的套接字数目。",
"xpack.remoteClusters.remoteClusterForm.hideRequestButtonLabel": "隐藏请求",
"xpack.remoteClusters.remoteClusterForm.inputLocalSeedErrorMessage": "“种子节点”字段无效。",
"xpack.remoteClusters.remoteClusterForm.inputNameErrorMessage": "“名称”字段无效。",
"xpack.remoteClusters.remoteClusterForm.inputProxyErrorMessage": "“代理地址”字段无效。",
"xpack.remoteClusters.remoteClusterForm.inputSeedsErrorMessage": "“种子节点”字段无效。",