From 30989fbca7fbbe492467270e72eedc1dda7be396 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Tue, 5 Feb 2019 14:01:17 -0800 Subject: [PATCH] [CCR] Follower index CRUD (#27936) * [CCR] Refactor redux for Auto-follow pattern detail panel (#27491) * [CCR] Refactor redux for Auto-follow pattern detail panel * [CCR] Small refactor * [CCR] Change to present tense * [CCR] Display auto-follow pattern name even if it does not exist * [CCR] Use href to edit auto-follow pattern + remove middelware to update "pattern" query params * [CCR] Fix navigation back bug + set 2 ids for detail and edit an auto-follow pattern * [CCR] Replace api middleware with redux-thunk action * [CCR] Show detail footer close button even when cluster is not valid * [CCR] Add endpoints for fetching and creating follower indices (#27646) * Add GET /follower_indices endpoint with deserialization logic and tests. * Add POST /follower_indices endpoint with serialization logic and tests. * [CCR] Add unit tests for RemoteClusterForm, RemoteClusterList, and RemoteClusterTable (#27647) * Use componentDidUpdate instead of getDerivedStateFromProps. * Add unit tests for RemoteClusterForm, RemoteClusterList, and RemoteClusterTable. * Add jest mock for eui `makeId()` utility and get deterministic aria IDs for snapshots * Update snapshot for Remote Cluster list test * [CCR] Follower indices table and detail panel (#27804) * Store for follower indices * Initial work for follower indices table and detail panel * Fix load auto-follow stats load as middleware was removed * [CCR] Create follower index UI form (#27864) * Initial setup Follower Index form * Working form without client validation * Add client side validation for follower index * Add client validation to check if index already exist * Improve error message when leader index does not exist * Remove update method for follower index * Clear api error on field change * Fix i18n error * Update snapshots * [CCR] Add pause, resume, and unfollow actions for follower indices (#28305) * Add pause and resume follower index routes * Add unfollow route * Add api methods for new routes * Adjust routes to have bulk capabilities, add corresponding actions * Refresh list after pausing/resuming, remove items after unfollowing * First pass at UI for pause and unfollow (and resume, but that is not visible due to ES stats response) * Handle additional conditions needed for unfollowing leader index, add placeholder code to deduce paused status * PR feedback * [CCR] Advanced settings UI for follower indices (#28267) * Add client side validation of advanced settings form * Move form entry row to separate component * Add server side serialization for advanced settings * Ignore advanced settings input when that section is hidden. - Cache and restore input when the section is shown again. * [CCR] Show remote cluster validation in CCR forms, and add edit follower index (#28778) * [CCR] Advanced settings component * Remove preview active on toggle settings * Add client side validation of advanced settings form * Move form entry row to separate component * Add title to panel * Add i18n translation of advanced settings * Update Follower index form with toggle for advanced settings * Add server side serialisation for advanced settings * Make code review changes * Fix test: mock constant dependency * Add section to edit follower index * Show confirm modal before updating follower index * Add edit icon in table + update server endpoint to pause / resume * [CCR] Show remote cluster validation in follower index form & auto-follow pattern form * PR feedback, cleanup form sizes, add redirect to edit remote cluster * Fix routing, remove unused code, adjust auto follow pattern edit loading error page * Adjust error messages and make remote cluster not found edit page the same * Fix functionality as result of merge * Fix validation, reorder actions, fix tests, and address feedback * PR feedback and fix validation pt 2 * Adjust remote cluster validation * Fix i18n * Fix api error not showing on add follower form * [CCR] Integrate new follower index info ES API (#29153) * Integrate new follower index info ES API * Collate data from follower stats and info apis when retrieving all followers and single follower * Add follower settings info to detail panel * Add paused/active UI state * Surface follower default settings to UI * Adjust tests * Address PR feedback * Update snapshots * [CCR] Surface license errors in-app and refine permissions error UI. (#29228) * Fix camelcasing bug in XPackInfo. * Silently swallow API error when checking for index name availability. * Fix typo in followerIndexForm fatal error. * Add permissions check before allowing user to access the app. * Refine wording of CCR permission denied page, to specify cluster privileges. (#29533) * [CCR] Improve form error handling and general UX (#29419) * Remove unnecessary eslint disable-line * [CCR] Implement Advanced Settings design feedback (#29543) * Use EuiSwitch to toggle advanced settings in Create Follower Index form. * Move 'optional' from each Advanced Setting field to the section heading. * Change Advanced Settings switch label and description to emphasize that you can customize them or use the defaults. * Prepopulate Advanced Settings fields with default values. * When editing a follower index, check if advanced settings have been edited and open them if so. * Add 'Reset to default' button below advanced settings fields if their values are different than the default. * Remove 'Default' copy from Advanced Settings descriptions. * Simplify toggleAdvancedSettings function, add comments, and fix React console error. * [CCR] Follower index list fixes from design feedback (#29484) * Delete remote cluster settings before updating * Fix detail panel z-index * Remove default descriptor from follower index detail panel setting values * Follower index confirm action copy adjustments * Change z-index styling to use EUI vars * [CCR] Improve remote clusters test coverage (#29487) * Add Jest test for RemoteClusterForm validation state. * Extract validation functions out of RemoteClusterForm and add unit tests. - Return null instead of undefined from validators. * Add unit tests for different types of remote clusters in RemoteClusterTable. * Add unit test for RemoteClusterList empty prompt. * Add tests verifying behavior for row link, row delete button, and detail panel delete button. - Use getRouterLinkProps to assign onClick and href to edit buttons in row and detail panel. * [CCR] Adjust spacing around descriptions in list views, link to transport port docs, etc (#29789) * Adjust spacing around description around descriptions in list views so that it's even on top and bottom. * Add link to transport port docs from Remote Cluster form. * Move 'View in Index Management' link from the detail panel body into the footer. * Re-order follower index form sections: remote cluster, leader index, follower index. (#29885) * Fix deep-linking to follower index after creating/updating it. (#29865) * [CCR] Copy edits (#29676) * Use 'Resume/pause data replication' in context menu and row actions. * Update copy of 'Update' confirm modal for a paused follower index. * Update copy of 'Update' confirm modal for an active follower index. * Update copy of 'Pause data replication' confirm modal. * Update copy of 'Resume data replication' confirm modal. * Update copy for permissions check. * Update copy of table empty state. * Update copy around tables. * Update form copy. * Update copy for RemoteClustersFormField callouts. * Convert 'data replication' -> 'replication'. * Update copy for Unfollow confirm modal. * Update copy for form API error and Auto-follow Patterns table. * Update form save button labels to be 'Create' and 'Update'. * Move API errors to bottom of form, into same position as sync validation errors. Remove spacer from SectionError implementation. * [CCR] Open index after unfollowing leader (#29887) * Open index after unfollowing leader, fix some variable names * Fix typo * Add comment * [CCR] IE and Screen reader accesibility (#29731) * Fix api endpoit for auto-follow stats * Prevent letter wrapping in IE for the Remote cluster "connection" table column * Move inline style to CSS class to fix IE flex bug * [CCR] Add callout to paused follower index detail panel (#30013) * Add callout to paused follower index detail panel * Update copy * Skip call to ccr stats api if follower index is paused (#30027) * [CCR] Add integration tests for follower indices (#30064) * [CCR] Add integration tests for follower indices * Import advanced settings value from app constants --- src/ui/public/indices/constants/index.js | 7 +- .../common/constants/base_path.js | 1 + .../common/constants/index.js | 1 + .../common/constants/settings.js | 18 + .../common/services/utils.js | 12 + .../fixtures/follower_index.js | 159 ++++ .../fixtures/index.js | 7 + .../public/app/_app.scss | 7 + .../public/app/app.js | 236 +++++- .../components/auto_follow_pattern_form.js | 145 ++-- .../auto_follow_pattern_page_title.js | 2 +- .../follower_index_form.test.js.snap | 19 + .../advanced_settings_fields.js | 251 ++++++ .../follower_index_form.js | 719 ++++++++++++++++++ .../follower_index_form.test.js | 32 + .../follower_index_form}/index.js | 3 +- .../components/follower_index_page_title.js | 55 ++ .../follower_index_pause_provider.js | 145 ++++ .../follower_index_resume_provider.js | 154 ++++ .../follower_index_unfollow_provider.js | 137 ++++ .../public/app/components/form_entry_row.js | 150 ++++ .../public/app/components/index.js | 6 + .../components/remote_clusters_form_field.js | 321 ++++++++ .../public/app/components/section_error.js | 35 +- .../app/components/section_unauthorized.js | 9 +- .../public/app/constants/sections.js | 2 +- .../auto_follow_pattern_add.container.js | 4 +- .../auto_follow_pattern_add.js | 106 +-- .../auto_follow_pattern_edit.container.js | 28 +- .../auto_follow_pattern_edit.js | 215 +++--- .../follower_index_add.container.js | 29 + .../follower_index_add/follower_index_add.js | 94 +++ .../app/sections/follower_index_add/index.js | 7 + .../follower_index_edit.container.js | 52 ++ .../follower_index_edit.js | 275 +++++++ .../app/sections/follower_index_edit/index.js | 7 + .../auto_follow_pattern_list.container.js | 14 +- .../auto_follow_pattern_list.js | 196 +++-- .../auto_follow_pattern_table.container.js | 10 +- .../auto_follow_pattern_table.js | 45 +- .../detail_panel/detail_panel.container.js | 42 +- .../components/detail_panel/detail_panel.js | 95 +-- .../components/context_menu/context_menu.js | 178 +++++ .../components/context_menu/index.js | 7 + .../detail_panel/detail_panel.container.js | 23 + .../components/detail_panel/detail_panel.js | 522 +++++++++++++ .../components/detail_panel/index.js | 7 + .../follower_indices_table.container.js | 27 + .../follower_indices_table.js | 292 +++++++ .../follower_indices_table/index.js | 7 + .../follower_indices_list/components/index.js | 8 + .../follower_indices_list.container.js | 40 + .../follower_indices_list.js | 251 ++++++ .../home/follower_indices_list/index.js | 7 + .../app/sections/home/home.container.js | 6 +- .../public/app/sections/home/home.js | 149 ++-- .../public/app/sections/index.js | 2 + .../public/app/services/api.js | 70 +- .../app/services/documentation_links.js | 3 + .../follower_index_default_settings.js | 23 + .../app/services/get_remote_cluster_name.js | 22 + .../public/app/services/input_validation.js | 86 +++ .../public/app/services/license.js | 15 + .../public/app/services/routing.js | 22 +- .../public/app/store/action_types.js | 14 +- .../public/app/store/actions/api.js | 38 +- .../app/store/actions/auto_follow_pattern.js | 53 +- .../app/store/actions/follower_index.js | 251 ++++++ .../public/app/store/actions/index.js | 2 +- .../public/app/store/middleware/api.js | 37 - .../store/middleware/auto_follow_pattern.js | 40 - .../public/app/store/reducers/api.js | 4 +- .../public/app/store/reducers/api.test.js | 11 + .../app/store/reducers/auto_follow_pattern.js | 12 +- .../app/store/reducers/follower_index.js | 45 ++ .../public/app/store/reducers/index.js | 2 + .../public/app/store/selectors/index.js | 46 +- .../public/app/store/store.js | 4 +- .../cross_cluster_replication/public/index.js | 1 - .../public/register_ccr_section.js | 21 - .../public/register_routes.js | 48 +- .../server/client/elasticsearch_ccr.js | 96 +++ .../follower_index_serialization.test.js.snap | 32 +- .../server/lib/check_license/check_license.js | 12 +- .../lib/follower_index_serialization.js | 70 +- .../lib/follower_index_serialization.test.js | 78 +- .../server/routes/api/auto_follow_pattern.js | 10 +- .../server/routes/api/ccr.js | 76 +- .../server/routes/api/follower_index.js | 311 ++++++++ .../server/routes/api/follower_index.test.js | 294 +++++++ .../server/routes/register_routes.js | 2 + .../plugins/remote_clusters/public/index.scss | 9 +- .../remote_cluster_form.test.js.snap | 539 +++++++++++++ .../remote_cluster_form.js | 100 +-- .../remote_cluster_form.test.js | 46 ++ .../__snapshots__/validate_name.test.js.snap | 161 ++++ .../__snapshots__/validate_seeds.test.js.snap | 9 + .../remote_cluster_form/validators/index.js | 9 + .../validators/validate_name.js | 30 + .../validators/validate_name.test.js | 25 + .../validators/validate_seed.js | 45 ++ .../validators/validate_seed.test.js | 24 + .../validators/validate_seeds.js | 30 + .../validators/validate_seeds.test.js | 21 + .../remote_cluster_add/remote_cluster_add.js | 61 +- .../remote_cluster_edit.js | 112 +-- .../components/connection_status/_index.scss | 6 + .../connection_status/connection_status.js | 2 +- .../remove_cluster_button_provider.js | 1 + .../__snapshots__/detail_panel.test.js.snap | 306 ++++++++ .../detail_panel/detail_panel.js | 73 +- .../detail_panel/detail_panel.test.js | 68 ++ .../remote_cluster_list.js | 180 +++-- .../remote_cluster_list.test.js | 52 ++ .../remote_cluster_table.test.js.snap | 470 ++++++++++++ .../remote_cluster_table.js | 10 +- .../remote_cluster_table.test.js | 99 +++ .../public/services/documentation_links.js | 1 + .../public/store/actions/add_cluster.js | 2 +- .../public/store/actions/edit_cluster.js | 31 +- .../server/lib/check_license/check_license.js | 1 + .../remote_clusters/register_update_route.js | 6 + .../auto_follow_pattern.js | 46 +- .../follower_indices.js | 152 ++++ .../cross_cluster_replication/index.js | 1 + .../cross_cluster_replication/lib/clusters.js | 56 ++ .../cross_cluster_replication/lib/es_index.js | 35 + .../cross_cluster_replication/lib/index.js | 17 + .../cross_cluster_replication/lib/random.js | 12 + 129 files changed, 8864 insertions(+), 1213 deletions(-) create mode 100644 x-pack/plugins/cross_cluster_replication/common/constants/settings.js create mode 100644 x-pack/plugins/cross_cluster_replication/fixtures/follower_index.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js rename x-pack/plugins/cross_cluster_replication/public/app/{store/middleware => components/follower_index_form}/index.js (69%) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_pause_provider.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_resume_provider.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_unfollow_provider.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/services/license.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js delete mode 100644 x-pack/plugins/cross_cluster_replication/public/app/store/middleware/api.js delete mode 100644 x-pack/plugins/cross_cluster_replication/public/app/store/middleware/auto_follow_pattern.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js delete mode 100644 x-pack/plugins/cross_cluster_replication/public/register_ccr_section.js create mode 100644 x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js create mode 100644 x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/remote_cluster_form.test.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_name.test.js.snap create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_seeds.test.js.snap create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/index.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.test.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.test.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.test.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/_index.scss create mode 100644 x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/detail_panel/__snapshots__/detail_panel.test.js.snap create mode 100644 x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/detail_panel/detail_panel.test.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_list.test.js create mode 100644 x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/__snapshots__/remote_cluster_table.test.js.snap create mode 100644 x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.test.js create mode 100644 x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js create mode 100644 x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/clusters.js create mode 100644 x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/es_index.js create mode 100644 x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/index.js create mode 100644 x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/random.js diff --git a/src/ui/public/indices/constants/index.js b/src/ui/public/indices/constants/index.js index e8f1da34e400d2..717472ebeffe52 100644 --- a/src/ui/public/indices/constants/index.js +++ b/src/ui/public/indices/constants/index.js @@ -19,4 +19,9 @@ import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/index_patterns'; -export const INDEX_ILLEGAL_CHARACTERS_VISIBLE = INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.concat(','); +export const INDEX_ILLEGAL_CHARACTERS_VISIBLE = [ ...INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE ]; + +// Insert the comma into the middle, so it doesn't look as if it has grammatical meaning when +// these characters are rendered in the UI. +const insertionIndex = Math.floor(INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.length / 2); +INDEX_ILLEGAL_CHARACTERS_VISIBLE.splice(insertionIndex, 0, ','); diff --git a/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js b/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js index f87d5767fafc2f..0a948793e07dbc 100644 --- a/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js +++ b/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js @@ -8,3 +8,4 @@ export const BASE_PATH = '/management/elasticsearch/cross_cluster_replication'; export const BASE_PATH_REMOTE_CLUSTERS = '/management/elasticsearch/remote_clusters'; export const API_BASE_PATH = '/api/cross_cluster_replication'; export const API_REMOTE_CLUSTERS_BASE_PATH = '/api/remote_clusters'; +export const API_INDEX_MANAGEMENT_BASE_PATH = '/api/index_management'; diff --git a/x-pack/plugins/cross_cluster_replication/common/constants/index.js b/x-pack/plugins/cross_cluster_replication/common/constants/index.js index e61c23841b0518..300afb4e2d2fff 100644 --- a/x-pack/plugins/cross_cluster_replication/common/constants/index.js +++ b/x-pack/plugins/cross_cluster_replication/common/constants/index.js @@ -7,3 +7,4 @@ export * from './plugin'; export * from './base_path'; export * from './app'; +export * from './settings'; diff --git a/x-pack/plugins/cross_cluster_replication/common/constants/settings.js b/x-pack/plugins/cross_cluster_replication/common/constants/settings.js new file mode 100644 index 00000000000000..0993a74c8f1fd1 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/common/constants/settings.js @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const FOLLOWER_INDEX_ADVANCED_SETTINGS = { + maxReadRequestOperationCount: 5120, + maxOutstandingReadRequests: 12, + maxReadRequestSize: '32mb', + maxWriteRequestOperationCount: 5120, + maxWriteRequestSize: '9223372036854775807b', + maxOutstandingWriteRequests: 9, + maxWriteBufferCount: 2147483647, + maxWriteBufferSize: '512mb', + maxRetryDelay: '500ms', + readPollTimeout: '1m', +}; diff --git a/x-pack/plugins/cross_cluster_replication/common/services/utils.js b/x-pack/plugins/cross_cluster_replication/common/services/utils.js index b8f245bfaefb4d..83154c7e95caca 100644 --- a/x-pack/plugins/cross_cluster_replication/common/services/utils.js +++ b/x-pack/plugins/cross_cluster_replication/common/services/utils.js @@ -15,3 +15,15 @@ export const wait = (time = 1000) => (data) => { setTimeout(() => resolve(data), time); }); }; + +/** + * Utility to remove empty fields ("") from a request body + */ +export const removeEmptyFields = (body) => ( + Object.entries(body).reduce((acc, [key, value]) => { + if (value !== '') { + acc[key] = value; + } + return acc; + }, {}) +); diff --git a/x-pack/plugins/cross_cluster_replication/fixtures/follower_index.js b/x-pack/plugins/cross_cluster_replication/fixtures/follower_index.js new file mode 100644 index 00000000000000..ef888f8929a267 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/fixtures/follower_index.js @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const Chance = require('chance'); // eslint-disable-line import/no-extraneous-dependencies +const chance = new Chance(); + +export const getFollowerIndexStatsMock = ( + name = chance.string(), + shards = [{ + id: chance.string(), + remoteCluster: chance.string(), + leaderIndex: chance.string(), + leaderGlobalCheckpoint: chance.integer(), + leaderMaxSequenceNum: chance.integer(), + followerGlobalCheckpoint: chance.integer(), + followerMaxSequenceNum: chance.integer(), + lastRequestedSequenceNum: chance.integer(), + outstandingReadRequestsCount: chance.integer(), + outstandingWriteRequestsCount: chance.integer(), + writeBufferOperationsCount: chance.integer(), + writeBufferSizeBytes: chance.integer(), + followerMappingVersion: chance.integer(), + followerSettingsVersion: chance.integer(), + totalReadTimeMs: chance.integer(), + totalReadRemoteExecTimeMs: chance.integer(), + successfulReadRequestCount: chance.integer(), + failedReadRequestsCount: chance.integer(), + operationsReadCount: chance.integer(), + bytesReadCount: chance.integer(), + totalWriteTimeMs: chance.integer(), + successfulWriteRequestsCount: chance.integer(), + failedWriteRequestsCount: chance.integer(), + operationsWrittenCount: chance.integer(), + readExceptions: [ chance.string() ], + timeSinceLastReadMs: chance.integer(), + }] +) => { + const serializeShard = ({ + id, + remoteCluster, + leaderIndex, + leaderGlobalCheckpoint, + leaderMaxSequenceNum, + followerGlobalCheckpoint, + followerMaxSequenceNum, + lastRequestedSequenceNum, + outstandingReadRequestsCount, + outstandingWriteRequestsCount, + writeBufferOperationsCount, + writeBufferSizeBytes, + followerMappingVersion, + followerSettingsVersion, + totalReadTimeMs, + totalReadRemoteExecTimeMs, + successfulReadRequestCount, + failedReadRequestsCount, + operationsReadCount, + bytesReadCount, + totalWriteTimeMs, + successfulWriteRequestsCount, + failedWriteRequestsCount, + operationsWrittenCount, + readExceptions, + timeSinceLastReadMs, + }) => ({ + shard_id: id, + remote_cluster: remoteCluster, + leader_index: leaderIndex, + leader_global_checkpoint: leaderGlobalCheckpoint, + leader_max_seq_no: leaderMaxSequenceNum, + follower_global_checkpoint: followerGlobalCheckpoint, + follower_max_seq_no: followerMaxSequenceNum, + last_requested_seq_no: lastRequestedSequenceNum, + outstanding_read_requests: outstandingReadRequestsCount, + outstanding_write_requests: outstandingWriteRequestsCount, + write_buffer_operation_count: writeBufferOperationsCount, + write_buffer_size_in_bytes: writeBufferSizeBytes, + follower_mapping_version: followerMappingVersion, + follower_settings_version: followerSettingsVersion, + total_read_time_millis: totalReadTimeMs, + total_read_remote_exec_time_millis: totalReadRemoteExecTimeMs, + successful_read_requests: successfulReadRequestCount, + failed_read_requests: failedReadRequestsCount, + operations_read: operationsReadCount, + bytes_read: bytesReadCount, + total_write_time_millis: totalWriteTimeMs, + successful_write_requests: successfulWriteRequestsCount, + failed_write_requests: failedWriteRequestsCount, + operations_written: operationsWrittenCount, + read_exceptions: readExceptions, + time_since_last_read_millis: timeSinceLastReadMs, + }); + + return { + index: name, + shards: shards.map(serializeShard), + }; +}; + +export const getFollowerIndexListStatsMock = (total = 3, names) => { + const list = { + follow_stats: { + indices: [], + }, + }; + + for(let i = 0; i < total; i++) { + list.follow_stats.indices.push(getFollowerIndexStatsMock(names[i])); + } + + return list; +}; + +export const getFollowerIndexInfoMock = ( + name = chance.string(), + status = chance.string(), + parameters = { + maxReadRequestOperationCount: chance.string(), + maxOutstandingReadRequests: chance.string(), + maxReadRequestSize: chance.string(), + maxWriteRequestOperationCount: chance.string(), + maxWriteRequestSize: chance.string(), + maxOutstandingWriteRequests: chance.string(), + maxWriteBufferCount: chance.string(), + maxWriteBufferSize: chance.string(), + maxRetryDelay: chance.string(), + readPollTimeout: chance.string(), + } +) => { + return { + follower_index: name, + status, + max_read_request_operation_count: parameters.maxReadRequestOperationCount, + max_outstanding_read_requests: parameters.maxOutstandingReadRequests, + max_read_request_size: parameters.maxReadRequestSize, + max_write_request_operation_count: parameters.maxWriteRequestOperationCount, + max_write_request_size: parameters.maxWriteRequestSize, + max_outstanding_write_requests: parameters.maxOutstandingWriteRequests, + max_write_buffer_count: parameters.maxWriteBufferCount, + max_write_buffer_size: parameters.maxWriteBufferSize, + max_retry_delay: parameters.maxRetryDelay, + read_poll_timeout: parameters.readPollTimeout, + }; +}; + +export const getFollowerIndexListInfoMock = (total = 3) => { + const list = { + follower_indices: [], + }; + + for(let i = 0; i < total; i++) { + list.follower_indices.push(getFollowerIndexInfoMock()); + } + + return list; +}; diff --git a/x-pack/plugins/cross_cluster_replication/fixtures/index.js b/x-pack/plugins/cross_cluster_replication/fixtures/index.js index 20fbce562a0874..9e76cf064118e6 100644 --- a/x-pack/plugins/cross_cluster_replication/fixtures/index.js +++ b/x-pack/plugins/cross_cluster_replication/fixtures/index.js @@ -10,3 +10,10 @@ export { } from './auto_follow_pattern'; export { esErrors } from './es_errors'; + +export { + getFollowerIndexStatsMock, + getFollowerIndexListStatsMock, + getFollowerIndexInfoMock, + getFollowerIndexListInfoMock, +} from './follower_index'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/_app.scss b/x-pack/plugins/cross_cluster_replication/public/app/_app.scss index 9e5c487d42a865..5ee862b1d9e443 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/_app.scss +++ b/x-pack/plugins/cross_cluster_replication/public/app/_app.scss @@ -5,3 +5,10 @@ .ccrFollowerIndicesHelpText { transform: translateY(-3px); } + +/** + * 1. Prevent context menu popover appearing above confirmation modal + */ +.ccrFollowerIndicesDetailPanel { + z-index: $euiZMask - 1; /* 1 */ +} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/app.js b/x-pack/plugins/cross_cluster_replication/public/app/app.js index 19703b8283a8c3..e360d639d4279c 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/app.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/app.js @@ -4,58 +4,218 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { Route, Switch, Redirect } from 'react-router-dom'; +import chrome from 'ui/chrome'; +import { fatalError } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPageContent, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; -import routing from './services/routing'; import { BASE_PATH } from '../../common/constants'; +import { SectionUnauthorized, SectionError } from './components'; +import routing from './services/routing'; +import { isAvailable, isActive, getReason } from './services/license'; +import { loadPermissions } from './services/api'; import { CrossClusterReplicationHome, AutoFollowPatternAdd, - AutoFollowPatternEdit + AutoFollowPatternEdit, + FollowerIndexAdd, + FollowerIndexEdit, } from './sections'; -export class App extends Component { - static contextTypes = { - router: PropTypes.shape({ - history: PropTypes.shape({ - push: PropTypes.func.isRequired, - createHref: PropTypes.func.isRequired +export const App = injectI18n( + class extends Component { + static contextTypes = { + router: PropTypes.shape({ + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + createHref: PropTypes.func.isRequired + }).isRequired }).isRequired - }).isRequired - } + } - constructor(...args) { - super(...args); - this.registerRouter(); - } + constructor(...args) { + super(...args); + this.registerRouter(); - componentWillMount() { - routing.userHasLeftApp = false; - } + this.state = { + isFetchingPermissions: false, + fetchPermissionError: undefined, + hasPermission: false, + missingClusterPrivileges: [], + }; + } - componentWillUnmount() { - routing.userHasLeftApp = true; - } + componentWillMount() { + routing.userHasLeftApp = false; + } - registerRouter() { - const { router } = this.context; - routing.reactRouter = router; - } + componentDidMount() { + this.checkPermissions(); + } - render() { - return ( -
- - - - - - -
- ); - } -} + componentWillUnmount() { + routing.userHasLeftApp = true; + } + + async checkPermissions() { + this.setState({ + isFetchingPermissions: true, + }); + + try { + const { hasPermission, missingClusterPrivileges } = await loadPermissions(); + this.setState({ + isFetchingPermissions: false, + hasPermission, + missingClusterPrivileges, + }); + } catch (error) { + // Expect an error in the shape provided by Angular's $http service. + if (error && error.data) { + return this.setState({ + isFetchingPermissions: false, + fetchPermissionError: error, + }); + } + + // This error isn't an HTTP error, so let the fatal error screen tell the user something + // unexpected happened. + fatalError(error, i18n.translate('xpack.crossClusterReplication.app.checkPermissionsFatalErrorTitle', { + defaultMessage: 'Cross Cluster Replication app', + })); + } + } + + registerRouter() { + const { router } = this.context; + routing.reactRouter = router; + } + + render() { + const { + isFetchingPermissions, + fetchPermissionError, + hasPermission, + missingClusterPrivileges, + } = this.state; + + if (!isAvailable() || !isActive()) { + return ( + + )} + > + {getReason()} + {' '} + + + + + ); + } + + if (isFetchingPermissions) { + return ( + + + + + + + + +

+ +

+
+
+
+
+ ); + } + + if (fetchPermissionError) { + return ( + + + )} + error={fetchPermissionError} + /> + + + + ); + } + + if (!hasPermission) { + return ( + + + + } + body={ +

+ +

} + /> +
+ ); + } + + return ( +
+ + + + + + + + +
+ ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js index f9f91694821068..1463560909edbe 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js @@ -26,32 +26,26 @@ import { EuiSpacer, EuiText, EuiTitle, - EuiSuperSelect, } from '@elastic/eui'; import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/index_patterns'; import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; import routing from '../services/routing'; +import { extractQueryParams } from '../services/query_params'; +import { getRemoteClusterName } from '../services/get_remote_cluster_name'; import { API_STATUS } from '../constants'; -import { SectionError, AutoFollowPatternIndicesPreview } from './'; +import { SectionError } from './section_error'; +import { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview'; +import { RemoteClustersFormField } from './remote_clusters_form_field'; import { validateAutoFollowPattern, validateLeaderIndexPattern } from '../services/auto_follow_pattern_validators'; const indexPatternIllegalCharacters = INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.join(' '); const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); -const getFirstConnectedCluster = (clusters) => { - for (let i = 0; i < clusters.length; i++) { - if (clusters[i].isConnected) { - return clusters[i]; - } - } - return {}; -}; - -const getEmptyAutoFollowPattern = (remoteClusters) => ({ +const getEmptyAutoFollowPattern = (remoteClusterName = '') => ({ name: '', - remoteCluster: getFirstConnectedCluster(remoteClusters).name, + remoteCluster: remoteClusterName, leaderIndexPatterns: [], followIndexPatternPrefix: '', followIndexPatternSuffix: '', @@ -70,16 +64,20 @@ export class AutoFollowPatternFormUI extends PureComponent { autoFollowPattern: PropTypes.object, apiError: PropTypes.object, apiStatus: PropTypes.string.isRequired, - remoteClusters: PropTypes.array.isRequired, + currentUrl: PropTypes.string.isRequired, + remoteClusters: PropTypes.array, + saveButtonLabel: PropTypes.node, } constructor(props) { super(props); const isNew = this.props.autoFollowPattern === undefined; - + const { route: { location: { search } } } = routing.reactRouter; + const queryParams = extractQueryParams(search); + const remoteClusterName = getRemoteClusterName(this.props.remoteClusters, queryParams.cluster); const autoFollowPattern = isNew - ? getEmptyAutoFollowPattern(this.props.remoteClusters) + ? getEmptyAutoFollowPattern(remoteClusterName) : { ...this.props.autoFollowPattern, }; @@ -101,9 +99,11 @@ export class AutoFollowPatternFormUI extends PureComponent { })); const errors = validateAutoFollowPattern(fields); - this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); + this.onFieldsErrorChange(errors); }; + onFieldsErrorChange = (errors) => this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); + onClusterChange = (remoteCluster) => { this.onFieldsChange({ remoteCluster }); }; @@ -169,8 +169,8 @@ export class AutoFollowPatternFormUI extends PureComponent { this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); } else { - this.setState(({ fieldsErrors, autoFollowPattern }) => { - const errors = validateAutoFollowPattern(autoFollowPattern); + this.setState(({ fieldsErrors, autoFollowPattern: { leaderIndexPatterns } }) => { + const errors = validateAutoFollowPattern({ leaderIndexPatterns }); return updateFormErrors(errors, fieldsErrors); }); } @@ -187,7 +187,7 @@ export class AutoFollowPatternFormUI extends PureComponent { }; isFormValid() { - return Object.values(this.state.fieldsErrors).every(error => error === null); + return Object.values(this.state.fieldsErrors).every(error => error === undefined || error === null); } sendForm = () => { @@ -217,9 +217,15 @@ export class AutoFollowPatternFormUI extends PureComponent { if (apiError) { const title = intl.formatMessage({ id: 'xpack.crossClusterReplication.autoFollowPatternForm.savingErrorTitle', - defaultMessage: 'Error creating auto-follow pattern', + defaultMessage: `Can't create auto-follow pattern`, }); - return ; + + return ( + + + + + ); } return null; @@ -293,12 +299,39 @@ export class AutoFollowPatternFormUI extends PureComponent { * Remote Cluster */ const renderRemoteClusterField = () => { - const remoteClustersOptions = this.props.remoteClusters.map(({ name, isConnected }) => ({ - value: name, - inputDisplay: isConnected ? name : `${name} (not connected)`, - disabled: !isConnected, - 'data-test-subj': `option-${name}` - })); + const { remoteClusters, currentUrl } = this.props; + + const errorMessages = { + noClusterFound: () => ( + + ), + remoteClusterNotConnectedNotEditable: (name) => ({ + title: ( + + ), + description: ( + + ), + }), + remoteClusterDoesNotExist: (name) => ( + + ), + }; return ( - - )} - fullWidth - > - - { isNew && ( - - )} - { !isNew && ( - - )} - - + this.onFieldsErrorChange({ remoteCluster: error })} + errorMessages={errorMessages} + /> ); }; @@ -384,7 +401,7 @@ export class AutoFollowPatternFormUI extends PureComponent {

{ + const renderAutoFollowPatternPrefixSuffix = () => { const isPrefixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternPrefix; const isSuffixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternSuffix; @@ -544,7 +561,6 @@ export class AutoFollowPatternFormUI extends PureComponent { return ( - + ); }; @@ -563,7 +580,7 @@ export class AutoFollowPatternFormUI extends PureComponent { * Form Actions */ const renderActions = () => { - const { apiStatus } = this.props; + const { apiStatus, saveButtonLabel } = this.props; const { areErrorsVisible } = this.state; if (apiStatus === API_STATUS.SAVING) { @@ -597,10 +614,7 @@ export class AutoFollowPatternFormUI extends PureComponent { fill disabled={isSaveDisabled} > - + {saveButtonLabel} @@ -625,10 +639,10 @@ export class AutoFollowPatternFormUI extends PureComponent { {renderAutoFollowPatternName()} {renderRemoteClusterField()} {renderLeaderIndexPatterns()} - {renderAutoFollowPattern()} + {renderAutoFollowPatternPrefixSuffix()} {renderFormErrorWarning()} - + {this.renderApiErrors()} {renderActions()} ); @@ -650,7 +664,6 @@ export class AutoFollowPatternFormUI extends PureComponent { render() { return ( - {this.renderApiErrors()} {this.renderForm()} {this.renderLoading()} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js index 10ddd036cb4adf..da0ebbf4b9c13c 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js @@ -40,7 +40,7 @@ export const AutoFollowPatternPageTitle = ({ title }) => ( iconType="help" > diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap new file mode 100644 index 00000000000000..017f565ff9f48b --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` state transitions updateFields() should merge new fields value with existing followerIndex 1`] = ` +Object { + "followerIndex": Object { + "leaderIndex": "bar", + "name": "new-name", + }, +} +`; + +exports[` state transitions updateFormErrors() should merge errors with existing fieldsErrors 1`] = ` +Object { + "fieldsErrors": Object { + "leaderIndex": null, + "name": "Some error", + }, +} +`; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js new file mode 100644 index 00000000000000..84e0d58191d27d --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { byteUnitsUrl, timeUnitsUrl } from '../../services/documentation_links'; +import { getSettingDefault } from '../../services/follower_index_default_settings'; + +const byteUnitsHelpText = ( + + + + ) }} + /> +); + +const timeUnitsHelpText = ( + + + + ) }} + /> +); + +export const advancedSettingsFields = [ + { + field: 'maxReadRequestOperationCount', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountTitle', { + defaultMessage: 'Max read request operation count' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountDescription', { + defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.' + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountLabel', { + defaultMessage: 'Max read request operation count' + } + ), + defaultValue: getSettingDefault('maxReadRequestOperationCount'), + type: 'number', + }, { + field: 'maxOutstandingReadRequests', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle', { + defaultMessage: 'Max outstanding read requests' + } + ), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', { + defaultMessage: 'The maximum number of outstanding read requests from the remote cluster.' + }), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsLabel', { + defaultMessage: 'Max outstanding read requests' + } + ), + defaultValue: getSettingDefault('maxOutstandingReadRequests'), + type: 'number', + }, { + field: 'maxReadRequestSize', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle', { + defaultMessage: 'Max read request size' + } + ), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', { + defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster.' + }), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeLabel', { + defaultMessage: 'Max read request size' + } + ), + defaultValue: getSettingDefault('maxReadRequestSize'), + helpText: byteUnitsHelpText, + }, { + field: 'maxWriteRequestOperationCount', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle', { + defaultMessage: 'Max write request operation count' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountDescription', { + defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.' + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountLabel', { + defaultMessage: 'Max write request operation count' + } + ), + defaultValue: getSettingDefault('maxWriteRequestOperationCount'), + type: 'number', + }, { + field: 'maxWriteRequestSize', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle', { + defaultMessage: 'Max write request size' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeDescription', { + defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.' + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeLabel', { + defaultMessage: 'Max write request size' + } + ), + defaultValue: getSettingDefault('maxWriteRequestSize'), + helpText: byteUnitsHelpText, + }, { + field: 'maxOutstandingWriteRequests', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle', { + defaultMessage: 'Max outstanding write requests' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsDescription', { + defaultMessage: 'The maximum number of outstanding write requests on the follower.' + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsLabel', { + defaultMessage: 'Max outstanding write requests' + } + ), + defaultValue: getSettingDefault('maxOutstandingWriteRequests'), + type: 'number', + }, { + field: 'maxWriteBufferCount', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle', { + defaultMessage: 'Max write buffer count' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountDescription', { + defaultMessage: `The maximum number of operations that can be queued for writing; when this + limit is reached, reads from the remote cluster will be deferred until the number of queued + operations goes below the limit.` + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountLabel', { + defaultMessage: 'Max write buffer count' + } + ), + defaultValue: getSettingDefault('maxWriteBufferCount'), + type: 'number', + }, { + field: 'maxWriteBufferSize', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle', { + defaultMessage: 'Max write buffer size' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeDescription', { + defaultMessage: `The maximum total bytes of operations that can be queued for writing; when + this limit is reached, reads from the remote cluster will be deferred until the total bytes + of queued operations goes below the limit.` + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeLabel', { + defaultMessage: 'Max write buffer size' + } + ), + defaultValue: getSettingDefault('maxWriteBufferSize'), + helpText: byteUnitsHelpText, + }, { + field: 'maxRetryDelay', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle', { + defaultMessage: 'Max retry delay' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayDescription', { + defaultMessage: `The maximum time to wait before retrying an operation that failed exceptionally; + an exponential backoff strategy is employed when retrying.` + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayLabel', { + defaultMessage: 'Max retry delay' + } + ), + defaultValue: getSettingDefault('maxRetryDelay'), + helpText: timeUnitsHelpText, + }, { + field: 'readPollTimeout', + title: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle', { + defaultMessage: 'Read poll timeout' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutDescription', { + defaultMessage: `The maximum time to wait for new operations on the remote cluster when the + follower index is synchronized with the leader index; when the timeout has elapsed, the + poll for operations will return to the follower so that it can update some statistics, and + then the follower will immediately attempt to read from the leader again.` + } + ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutLabel', { + defaultMessage: 'Read poll timeout' + } + ), + defaultValue: getSettingDefault('readPollTimeout'), + helpText: timeUnitsHelpText, + }, +]; + +export const emptyAdvancedSettings = advancedSettingsFields.reduce((obj, advancedSetting) => { + const { field, defaultValue } = advancedSetting; + return { ...obj, [field]: defaultValue }; +}, {}); + +export function areAdvancedSettingsEdited(followerIndex) { + return advancedSettingsFields.some(advancedSetting => { + const { field } = advancedSetting; + return followerIndex[field] !== emptyAdvancedSettings[field]; + }); +} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js new file mode 100644 index 00000000000000..1d3bf8d60db21d --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -0,0 +1,719 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; +import { fatalError } from 'ui/notify'; + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiHorizontalRule, + EuiLoadingKibana, + EuiLoadingSpinner, + EuiOverlayMask, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { indexNameValidator, leaderIndexValidator } from '../../services/input_validation'; +import routing from '../../services/routing'; +import { loadIndices } from '../../services/api'; +import { API_STATUS } from '../../constants'; +import { SectionError } from '../section_error'; +import { FormEntryRow } from '../form_entry_row'; +import { + advancedSettingsFields, + emptyAdvancedSettings, + areAdvancedSettingsEdited, +} from './advanced_settings_fields'; +import { extractQueryParams } from '../../services/query_params'; +import { getRemoteClusterName } from '../../services/get_remote_cluster_name'; +import { RemoteClustersFormField } from '../remote_clusters_form_field'; + +const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); + +const fieldToValidatorMap = advancedSettingsFields.reduce((map, advancedSetting) => { + const { field, validator } = advancedSetting; + map[field] = validator; + return map; +}, { + 'name': indexNameValidator, + 'leaderIndex': leaderIndexValidator, +}); + +const getEmptyFollowerIndex = (remoteClusterName = '') => ({ + name: '', + remoteCluster: remoteClusterName, + leaderIndex: '', + ...emptyAdvancedSettings, +}); + +/** + * State transitions: fields update + */ +export const updateFields = (fields) => ({ followerIndex }) => ({ + followerIndex: { + ...followerIndex, + ...fields, + }, +}); + +/** + * State transitions: errors update + */ +export const updateFormErrors = (errors) => ({ fieldsErrors }) => ({ + fieldsErrors: { + ...fieldsErrors, + ...errors, + } +}); + +export const FollowerIndexForm = injectI18n( + class extends PureComponent { + static propTypes = { + saveFollowerIndex: PropTypes.func.isRequired, + clearApiError: PropTypes.func.isRequired, + followerIndex: PropTypes.object, + apiError: PropTypes.object, + apiStatus: PropTypes.string.isRequired, + remoteClusters: PropTypes.array, + saveButtonLabel: PropTypes.node, + } + + constructor(props) { + super(props); + + const { route: { location: { search } } } = routing.reactRouter; + const queryParams = extractQueryParams(search); + + const isNew = this.props.followerIndex === undefined; + const remoteClusterName = getRemoteClusterName(this.props.remoteClusters, queryParams.cluster); + const followerIndex = isNew + ? getEmptyFollowerIndex(remoteClusterName) + : { + ...getEmptyFollowerIndex(), + ...this.props.followerIndex, + }; + const areAdvancedSettingsVisible = isNew ? false : ( // eslint-disable-line no-nested-ternary + areAdvancedSettingsEdited(followerIndex) ? true : false + ); + + const fieldsErrors = this.getFieldsErrors(followerIndex); + + this.state = { + isNew, + followerIndex, + fieldsErrors, + areErrorsVisible: false, + areAdvancedSettingsVisible, + isValidatingIndexName: false, + }; + + this.cachedAdvancedSettings = {}; + this.validateIndexName = debounce(this.validateIndexName, 500); + } + + onFieldsChange = (fields) => { + this.setState(updateFields(fields)); + + const newFields = { + ...this.state.fields, + ...fields, + }; + + this.setState(updateFormErrors(this.getFieldsErrors(newFields))); + + if (this.props.apiError) { + this.props.clearApiError(); + } + }; + + getFieldsErrors = (newFields) => { + return Object.keys(newFields).reduce((errors, field) => { + const validator = fieldToValidatorMap[field]; + const value = newFields[field]; + + if (validator) { + const error = validator(value); + errors[field] = error; + } + + return errors; + }, {}); + }; + + onIndexNameChange = ({ name }) => { + this.onFieldsChange({ name }); + + if (!name || !name.trim()) { + this.setState({ + isValidatingIndexName: false, + }); + + return; + } + + this.setState({ + isValidatingIndexName: true, + }); + + this.validateIndexName(name); + }; + + validateIndexName = async (name) => { + try { + const indices = await loadIndices(); + const doesExist = indices.some(index => index.name === name); + if (doesExist) { + const error = { + message: ( + + ), + alwaysVisible: true, + }; + + this.setState(updateFormErrors({ name: error })); + } + + this.setState({ + isValidatingIndexName: false, + }); + } catch (error) { + // Expect an error in the shape provided by Angular's $http service. + if (error && error.data) { + // All validation does is check for a name collision, so we can just let the user attempt + // to save the follower index and get an error back from the API. + return this.setState({ + isValidatingIndexName: false, + }); + } + + // This error isn't an HTTP error, so let the fatal error screen tell the user something + // unexpected happened. + fatalError(error, i18n.translate('xpack.crossClusterReplication.followerIndexForm.indexNameValidationFatalErrorTitle', { + defaultMessage: 'Follower Index Form index name validation', + })); + } + }; + + onClusterChange = (remoteCluster) => { + this.onFieldsChange({ remoteCluster }); + }; + + getFields = () => { + return this.state.followerIndex; + }; + + toggleAdvancedSettings = (event) => { + // If the user edits the advanced settings but then hides them, we need to make sure the + // edited values don't get sent to the API when the user saves, but we *do* want to restore + // these values to the form when the user re-opens the advanced settings. + if (event.target.checked) { + // Apply the cached advanced settings to the advanced settings form. + this.onFieldsChange(this.cachedAdvancedSettings); + + // Reset the cache of the advanced settings. + this.cachedAdvancedSettings = {}; + + // Show the advanced settings. + return this.setState({ + areAdvancedSettingsVisible: true, + }); + } + + // Clear the advanced settings form. + this.onFieldsChange(emptyAdvancedSettings); + + // Save a cache of the advanced settings. + const fields = this.getFields(); + this.cachedAdvancedSettings = advancedSettingsFields.reduce((cache, { field }) => { + const value = fields[field]; + if (value !== '') { + cache[field] = value; + } + return cache; + }, {}); + + // Hide the advanced settings. + this.setState({ + areAdvancedSettingsVisible: false, + }); + } + + isFormValid() { + return Object.values(this.state.fieldsErrors).every(error => error === undefined || error === null); + } + + sendForm = () => { + const isFormValid = this.isFormValid(); + + this.setState({ areErrorsVisible: !isFormValid }); + + if (!isFormValid) { + return; + } + + const { name, ...followerIndex } = this.getFields(); + + this.props.saveFollowerIndex(name, followerIndex); + }; + + cancelForm = () => { + routing.navigate('/follower_indices'); + }; + + /** + * Sections Renders + */ + renderApiErrors() { + const { apiError, intl } = this.props; + + if (apiError) { + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexForm.savingErrorTitle', + defaultMessage: `Can't create follower index`, + }); + const { leaderIndex } = this.state.followerIndex; + const error = apiError.status === 404 + ? { + data: { + message: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexForm.leaderIndexNotFoundError', + defaultMessage: `The leader index '{leaderIndex}' does not exist.`, + }, { leaderIndex }) + } + } + : apiError; + + return ( + + + + + ); + } + + return null; + } + + renderForm = () => { + const { + followerIndex, + isNew, + areErrorsVisible, + areAdvancedSettingsVisible, + fieldsErrors, + isValidatingIndexName, + } = this.state; + + /** + * Follower index name + */ + + const indexNameHelpText = ( + + {isValidatingIndexName && ( +

+ +

+ )} +

+ {indexNameIllegalCharacters} }} + /> +

+ + ); + + const indexNameLabel = i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameTitle', + { + defaultMessage: 'Follower index' + } + ); + + const renderFollowerIndexName = () => ( + +

{indexNameLabel}

+ + )} + label={indexNameLabel} + description={i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameDescription', { + defaultMessage: 'A unique name for your index.' + })} + helpText={indexNameHelpText} + isLoading={isValidatingIndexName} + disabled={!isNew} + areErrorsVisible={areErrorsVisible} + onValueUpdate={this.onIndexNameChange} + /> + ); + + /** + * Remote Cluster + */ + const renderRemoteClusterField = () => { + const { remoteClusters, currentUrl } = this.props; + + const errorMessages = { + noClusterFound: () => ( + + ), + remoteClusterNotConnectedNotEditable: (name) => ({ + title: ( + + ), + description: ( + + ), + }), + remoteClusterDoesNotExist: (name) => ( + + ), + }; + + return ( + +

+ +

+ + )} + description={( + + )} + fullWidth + > + { + this.setState(updateFormErrors({ remoteCluster: error })); + }} + errorMessages={errorMessages} + /> +
+ ); + }; + + /** + * Leader index + */ + + const leaderIndexLabel = i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexTitle', { + defaultMessage: 'Leader index' + } + ); + + const renderLeaderIndex = () => ( + +

{leaderIndexLabel}

+ + )} + label={leaderIndexLabel} + description={( + +

+ +

+ +

+ + + + ) }} + /> +

+
+ )} + helpText={( + {indexNameIllegalCharacters} }} + /> + )} + disabled={!isNew} + areErrorsVisible={areErrorsVisible} + onValueUpdate={this.onFieldsChange} + /> + ); + + /** + * Advanced settings + */ + + const renderAdvancedSettings = () => { + return ( + + + +

+ +

+ + )} + description={( + +

+ +

+ + + )} + checked={areAdvancedSettingsVisible} + onChange={this.toggleAdvancedSettings} + /> +
+ )} + fullWidth + > + {/* Avoid missing `children` warning */} +
+ + {areAdvancedSettingsVisible && ( + + + {advancedSettingsFields.map((advancedSetting) => { + const { field, title, description, label, helpText, defaultValue, type } = advancedSetting; + return ( + +

{title}

+ + )} + description={description} + label={label} + helpText={helpText} + type={type} + areErrorsVisible={areErrorsVisible} + onValueUpdate={this.onFieldsChange} + /> + ); + })} +
+ )} + +
+ ); + }; + + /** + * Form Error warning message + */ + const renderFormErrorWarning = () => { + const { areErrorsVisible } = this.state; + const isFormValid = this.isFormValid(); + + if (!areErrorsVisible || isFormValid) { + return null; + } + + return ( + + + )} + color="danger" + iconType="cross" + /> + + + + ); + }; + + /** + * Form Actions + */ + const renderActions = () => { + const { apiStatus, saveButtonLabel } = this.props; + const { areErrorsVisible } = this.state; + + if (apiStatus === API_STATUS.SAVING) { + return ( + + + + + + + + + + + + ); + } + + const isSaveDisabled = areErrorsVisible && !this.isFormValid(); + + return ( + + + + {saveButtonLabel} + + + + + + + + + + ); + }; + + return ( + + + {renderRemoteClusterField()} + {renderLeaderIndex()} + {renderFollowerIndexName()} + + {renderAdvancedSettings()} + + + {renderFormErrorWarning()} + {this.renderApiErrors()} + {renderActions()} + + ); + } + + renderLoading = () => { + const { apiStatus } = this.props; + + if (apiStatus === API_STATUS.SAVING) { + return ( + + + + ); + } + return null; + } + + render() { + return ( + + {this.renderForm()} + {this.renderLoading()} + + ); + } + } +); + + diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js new file mode 100644 index 00000000000000..74fdf3301bccaa --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +import { updateFields, updateFormErrors } from './follower_index_form'; + +jest.mock('ui/indices', () => ({ + INDEX_ILLEGAL_CHARACTERS_VISIBLE: [], +})); + +describe(' state transitions', () => { + it('updateFormErrors() should merge errors with existing fieldsErrors', () => { + const errors = { name: 'Some error' }; + const state = { + fieldsErrors: { leaderIndex: null } + }; + const output = updateFormErrors(errors)(state); + expect(output).toMatchSnapshot(); + }); + + it('updateFields() should merge new fields value with existing followerIndex', () => { + const fields = { name: 'new-name' }; + const state = { + followerIndex: { name: 'foo', leaderIndex: 'bar' } + }; + const output = updateFields(fields)(state); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/index.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js similarity index 69% rename from x-pack/plugins/cross_cluster_replication/public/app/store/middleware/index.js rename to x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js index 5097ec64e82848..265780b620a7cd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { apiMiddleware } from './api'; -export { autoFollowPatternMiddleware } from './auto_follow_pattern'; +export { FollowerIndexForm } from './follower_index_form'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js new file mode 100644 index 00000000000000..a1ec4a44e218a1 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiPageContentHeader, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { followerIndexUrl } from '../services/documentation_links'; + +export const FollowerIndexPageTitle = ({ title }) => ( + + + + + + + +

{title}

+
+
+ + + + + + +
+
+
+); + +FollowerIndexPageTitle.propTypes = { + title: PropTypes.node.isRequired, +}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_pause_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_pause_provider.js new file mode 100644 index 00000000000000..a745022e12f6a5 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_pause_provider.js @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { + EuiConfirmModal, + EuiOverlayMask, +} from '@elastic/eui'; + +import { pauseFollowerIndex } from '../store/actions'; +import { arrify } from '../../../common/services/utils'; +import { areAllSettingsDefault } from '../services/follower_index_default_settings'; + +class Provider extends PureComponent { + static propTypes = { + onConfirm: PropTypes.func, + } + + state = { + isModalOpen: false, + indices: [] + } + + onMouseOverModal = (event) => { + // This component can sometimes be used inside of an EuiToolTip, in which case mousing over + // the modal can trigger the tooltip. Stopping propagation prevents this. + event.stopPropagation(); + }; + + pauseFollowerIndex = (index) => { + this.setState({ isModalOpen: true, indices: arrify(index) }); + }; + + onConfirm = () => { + this.props.pauseFollowerIndex(this.state.indices.map(index => index.name)); + this.setState({ isModalOpen: false, indices: [] }); + this.props.onConfirm && this.props.onConfirm(); + } + + closeConfirmModal = () => { + this.setState({ + isModalOpen: false, + }); + }; + + renderModal = () => { + const { intl } = this.props; + const { indices } = this.state; + const isSingle = indices.length === 1; + const title = isSingle + ? intl.formatMessage({ + id: 'xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.pauseSingleTitle', + defaultMessage: 'Pause replication to follower index \'{name}\'?', + }, { name: indices[0].name }) + : intl.formatMessage({ + id: 'xpack.crossClusterReplication.pauseFollowerIndex.confirmModal.pauseMultipleTitle', + defaultMessage: 'Pause replication to {count} follower indices?', + }, { count: indices.length }); + const hasCustomSettings = indices.some(index => !areAllSettingsDefault(index)); + + return ( + + { /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ } + + {hasCustomSettings && ( +

+ {isSingle ? ( + + ) : ( + + )} +

+ )} + + {!isSingle && ( + +

+ +

+ +
    + {indices.map(index =>
  • {index.name}
  • )} +
+
+ )} +
+
+ ); + } + + render() { + const { children } = this.props; + const { isModalOpen } = this.state; + + return ( + + {children(this.pauseFollowerIndex)} + {isModalOpen && this.renderModal()} + + ); + } +} + +const mapDispatchToProps = (dispatch) => ({ + pauseFollowerIndex: (id) => dispatch(pauseFollowerIndex(id)), +}); + +export const FollowerIndexPauseProvider = connect( + undefined, + mapDispatchToProps +)(injectI18n(Provider)); + diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_resume_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_resume_provider.js new file mode 100644 index 00000000000000..a97153f3142877 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_resume_provider.js @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { + EuiConfirmModal, + EuiLink, + EuiOverlayMask, +} from '@elastic/eui'; + +import routing from '../services/routing'; +import { resumeFollowerIndex } from '../store/actions'; +import { arrify } from '../../../common/services/utils'; + +class Provider extends PureComponent { + static propTypes = { + onConfirm: PropTypes.func, + } + + state = { + isModalOpen: false, + ids: null + } + + onMouseOverModal = (event) => { + // This component can sometimes be used inside of an EuiToolTip, in which case mousing over + // the modal can trigger the tooltip. Stopping propagation prevents this. + event.stopPropagation(); + }; + + resumeFollowerIndex = (id) => { + this.setState({ isModalOpen: true, ids: arrify(id) }); + }; + + onConfirm = () => { + this.props.resumeFollowerIndex(this.state.ids); + this.setState({ isModalOpen: false, ids: null }); + this.props.onConfirm && this.props.onConfirm(); + } + + closeConfirmModal = () => { + this.setState({ + isModalOpen: false, + }); + }; + + renderModal = () => { + const { intl } = this.props; + const { ids } = this.state; + const isSingle = ids.length === 1; + const title = isSingle + ? intl.formatMessage({ + id: 'xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.resumeSingleTitle', + defaultMessage: 'Resume replication to follower index \'{name}\'?', + }, { name: ids[0] }) + : intl.formatMessage({ + id: 'xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.resumeMultipleTitle', + defaultMessage: 'Resume replication to {count} follower indices?', + }, { count: ids.length }); + + return ( + + { /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ } + + {isSingle ? ( +

+ + + + ), + }} + /> +

+ ) : ( + +

+ +

+ +

+ +

+ +
    + {ids.map(id =>
  • {id}
  • )} +
+
+ )} +
+
+ ); + } + + render() { + const { children } = this.props; + const { isModalOpen } = this.state; + + return ( + + {children(this.resumeFollowerIndex)} + {isModalOpen && this.renderModal()} + + ); + } +} + +const mapDispatchToProps = (dispatch) => ({ + resumeFollowerIndex: (id) => dispatch(resumeFollowerIndex(id)), +}); + +export const FollowerIndexResumeProvider = connect( + undefined, + mapDispatchToProps +)(injectI18n(Provider)); + diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_unfollow_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_unfollow_provider.js new file mode 100644 index 00000000000000..2b0f6931044afb --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_unfollow_provider.js @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { + EuiConfirmModal, + EuiOverlayMask, +} from '@elastic/eui'; + +import { unfollowLeaderIndex } from '../store/actions'; +import { arrify } from '../../../common/services/utils'; + +class Provider extends PureComponent { + static propTypes = { + onConfirm: PropTypes.func, + } + + state = { + isModalOpen: false, + ids: null + } + + onMouseOverModal = (event) => { + // This component can sometimes be used inside of an EuiToolTip, in which case mousing over + // the modal can trigger the tooltip. Stopping propagation prevents this. + event.stopPropagation(); + }; + + unfollowLeaderIndex = (id) => { + this.setState({ isModalOpen: true, ids: arrify(id) }); + }; + + onConfirm = () => { + this.props.unfollowLeaderIndex(this.state.ids); + this.setState({ isModalOpen: false, ids: null }); + this.props.onConfirm && this.props.onConfirm(); + } + + closeConfirmModal = () => { + this.setState({ + isModalOpen: false, + }); + }; + + renderModal = () => { + const { intl } = this.props; + const { ids } = this.state; + const isSingle = ids.length === 1; + const title = isSingle + ? intl.formatMessage({ + id: 'xpack.crossClusterReplication.unfollowLeaderIndex.confirmModal.unfollowSingleTitle', + defaultMessage: `Unfollow leader index of '{name}'?`, + }, { name: ids[0] }) + : intl.formatMessage({ + id: 'xpack.crossClusterReplication.unfollowLeaderIndex.confirmModal.unfollowMultipleTitle', + defaultMessage: 'Unfollow {count} leader indices?', + }, { count: ids.length }); + + return ( + + { /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ } + + {isSingle ? ( + +

+ +

+
+ ) : ( + +

+ +

+
    {ids.map(id =>
  • {id}
  • )}
+
+ )} +
+
+ ); + } + + render() { + const { children } = this.props; + const { isModalOpen } = this.state; + + return ( + + {children(this.unfollowLeaderIndex)} + {isModalOpen && this.renderModal()} + + ); + } +} + +const mapDispatchToProps = (dispatch) => ({ + unfollowLeaderIndex: (id) => dispatch(unfollowLeaderIndex(id)), +}); + +export const FollowerIndexUnfollowProvider = connect( + undefined, + mapDispatchToProps +)(injectI18n(Provider)); + diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js new file mode 100644 index 00000000000000..ddcec86d91fcfa --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiDescribedFormGroup, + EuiFieldNumber, + EuiFieldText, + EuiFormRow, + EuiLink, +} from '@elastic/eui'; + +/** + * State transitions: fields update + */ +export const updateFields = (newValues) => ({ fields }) => ({ + fields: { + ...fields, + ...newValues, + }, +}); + +export class FormEntryRow extends PureComponent { + static propTypes = { + title: PropTypes.node, + description: PropTypes.node, + label: PropTypes.node, + helpText: PropTypes.node, + type: PropTypes.string, + onValueUpdate: PropTypes.func.isRequired, + field: PropTypes.string.isRequired, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]).isRequired, + defaultValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + isLoading: PropTypes.bool, + error: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.object, + ]), + disabled: PropTypes.bool, + areErrorsVisible: PropTypes.bool.isRequired, + }; + + onFieldChange = (value) => { + const { field, onValueUpdate, type } = this.props; + const isNumber = type === 'number'; + + let valueParsed = value; + + if (isNumber) { + valueParsed = !!value ? Math.max(0, parseInt(value, 10)) : value; // make sure we don't send NaN value or a negative number + } + + onValueUpdate({ [field]: valueParsed }); + } + + renderField = (isInvalid) => { + const { value, type, disabled, isLoading } = this.props; + switch (type) { + case 'number': + return ( + this.onFieldChange(e.target.value)} + disabled={disabled === true} + isLoading={isLoading} + fullWidth + /> + ); + default: + return ( + this.onFieldChange(e.target.value)} + disabled={disabled === true} + isLoading={isLoading} + fullWidth + /> + ); + } + } + + render() { + const { + field, + error, + title, + label, + description, + helpText, + areErrorsVisible, + value, + defaultValue, + } = this.props; + + const hasError = !!error; + const isInvalid = hasError && (error.alwaysVisible || areErrorsVisible); + const canBeResetToDefault = defaultValue !== undefined; + const isResetToDefaultVisible = value !== defaultValue; + + const fieldHelpText = ( + + {helpText} + + {canBeResetToDefault && isResetToDefaultVisible && ( +

+ this.onFieldChange(defaultValue)}> + + +

+ )} +
+ ); + + return ( + + + {this.renderField(isInvalid)} + + + ); + } +} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/index.js b/x-pack/plugins/cross_cluster_replication/public/app/components/index.js index 5b4a7b407eda03..18029cfc5271ff 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/index.js @@ -12,3 +12,9 @@ export { AutoFollowPatternForm } from './auto_follow_pattern_form'; export { AutoFollowPatternDeleteProvider } from './auto_follow_pattern_delete_provider'; export { AutoFollowPatternPageTitle } from './auto_follow_pattern_page_title'; export { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview'; +export { FollowerIndexPauseProvider } from './follower_index_pause_provider'; +export { FollowerIndexResumeProvider } from './follower_index_resume_provider'; +export { FollowerIndexUnfollowProvider } from './follower_index_unfollow_provider'; +export { FollowerIndexForm } from './follower_index_form'; +export { FollowerIndexPageTitle } from './follower_index_page_title'; +export { FormEntryRow } from './form_entry_row'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js new file mode 100644 index 00000000000000..224b5f295f7cec --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js @@ -0,0 +1,321 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +import React, { Fragment, PureComponent } from 'react'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFormErrorText, + EuiFormRow, + EuiSpacer, + EuiSelect, + EuiFieldText, +} from '@elastic/eui'; + +import routing from '../services/routing'; +import { BASE_PATH_REMOTE_CLUSTERS } from '../../../common/constants'; + +const errorMessages = { + noClusterFound: () => ( + + ), + remoteClusterNotConnectedEditable: (name) => ({ + title: ( + + ), + description: ( + + ), + }), +}; + +export const RemoteClustersFormField = injectI18n( + class extends PureComponent { + errorMessages = { + ...errorMessages, + ...this.props.errorMessages + } + + componentDidMount() { + const { selected, onError } = this.props; + const { error } = this.validateRemoteCluster(selected); + + onError(error); + } + + validateRemoteCluster(clusterName) { + const { remoteClusters } = this.props; + const remoteCluster = remoteClusters.find(c => c.name === clusterName); + + return remoteCluster && remoteCluster.isConnected + ? { error: null } + : { error: { message: ( + + ) } }; + } + + onRemoteClusterChange = (cluster) => { + const { onChange, onError } = this.props; + const { error } = this.validateRemoteCluster(cluster); + onChange(cluster); + onError(error); + }; + + renderNotEditable = () => { + const { areErrorsVisible } = this.props; + const errorMessage = this.renderErrorMessage(); + + return ( + + + { areErrorsVisible && Boolean(errorMessage) ? this.renderValidRemoteClusterRequired() : null } + { errorMessage } + + ); + }; + + renderValidRemoteClusterRequired = () => ( + + + + ); + + renderDropdown = () => { + const { remoteClusters, selected, currentUrl, areErrorsVisible } = this.props; + const hasClusters = Boolean(remoteClusters.length); + const remoteClustersOptions = hasClusters ? remoteClusters.map(({ name, isConnected }) => ({ + value: name, + text: isConnected ? name : this.props.intl.formatMessage({ + id: 'xpack.crossClusterReplication.forms.remoteClusterDropdownNotConnected', + defaultMessage: '{name} (not connected)', + }, { name }), + 'data-test-subj': `option-${name}` + })) : []; + const errorMessage = this.renderErrorMessage(); + + return ( + + { this.onRemoteClusterChange(e.target.value); }} + hasNoInitialSelection={!hasClusters} + isInvalid={areErrorsVisible && Boolean(errorMessage)} + /> + { areErrorsVisible && Boolean(errorMessage) ? this.renderValidRemoteClusterRequired() : null } + { errorMessage } + + + +
{/* Break out of EuiFormRow's flexbox layout */} + + + +
+
+
+ ); + }; + + renderNoClusterFound = () => { + const { intl, currentUrl } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.forms.emptyRemoteClustersCallOutTitle', + defaultMessage: `You don't have any remote clusters`, + }); + + return ( + + +

+ { this.errorMessages.noClusterFound() } +

+ + + + +
+
+ ); + }; + + renderCurrentRemoteClusterNotConnected = (name, fatal) => { + const { isEditable, currentUrl } = this.props; + const { + remoteClusterNotConnectedEditable, + remoteClusterNotConnectedNotEditable, + } = this.errorMessages; + + const { title, description } = isEditable + ? remoteClusterNotConnectedEditable(name) + : remoteClusterNotConnectedNotEditable(name); + + return ( + +

+ { description } +

+ + + + +
+ ); + }; + + renderRemoteClusterDoesNotExist = (name) => { + const { intl, currentUrl } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.forms.remoteClusterNotFoundTitle', + defaultMessage: `Couldn't find remote cluster '{name}'`, + }, { name }); + + return ( + +

+ { this.errorMessages.remoteClusterDoesNotExist(name) } +

+ + + +
+ ); + } + + renderErrorMessage = () => { + const { selected, remoteClusters, isEditable } = this.props; + const remoteCluster = remoteClusters.find(c => c.name === selected); + const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected; + let error; + + if (isEditable) { + /* Create */ + const hasClusters = Boolean(remoteClusters.length); + if (hasClusters && !isSelectedRemoteClusterConnected) { + error = this.renderCurrentRemoteClusterNotConnected(selected); + } else if (!hasClusters) { + error = this.renderNoClusterFound(); + } + } else { + /* Edit */ + const doesExists = !!remoteCluster; + if (!doesExists) { + error = this.renderRemoteClusterDoesNotExist(selected); + } else if (!isSelectedRemoteClusterConnected) { + error = this.renderCurrentRemoteClusterNotConnected(selected, true); + } + } + + return error ? ( + + + {error} + + ) : null; + } + + render() { + const { remoteClusters, selected, isEditable, areErrorsVisible } = this.props; + const remoteCluster = remoteClusters.find(c => c.name === selected); + const hasClusters = Boolean(remoteClusters.length); + const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected; + const isInvalid = areErrorsVisible && (!hasClusters || !isSelectedRemoteClusterConnected); + let field; + + if(isEditable) { + if(hasClusters) { + field = this.renderDropdown(); + } else { + field = this.renderErrorMessage(); + } + } else { + field = this.renderNotEditable(); + } + + return ( + + )} + isInvalid={isInvalid} + fullWidth + > + + {field} + + + ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/section_error.js b/x-pack/plugins/cross_cluster_replication/public/app/components/section_error.js index 922a2178c44ee8..4e4abfa571c707 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/section_error.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/section_error.js @@ -6,8 +6,8 @@ import React, { Fragment } from 'react'; import { - EuiSpacer, EuiCallOut, + EuiSpacer, } from '@elastic/eui'; export function SectionError({ title, error }) { @@ -18,23 +18,20 @@ export function SectionError({ title, error }) { } = error.data; return ( - - -
{message || errorString}
- { cause && ( - - -
    - { cause.map((message, i) =>
  • {message}
  • ) } -
-
- )} -
- -
+ +
{message || errorString}
+ { cause && ( + + +
    + { cause.map((message, i) =>
  • {message}
  • ) } +
+
+ )} +
); } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js b/x-pack/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js index 958065e87424eb..90737613479d43 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/section_unauthorized.js @@ -5,15 +5,10 @@ */ import React, { Fragment } from 'react'; -import { injectI18n } from '@kbn/i18n/react'; import { EuiCallOut } from '@elastic/eui'; -export function SectionUnauthorizedUI({ intl, children }) { - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.remoteClusterList.noPermissionTitle', - defaultMessage: 'Permission error', - }); +export function SectionUnauthorized({ title, children }) { return ( ); } - -export const SectionUnauthorized = injectI18n(SectionUnauthorizedUI); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/constants/sections.js b/x-pack/plugins/cross_cluster_replication/public/app/constants/sections.js index 8d8848ed08acbb..b452b564e94fa4 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/constants/sections.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/constants/sections.js @@ -6,7 +6,7 @@ export const SECTIONS = { AUTO_FOLLOW_PATTERN: 'autoFollowPattern', - INDEX_FOLLOWER: 'indexFollower', + FOLLOWER_INDEX: 'followerIndex', REMOTE_CLUSTER: 'remoteCluster', CCR_STATS: 'ccrStats', }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js index 6cee0f21236b20..f7d47b57dac33d 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js @@ -14,8 +14,8 @@ import { AutoFollowPatternAdd as AutoFollowPatternAddView } from './auto_follow_ const scope = SECTIONS.AUTO_FOLLOW_PATTERN; const mapStateToProps = (state) => ({ - apiStatus: getApiStatus(scope)(state), - apiError: getApiError(scope)(state), + apiStatus: getApiStatus(`${scope}-save`)(state), + apiError: getApiError(`${scope}-save`)(state), }); const mapDispatchToProps = dispatch => ({ diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js index 1e819cac60ecdc..23b7a5b77295e5 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import chrome from 'ui/chrome'; @@ -12,19 +12,14 @@ import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageContent, - EuiButton, - EuiCallOut, } from '@elastic/eui'; import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; -import routing from '../../services/routing'; -import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, RemoteClustersProvider, SectionLoading, - SectionError, } from '../../components'; export const AutoFollowPatternAdd = injectI18n( @@ -44,80 +39,8 @@ export const AutoFollowPatternAdd = injectI18n( this.props.clearApiError(); } - renderEmptyClusters() { - const { intl, match: { url: currentUrl } } = this.props; - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.emptyRemoteClustersCallOutTitle', - defaultMessage: 'No remote cluster found' - }); - - return ( - - -

- -

- - - - -
-
- ); - } - - renderNoConnectedCluster() { - const { intl } = this.props; - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.noRemoteClustersConnectedCallOutTitle', - defaultMessage: 'Remote cluster connection error' - }); - - return ( - - -

- -

- - - -
-
- ); - } - render() { - const { saveAutoFollowPattern, apiStatus, apiError, intl } = this.props; + const { saveAutoFollowPattern, apiStatus, apiError, match: { url: currentUrl } } = this.props; return ( @@ -143,28 +66,19 @@ export const AutoFollowPatternAdd = injectI18n( ); } - if (error) { - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.loadingRemoteClustersErrorTitle', - defaultMessage: 'Error loading remote clusters', - }); - return ; - } - - if (!remoteClusters.length) { - return this.renderEmptyClusters(); - } - - if (remoteClusters.every(cluster => cluster.isConnected === false)) { - return this.renderNoConnectedCluster(); - } - return ( + )} /> ); }} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js index 26a312692e5efa..e0be41afe439d1 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js @@ -7,22 +7,38 @@ import { connect } from 'react-redux'; import { SECTIONS } from '../../constants'; -import { getApiStatus, getApiError, getSelectedAutoFollowPattern } from '../../store/selectors'; -import { getAutoFollowPattern, saveAutoFollowPattern, clearApiError } from '../../store/actions'; +import { + getApiStatus, + getApiError, + getSelectedAutoFollowPatternId, + getSelectedAutoFollowPattern, +} from '../../store/selectors'; +import { getAutoFollowPattern, saveAutoFollowPattern, selectEditAutoFollowPattern, clearApiError } from '../../store/actions'; import { AutoFollowPatternEdit as AutoFollowPatternEditView } from './auto_follow_pattern_edit'; const scope = SECTIONS.AUTO_FOLLOW_PATTERN; const mapStateToProps = (state) => ({ - apiStatus: getApiStatus(scope)(state), - apiError: getApiError(scope)(state), - autoFollowPattern: getSelectedAutoFollowPattern(state), + apiStatus: { + get: getApiStatus(`${scope}-get`)(state), + save: getApiStatus(`${scope}-save`)(state), + }, + apiError: { + get: getApiError(`${scope}-get`)(state), + save: getApiError(`${scope}-save`)(state), + }, + autoFollowPatternId: getSelectedAutoFollowPatternId('edit')(state), + autoFollowPattern: getSelectedAutoFollowPattern('edit')(state), }); const mapDispatchToProps = dispatch => ({ getAutoFollowPattern: (id) => dispatch(getAutoFollowPattern(id)), + selectAutoFollowPattern: (id) => dispatch(selectEditAutoFollowPattern(id)), saveAutoFollowPattern: (id, autoFollowPattern) => dispatch(saveAutoFollowPattern(id, autoFollowPattern, true)), - clearApiError: () => dispatch(clearApiError(scope)), + clearApiError: () => { + dispatch(clearApiError(`${scope}-get`)); + dispatch(clearApiError(`${scope}-save`)); + }, }); export const AutoFollowPatternEdit = connect( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index 46a802e9c228fb..fe365d0315efd4 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -11,19 +11,15 @@ import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { - EuiPage, - EuiPageBody, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, EuiPageContent, EuiSpacer, - EuiButton, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem } from '@elastic/eui'; import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; -import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, @@ -37,49 +33,78 @@ export const AutoFollowPatternEdit = injectI18n( class extends PureComponent { static propTypes = { getAutoFollowPattern: PropTypes.func.isRequired, + selectAutoFollowPattern: PropTypes.func.isRequired, saveAutoFollowPattern: PropTypes.func.isRequired, clearApiError: PropTypes.func.isRequired, - apiError: PropTypes.object, - apiStatus: PropTypes.string.isRequired, + apiError: PropTypes.object.isRequired, + apiStatus: PropTypes.object.isRequired, + autoFollowPattern: PropTypes.object, + autoFollowPatternId: PropTypes.string, } - componentDidMount() { - const { autoFollowPattern, match: { params: { id } } } = this.props; - if (!autoFollowPattern) { - const decodedId = decodeURIComponent(id); - this.props.getAutoFollowPattern(decodedId); + static getDerivedStateFromProps({ autoFollowPatternId }, { lastAutoFollowPatternId }) { + if (lastAutoFollowPatternId !== autoFollowPatternId) { + return { lastAutoFollowPatternId: autoFollowPatternId }; } + return null; + } + + state = { lastAutoFollowPatternId: undefined } + + componentDidMount() { + const { match: { params: { id } }, selectAutoFollowPattern } = this.props; + const decodedId = decodeURIComponent(id); + + selectAutoFollowPattern(decodedId); chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb ]); } + componentDidUpdate(prevProps, prevState) { + const { autoFollowPattern, getAutoFollowPattern } = this.props; + // Fetch the auto-follow pattern on the server if we don't have it (i.e. page reload) + if (!autoFollowPattern && prevState.lastAutoFollowPatternId !== this.state.lastAutoFollowPatternId) { + getAutoFollowPattern(this.state.lastAutoFollowPatternId); + } + } + componentWillUnmount() { this.props.clearApiError(); } - renderApiError(error) { - const { intl } = this.props; + renderGetAutoFollowPatternError(error) { + const { intl, match: { params: { id: name } } } = this.props; const title = intl.formatMessage({ id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorTitle', defaultMessage: 'Error loading auto-follow pattern', }); + const errorMessage = error.status === 404 ? { + data: { + error: intl.formatMessage({ + id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorMessage', + defaultMessage: `The auto-follow pattern '{name}' does not exist.`, + }, { name }) + } + } : error; return ( - - - + + + + + - - + @@ -97,110 +122,62 @@ export const AutoFollowPatternEdit = injectI18n( ); } - renderMissingCluster({ name, remoteCluster }) { - const { intl } = this.props; - - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.emptyRemoteClustersTitle', - defaultMessage: 'Remote cluster missing' - }); + render() { + const { saveAutoFollowPattern, apiStatus, apiError, autoFollowPattern, match: { url: currentUrl } } = this.props; return ( - - -

- - -

- + + - -
-
- ); - } + )} + /> - render() { - const { saveAutoFollowPattern, apiStatus, apiError, autoFollowPattern, intl } = this.props; + {apiStatus.get === API_STATUS.LOADING && this.renderLoadingAutoFollowPattern()} - return ( - - - - - )} - /> - {apiStatus === API_STATUS.LOADING && this.renderLoadingAutoFollowPattern()} - - {apiError && this.renderApiError(apiError)} - - {autoFollowPattern && ( - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - - - - ); - } - - if (error) { - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClustersErrorTitle', - defaultMessage: 'Error loading remote clusters', - }); - return ; - } - - const autoFollowPatternCluster = remoteClusters.find(cluster => cluster.name === autoFollowPattern.remoteCluster); - - if (!autoFollowPatternCluster || !autoFollowPatternCluster.isConnected) { - return this.renderMissingCluster(autoFollowPattern); - } - - return ( - + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + - ); - }} - - )} - - - + + ); + } + + return ( + + )} + /> + ); + }} + + )} +
); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js new file mode 100644 index 00000000000000..d63ae84b0bf6bc --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; + +import { SECTIONS } from '../../constants'; +import { getApiStatus, getApiError } from '../../store/selectors'; +import { saveFollowerIndex, clearApiError } from '../../store/actions'; +import { FollowerIndexAdd as FollowerIndexAddView } from './follower_index_add'; + +const scope = SECTIONS.FOLLOWER_INDEX; + +const mapStateToProps = (state) => ({ + apiStatus: getApiStatus(`${scope}-save`)(state), + apiError: getApiError(`${scope}-save`)(state), +}); + +const mapDispatchToProps = dispatch => ({ + saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex)), + clearApiError: () => dispatch(clearApiError(`${scope}-save`)), +}); + +export const FollowerIndexAdd = connect( + mapStateToProps, + mapDispatchToProps +)(FollowerIndexAddView); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js new file mode 100644 index 00000000000000..e335d1a1318e07 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import chrome from 'ui/chrome'; +import { MANAGEMENT_BREADCRUMB } from 'ui/management'; + +import { + EuiPageContent, +} from '@elastic/eui'; + +import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; +import { + FollowerIndexForm, + FollowerIndexPageTitle, + RemoteClustersProvider, + SectionLoading, +} from '../../components'; + +export const FollowerIndexAdd = injectI18n( + class extends PureComponent { + static propTypes = { + saveFollowerIndex: PropTypes.func.isRequired, + clearApiError: PropTypes.func.isRequired, + apiError: PropTypes.object, + apiStatus: PropTypes.string.isRequired, + } + + componentDidMount() { + chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb ]); + } + + componentWillUnmount() { + this.props.clearApiError(); + } + + render() { + const { saveFollowerIndex, clearApiError, apiStatus, apiError, match: { url: currentUrl } } = this.props; + + return ( + + + )} + /> + + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + + + ); + } + + return ( + + )} + /> + ); + }} + + + ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js new file mode 100644 index 00000000000000..f7433f828fbbaa --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { FollowerIndexAdd } from './follower_index_add.container'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js new file mode 100644 index 00000000000000..84e03cf4a8043b --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; + +import { SECTIONS } from '../../constants'; +import { + getApiStatus, + getApiError, + getSelectedFollowerIndexId, + getSelectedFollowerIndex, +} from '../../store/selectors'; +import { + saveFollowerIndex, + clearApiError, + getFollowerIndex, + selectEditFollowerIndex, +} from '../../store/actions'; +import { FollowerIndexEdit as FollowerIndexEditView } from './follower_index_edit'; + +const scope = SECTIONS.FOLLOWER_INDEX; + +const mapStateToProps = (state) => ({ + apiStatus: { + get: getApiStatus(`${scope}-get`)(state), + save: getApiStatus(`${scope}-save`)(state), + }, + apiError: { + get: getApiError(`${scope}-get`)(state), + save: getApiError(`${scope}-save`)(state), + }, + followerIndexId: getSelectedFollowerIndexId('edit')(state), + followerIndex: getSelectedFollowerIndex('edit')(state), +}); + +const mapDispatchToProps = dispatch => ({ + getFollowerIndex: (id) => dispatch(getFollowerIndex(id)), + selectFollowerIndex: (id) => dispatch(selectEditFollowerIndex(id)), + saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex, true)), + clearApiError: () => { + dispatch(clearApiError(`${scope}-get`)); + dispatch(clearApiError(`${scope}-save`)); + }, +}); + +export const FollowerIndexEdit = connect( + mapStateToProps, + mapDispatchToProps +)(FollowerIndexEditView); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js new file mode 100644 index 00000000000000..48d8e1894e9a42 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -0,0 +1,275 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import chrome from 'ui/chrome'; +import { MANAGEMENT_BREADCRUMB } from 'ui/management'; + +import { + EuiButtonEmpty, + EuiConfirmModal, + EuiFlexGroup, + EuiFlexItem, + EuiOverlayMask, + EuiPageContent, + EuiSpacer, +} from '@elastic/eui'; + +import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; +import routing from '../../services/routing'; +import { + FollowerIndexForm, + FollowerIndexPageTitle, + SectionLoading, + SectionError, + RemoteClustersProvider, +} from '../../components'; +import { API_STATUS } from '../../constants'; + +export const FollowerIndexEdit = injectI18n( + class extends PureComponent { + static propTypes = { + getFollowerIndex: PropTypes.func.isRequired, + selectFollowerIndex: PropTypes.func.isRequired, + saveFollowerIndex: PropTypes.func.isRequired, + clearApiError: PropTypes.func.isRequired, + apiError: PropTypes.object.isRequired, + apiStatus: PropTypes.object.isRequired, + followerIndex: PropTypes.object, + followerIndexId: PropTypes.string, + } + + static getDerivedStateFromProps({ followerIndexId }, { lastFollowerIndexId }) { + if (lastFollowerIndexId !== followerIndexId) { + return { lastFollowerIndexId: followerIndexId }; + } + return null; + } + + state = { + lastFollowerIndexId: undefined, + showConfirmModal: false, + } + + componentDidMount() { + const { match: { params: { id } }, selectFollowerIndex } = this.props; + let decodedId; + try { + // When we navigate through the router (history.push) we need to decode both the uri and the id + decodedId = decodeURI(id); + decodedId = decodeURIComponent(decodedId); + } catch (e) { + // This is a page load. I guess that AngularJS router does already a decodeURI so it is not + // necessary in this case. + decodedId = decodeURIComponent(id); + } + + selectFollowerIndex(decodedId); + + chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb ]); + } + + componentDidUpdate(prevProps, prevState) { + const { followerIndex, getFollowerIndex } = this.props; + // Fetch the follower index on the server if we don't have it (i.e. page reload) + if (!followerIndex && prevState.lastFollowerIndexId !== this.state.lastFollowerIndexId) { + getFollowerIndex(this.state.lastFollowerIndexId); + } + } + + componentWillUnmount() { + this.props.clearApiError(); + } + + saveFollowerIndex = (name, followerIndex) => { + this.editedFollowerIndexPayload = { name, followerIndex }; + this.showConfirmModal(); + } + + confirmSaveFollowerIhdex = () => { + const { name, followerIndex } = this.editedFollowerIndexPayload; + this.props.saveFollowerIndex(name, followerIndex); + this.closeConfirmModal(); + } + + showConfirmModal = () => this.setState({ showConfirmModal: true }); + + closeConfirmModal = () => this.setState({ showConfirmModal: false }); + + renderLoadingFollowerIndex() { + return ( + + + + ); + } + + renderGetFollowerIndexError(error) { + const { intl, match: { params: { id: name } } } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorTitle', + defaultMessage: 'Error loading follower index', + }); + const errorMessage = error.status === 404 ? { + data: { + error: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorMessage', + defaultMessage: `The follower index '{name}' does not exist.`, + }, { name }) + } + } : error; + + return ( + + + + + + + + + + + + + + ); + } + + renderConfirmModal = () => { + const { followerIndexId, intl, followerIndex: { isPaused } } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.title', + defaultMessage: 'Update follower index \'{id}\'?', + }, { id: followerIndexId }); + + return ( + + + ) : ( + + )} + > +

+ {isPaused ? ( + + ) : ( + + )} +

+
+
+ ); + } + + render() { + const { + clearApiError, + apiStatus, + apiError, + followerIndex, + match: { url: currentUrl } + } = this.props; + + const { showConfirmModal } = this.state; + + /* remove non-editable properties */ + const { shards, ...rest } = followerIndex || {}; // eslint-disable-line no-unused-vars + + return ( + + + )} + /> + + {apiStatus.get === API_STATUS.LOADING && this.renderLoadingFollowerIndex()} + + {apiError.get && this.renderGetFollowerIndexError(apiError.get)} + { followerIndex && ( + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + + + ); + } + + return ( + + )} + /> + ); + }} + + ) } + + { showConfirmModal && this.renderConfirmModal() } + + ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js new file mode 100644 index 00000000000000..7bc01ebd874e84 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { FollowerIndexEdit } from './follower_index_edit.container'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js index eb2ba69c3ed990..dc5fe2c6ea0d66 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js @@ -9,15 +9,14 @@ import { connect } from 'react-redux'; import { SECTIONS } from '../../../constants'; import { getListAutoFollowPatterns, + getSelectedAutoFollowPatternId, getApiStatus, getApiError, isApiAuthorized, - isAutoFollowPatternDetailPanelOpen as isDetailPanelOpen, } from '../../../store/selectors'; import { loadAutoFollowPatterns, - openAutoFollowPatternDetailPanel as openDetailPanel, - closeAutoFollowPatternDetailPanel as closeDetailPanel, + selectDetailAutoFollowPattern, loadAutoFollowStats, } from '../../../store/actions'; import { AutoFollowPatternList as AutoFollowPatternListView } from './auto_follow_pattern_list'; @@ -26,20 +25,15 @@ const scope = SECTIONS.AUTO_FOLLOW_PATTERN; const mapStateToProps = (state) => ({ autoFollowPatterns: getListAutoFollowPatterns(state), + autoFollowPatternId: getSelectedAutoFollowPatternId('detail')(state), apiStatus: getApiStatus(scope)(state), apiError: getApiError(scope)(state), isAuthorized: isApiAuthorized(scope)(state), - isDetailPanelOpen: isDetailPanelOpen(state), }); const mapDispatchToProps = dispatch => ({ loadAutoFollowPatterns: (inBackground) => dispatch(loadAutoFollowPatterns(inBackground)), - openDetailPanel: (name) => { - dispatch(openDetailPanel(name)); - }, - closeDetailPanel: () => { - dispatch(closeDetailPanel()); - }, + selectAutoFollowPattern: (id) => dispatch(selectDetailAutoFollowPattern(id)), loadAutoFollowStats: () => dispatch(loadAutoFollowStats()) }); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js index f20ae97ac7d7f5..d9243be3c774a4 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js @@ -7,61 +7,171 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; -import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer, +} from '@elastic/eui'; import routing from '../../../services/routing'; import { extractQueryParams } from '../../../services/query_params'; import { API_STATUS } from '../../../constants'; -import { SectionLoading, SectionError } from '../../../components'; +import { SectionLoading, SectionError, SectionUnauthorized } from '../../../components'; import { AutoFollowPatternTable, DetailPanel } from './components'; const REFRESH_RATE_MS = 30000; +const getQueryParamPattern = ({ location: { search } }) => { + const { pattern } = extractQueryParams(search); + return pattern ? decodeURIComponent(pattern) : null; +}; + export const AutoFollowPatternList = injectI18n( class extends PureComponent { static propTypes = { loadAutoFollowPatterns: PropTypes.func, + selectAutoFollowPattern: PropTypes.func, loadAutoFollowStats: PropTypes.func, autoFollowPatterns: PropTypes.array, apiStatus: PropTypes.string, apiError: PropTypes.object, - openDetailPanel: PropTypes.func.isRequired, - closeDetailPanel: PropTypes.func.isRequired, - isDetailPanelOpen: PropTypes.bool, } + static getDerivedStateFromProps({ autoFollowPatternId }, { lastAutoFollowPatternId }) { + if (autoFollowPatternId !== lastAutoFollowPatternId) { + return { + lastAutoFollowPatternId: autoFollowPatternId, + isDetailPanelOpen: !!autoFollowPatternId, + }; + } + return null; + } + + state = { + lastAutoFollowPatternId: null, + isDetailPanelOpen: false, + }; + componentDidMount() { - this.props.loadAutoFollowPatterns(); - this.props.loadAutoFollowStats(); + const { loadAutoFollowPatterns, loadAutoFollowStats, selectAutoFollowPattern, history } = this.props; + + loadAutoFollowPatterns(); + loadAutoFollowStats(); + + // Select the pattern in the URL query params + selectAutoFollowPattern(getQueryParamPattern(history)); // Interval to load auto-follow patterns in the background passing "true" to the fetch method - this.interval = setInterval(() => this.props.loadAutoFollowPatterns(true), REFRESH_RATE_MS); + this.interval = setInterval(() => loadAutoFollowPatterns(true), REFRESH_RATE_MS); + } + + componentDidUpdate(prevProps, prevState) { + const { history, loadAutoFollowStats } = this.props; + const { lastAutoFollowPatternId } = this.state; + + /** + * Each time our state is updated (through getDerivedStateFromProps()) + * we persist the auto-follow pattern id to query params for deep linking + */ + if (lastAutoFollowPatternId !== prevState.lastAutoFollowPatternId) { + if(!lastAutoFollowPatternId) { + history.replace({ + search: '', + }); + } else { + history.replace({ + search: `?pattern=${encodeURIComponent(lastAutoFollowPatternId)}`, + }); + + loadAutoFollowStats(); + } + } } componentWillUnmount() { clearInterval(this.interval); } - componentDidUpdate() { - const { - openDetailPanel, - closeDetailPanel, - isDetailPanelOpen, - history: { - location: { - search, - }, - }, - } = this.props; + renderHeader() { + const { isAuthorized } = this.props; + return ( + + + + +

+ +

+
+
- const { pattern: patternName } = extractQueryParams(search); + + {isAuthorized && ( + + + + )} + +
- // Show deeplinked auto follow pattern whenever patterns get loaded or the URL changes. - if (patternName != null) { - openDetailPanel(patternName); - } else if (isDetailPanelOpen) { - closeDetailPanel(); + +
+ ); + } + + renderContent(isEmpty) { + const { apiError, isAuthorized, intl } = this.props; + if (!isAuthorized) { + return ( + + )} + > + + + ); } + + if (apiError) { + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.autoFollowPatternList.loadingErrorTitle', + defaultMessage: 'Error loading auto-follow patterns', + }); + + return ( + + + + + ); + } + + if (isEmpty) { + return this.renderEmpty(); + } + + return this.renderList(); } renderEmpty() { @@ -104,7 +214,13 @@ export const AutoFollowPatternList = injectI18n( } renderList() { - const { autoFollowPatterns, apiStatus } = this.props; + const { + selectAutoFollowPattern, + autoFollowPatterns, + apiStatus, + } = this.props; + + const { isDetailPanelOpen } = this.state; if (apiStatus === API_STATUS.LOADING) { return ( @@ -120,31 +236,21 @@ export const AutoFollowPatternList = injectI18n( return ( - + {isDetailPanelOpen && selectAutoFollowPattern(null)} />} ); } render() { - const { autoFollowPatterns, apiStatus, apiError, isAuthorized, intl } = this.props; - - if (!isAuthorized) { - return null; - } + const { autoFollowPatterns, apiStatus, } = this.props; + const isEmpty = apiStatus === API_STATUS.IDLE && !autoFollowPatterns.length; - if (apiStatus === API_STATUS.IDLE && !autoFollowPatterns.length) { - return this.renderEmpty(); - } - - if (apiError) { - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternList.loadingErrorTitle', - defaultMessage: 'Error loading auto-follow patterns', - }); - return ; - } - - return this.renderList(); + return ( + + {!isEmpty && this.renderHeader()} + {this.renderContent(isEmpty)} + + ); } } ); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js index f631533f68385e..d80bf65bd00138 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js @@ -7,10 +7,7 @@ import { connect } from 'react-redux'; import { SECTIONS } from '../../../../../constants'; -import { - editAutoFollowPattern, - openAutoFollowPatternDetailPanel as openDetailPanel, -} from '../../../../../store/actions'; +import { selectDetailAutoFollowPattern } from '../../../../../store/actions'; import { getApiStatus } from '../../../../../store/selectors'; import { AutoFollowPatternTable as AutoFollowPatternTableComponent } from './auto_follow_pattern_table'; @@ -21,10 +18,7 @@ const mapStateToProps = (state) => ({ }); const mapDispatchToProps = (dispatch) => ({ - editAutoFollowPattern: (name) => dispatch(editAutoFollowPattern(name)), - openDetailPanel: (name) => { - dispatch(openDetailPanel(name)); - }, + selectAutoFollowPattern: (name) => dispatch(selectDetailAutoFollowPattern(name)), }); export const AutoFollowPatternTable = connect( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js index fa42a80b4ecd4e..58ea095782836a 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js @@ -25,7 +25,7 @@ export const AutoFollowPatternTable = injectI18n( class extends PureComponent { static propTypes = { autoFollowPatterns: PropTypes.array, - openDetailPanel: PropTypes.func.isRequired, + selectAutoFollowPattern: PropTypes.func.isRequired, } state = { @@ -61,7 +61,7 @@ export const AutoFollowPatternTable = injectI18n( }; getTableColumns() { - const { intl, editAutoFollowPattern, openDetailPanel } = this.props; + const { intl, selectAutoFollowPattern } = this.props; return [{ field: 'name', @@ -73,7 +73,7 @@ export const AutoFollowPatternTable = injectI18n( truncateText: false, render: (name) => { return ( - openDetailPanel(name)}> + selectAutoFollowPattern(name)}> {name} ); @@ -82,7 +82,7 @@ export const AutoFollowPatternTable = injectI18n( field: 'remoteCluster', name: intl.formatMessage({ id: 'xpack.crossClusterReplication.autoFollowPatternList.table.clusterColumnTitle', - defaultMessage: 'Cluster', + defaultMessage: 'Remote cluster', }), truncateText: true, sortable: true, @@ -97,14 +97,14 @@ export const AutoFollowPatternTable = injectI18n( field: 'followIndexPatternPrefix', name: intl.formatMessage({ id: 'xpack.crossClusterReplication.autoFollowPatternList.table.prefixColumnTitle', - defaultMessage: 'Follower pattern prefix', + defaultMessage: 'Follower index prefix', }), sortable: true, }, { field: 'followIndexPatternSuffix', name: intl.formatMessage({ id: 'xpack.crossClusterReplication.autoFollowPatternList.table.suffixColumnTitle', - defaultMessage: 'Follower pattern suffix', + defaultMessage: 'Follower index suffix', }), sortable: true, }, { @@ -116,7 +116,7 @@ export const AutoFollowPatternTable = injectI18n( { render: ({ name }) => { const label = i18n.translate( - 'xpack.crossClusterReplication.autofollowPatternList.table.actionDeleteDescription', + 'xpack.crossClusterReplication.autoFollowPatternList.table.actionDeleteDescription', { defaultMessage: 'Delete auto-follow pattern', } @@ -142,20 +142,25 @@ export const AutoFollowPatternTable = injectI18n( }, }, { - name: intl.formatMessage({ - id: 'xpack.crossClusterReplication.editIndexPattern.fields.table.actionEditLabel', - defaultMessage: 'Edit', - }), - description: intl.formatMessage({ - id: 'xpack.crossClusterReplication.editIndexPattern.fields.table.actionEditDescription', - defaultMessage: 'Edit', - }), - icon: 'pencil', - onClick: ({ name }) => { - editAutoFollowPattern(name); - routing.navigate(encodeURI(`/auto_follow_patterns/edit/${encodeURIComponent(name)}`)); + render: ({ name }) => { + const label = i18n.translate('xpack.crossClusterReplication.autoFollowPatternList.table.actionEditDescription', { + defaultMessage: 'Edit auto-follow pattern', + }); + + return ( + + + + ); }, - type: 'icon', }, ], width: '100px', diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js index 3f8183eb5158e6..64468003887291 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js @@ -7,45 +7,17 @@ import { connect } from 'react-redux'; import { DetailPanel as DetailPanelView } from './detail_panel'; -import { - getDetailPanelAutoFollowPattern, - getDetailPanelAutoFollowPatternName, - getApiStatus, - isAutoFollowPatternDetailPanelOpen as isDetailPanelOpen, -} from '../../../../../store/selectors'; - -import { - closeAutoFollowPatternDetailPanel as closeDetailPanel, - editAutoFollowPattern, -} from '../../../../../store/actions'; - -import { - SECTIONS -} from '../../../../../constants'; +import { getSelectedAutoFollowPattern, getSelectedAutoFollowPatternId, getApiStatus, } from '../../../../../store/selectors'; +import { SECTIONS } from '../../../../../constants'; const scope = SECTIONS.AUTO_FOLLOW_PATTERN; -const mapStateToProps = (state) => { - return { - isDetailPanelOpen: isDetailPanelOpen(state), - autoFollowPattern: getDetailPanelAutoFollowPattern(state), - autoFollowPatternName: getDetailPanelAutoFollowPatternName(state), - apiStatus: getApiStatus(scope)(state), - }; -}; - -const mapDispatchToProps = (dispatch) => { - return { - closeDetailPanel: () => { - dispatch(closeDetailPanel()); - }, - editAutoFollowPattern: (name) => { - dispatch(editAutoFollowPattern(name)); - } - }; -}; +const mapStateToProps = (state) => ({ + autoFollowPatternId: getSelectedAutoFollowPatternId('detail')(state), + autoFollowPattern: getSelectedAutoFollowPattern('detail')(state), + apiStatus: getApiStatus(scope)(state), +}); export const DetailPanel = connect( mapStateToProps, - mapDispatchToProps )(DetailPanelView); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js index 2af00a8babd323..99e2d4083e96e3 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js @@ -41,12 +41,10 @@ import routing from '../../../../../services/routing'; export class DetailPanelUi extends Component { static propTypes = { - isDetailPanelOpen: PropTypes.bool.isRequired, apiStatus: PropTypes.string, + autoFollowPatternId: PropTypes.string, autoFollowPattern: PropTypes.object, - autoFollowPatternName: PropTypes.string, closeDetailPanel: PropTypes.func.isRequired, - editAutoFollowPattern: PropTypes.func.isRequired, } renderAutoFollowPattern() { @@ -235,7 +233,7 @@ export class DetailPanelUi extends Component { autoFollowPattern, } = this.props; - if(apiStatus === API_STATUS.LOADING) { + if (apiStatus === API_STATUS.LOADING) { return ( @@ -320,56 +312,47 @@ export class DetailPanelUi extends Component { - - - - - {(deleteAutoFollowPattern) => ( - deleteAutoFollowPattern(autoFollowPatternName)} - > - - - )} - - - - - { - editAutoFollowPattern(autoFollowPatternName); - routing.navigate(encodeURI(`/auto_follow_patterns/edit/${encodeURIComponent(autoFollowPatternName)}`)); - }} - > - - - - - + {autoFollowPattern && ( + + + + + {(deleteAutoFollowPattern) => ( + deleteAutoFollowPattern(autoFollowPattern.name)} + > + + + )} + + + + + + + + + + + )} ); } render() { - const { - isDetailPanelOpen, - closeDetailPanel, - autoFollowPatternName, - } = this.props; - - if (!isDetailPanelOpen) { - return null; - } + const { autoFollowPatternId, closeDetailPanel } = this.props; return ( + -

{autoFollowPatternName}

+

{autoFollowPatternId}

{this.renderContent()} - {this.renderFooter()}
); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js new file mode 100644 index 00000000000000..2f3ffb4cedb74e --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent, Fragment } from 'react'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import PropTypes from 'prop-types'; +import { + EuiButton, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; + +import routing from '../../../../../services/routing'; +import { + FollowerIndexPauseProvider, + FollowerIndexResumeProvider, + FollowerIndexUnfollowProvider +} from '../../../../../components'; + +export class ContextMenuUi extends PureComponent { + + static propTypes = { + iconSide: PropTypes.string, + iconType: PropTypes.string, + anchorPosition: PropTypes.string, + label: PropTypes.node, + followerIndices: PropTypes.array.isRequired, + } + + state = { + isPopoverOpen: false, + } + + onButtonClick = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen + })); + }; + + closePopover = () => { + this.setState({ + isPopoverOpen: false + }); + }; + + editFollowerIndex = (id) => { + const uri = routing.getFollowerIndexPath(id, '/edit', false); + routing.navigate(uri); + } + + render() { + const { followerIndices } = this.props; + const followerIndicesLength = followerIndices.length; + const followerIndexNames = followerIndices.map((index) => index.name); + const { + iconSide = 'right', + iconType = 'arrowDown', + anchorPosition = 'rightUp', + label = ( + + ), + } = this.props; + + + const button = ( + + {label} + + ); + + const pausedFollowerIndexNames = followerIndices.filter(({ isPaused }) => isPaused).map((index) => index.name); + const activeFollowerIndices = followerIndices.filter(({ isPaused }) => !isPaused); + + return ( + + + + + + + { + activeFollowerIndices.length ? ( + + {(pauseFollowerIndex) => ( + pauseFollowerIndex(activeFollowerIndices)} + > + + + )} + + ) : null + } + + { + pausedFollowerIndexNames.length ? ( + + {(resumeFollowerIndex) => ( + resumeFollowerIndex(pausedFollowerIndexNames)} + > + + + )} + + ) : null + } + + { followerIndexNames.length === 1 && ( + + this.editFollowerIndex(followerIndexNames[0])} + > + + + + ) } + + + {(unfollowLeaderIndex) => ( + unfollowLeaderIndex(followerIndexNames)} + > + + + )} + + + + ); + } +} + +export const ContextMenu = injectI18n(ContextMenuUi); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js new file mode 100644 index 00000000000000..8c2d3743ecfc6a --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ContextMenu } from './context_menu'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js new file mode 100644 index 00000000000000..6efb6ad1efbe7d --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { DetailPanel as DetailPanelView } from './detail_panel'; + +import { getSelectedFollowerIndex, getSelectedFollowerIndexId, getApiStatus, } from '../../../../../store/selectors'; +import { SECTIONS } from '../../../../../constants'; + +const scope = SECTIONS.FOLLOWER_INDEX; + +const mapStateToProps = (state) => ({ + followerIndexId: getSelectedFollowerIndexId('detail')(state), + followerIndex: getSelectedFollowerIndex('detail')(state), + apiStatus: getApiStatus(scope)(state), +}); + +export const DetailPanel = connect( + mapStateToProps, +)(DetailPanelView); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js new file mode 100644 index 00000000000000..4fc0f0f6c3873d --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js @@ -0,0 +1,522 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { getIndexListUri } from '../../../../../../../../index_management/public/services/navigation'; + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiCodeEditor, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiHealth, + EuiIcon, + EuiLoadingSpinner, + EuiSpacer, + EuiText, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; + +import 'brace/theme/textmate'; + +import { ContextMenu } from '../context_menu'; + +import { API_STATUS } from '../../../../../constants'; + +export class DetailPanelUi extends Component { + static propTypes = { + apiStatus: PropTypes.string, + followerIndexId: PropTypes.string, + followerIndex: PropTypes.object, + closeDetailPanel: PropTypes.func.isRequired, + } + + renderFollowerIndex() { + const { + followerIndex: { + remoteCluster, + leaderIndex, + isPaused, + shards, + maxReadRequestOperationCount, + maxOutstandingReadRequests, + maxReadRequestSize, + maxWriteRequestOperationCount, + maxWriteRequestSize, + maxOutstandingWriteRequests, + maxWriteBufferCount, + maxWriteBufferSize, + maxRetryDelay, + readPollTimeout, + }, + } = this.props; + + return ( + + + +

+ +

+
+ + + + + + + + + + + + + + {isPaused ? ( + + + + ) : ( + + + + )} + + + + + + + + + + + + + + + + {remoteCluster} + + + + + + + + + + + + {leaderIndex} + + + + + {isPaused ? ( + + + + } + /> + + ) : ( + + + + + + + + + + + + {maxReadRequestOperationCount} + + + + + + + + + + + + {maxOutstandingReadRequests} + + + + + + + + + + + + + + + + {maxReadRequestSize} + + + + + + + + + + + + {maxWriteRequestOperationCount} + + + + + + + + + + + + + + + + {maxWriteRequestSize} + + + + + + + + + + + + {maxOutstandingWriteRequests} + + + + + + + + + + + + + + + + {maxWriteBufferCount} + + + + + + + + + + + + {maxWriteBufferSize} + + + + + + + + + + + + + + + + {maxRetryDelay} + + + + + + + + + + + + {readPollTimeout} + + + + + )} + + + + {shards && shards.map((shard, i) => ( + + + +

+ +

+
+ + +
+ ))} +
+
+
+ ); + } + + renderContent() { + const { + apiStatus, + followerIndex, + } = this.props; + + if (apiStatus === API_STATUS.LOADING) { + return ( + + + + + + + + + + + + + + + + ); + } + + if (!followerIndex) { + return ( + + + + + + + + + + + + + + + + ); + } + + return this.renderFollowerIndex(); + } + + renderFooter() { + const { + followerIndexId, + followerIndex, + closeDetailPanel, + } = this.props; + + // Use ID instead of followerIndex, because followerIndex may not be loaded yet. + const indexManagementUri = getIndexListUri(`name:${followerIndexId}`); + + return ( + + + + + + + + + + + + + + + + + {followerIndex && ( + + + )} + followerIndices={[followerIndex]} + /> + + )} + + + + + ); + } + + render() { + const { followerIndexId, closeDetailPanel } = this.props; + + return ( + + + + +

{followerIndexId}

+
+
+ + {this.renderContent()} + {this.renderFooter()} +
+ ); + } +} + +export const DetailPanel = injectI18n(DetailPanelUi); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js new file mode 100644 index 00000000000000..c27bbd8ea830fb --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DetailPanel } from './detail_panel.container'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js new file mode 100644 index 00000000000000..ea6a4c9fd71d1a --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.container.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; + +import { SECTIONS } from '../../../../../constants'; +import { selectDetailFollowerIndex } from '../../../../../store/actions'; +import { getApiStatus } from '../../../../../store/selectors'; +import { FollowerIndicesTable as FollowerIndicesTableComponent } from './follower_indices_table'; + +const scope = SECTIONS.FOLLOWER_INDEX; + +const mapStateToProps = (state) => ({ + apiStatusDelete: getApiStatus(`${scope}-delete`)(state), +}); +// +const mapDispatchToProps = (dispatch) => ({ + selectFollowerIndex: (name) => dispatch(selectDetailFollowerIndex(name)), +}); + +export const FollowerIndicesTable = connect( + mapStateToProps, + mapDispatchToProps, +)(FollowerIndicesTableComponent); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js new file mode 100644 index 00000000000000..8fe4ecc4f34777 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js @@ -0,0 +1,292 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { + EuiHealth, + EuiIcon, + EuiInMemoryTable, + EuiLink, + EuiLoadingKibana, + EuiOverlayMask, +} from '@elastic/eui'; +import { API_STATUS } from '../../../../../constants'; +import { + FollowerIndexPauseProvider, + FollowerIndexResumeProvider, + FollowerIndexUnfollowProvider +} from '../../../../../components'; +import routing from '../../../../../services/routing'; +import { ContextMenu } from '../context_menu'; + +export const FollowerIndicesTable = injectI18n( + class extends PureComponent { + static propTypes = { + followerIndices: PropTypes.array, + selectFollowerIndex: PropTypes.func.isRequired, + } + + state = { + selectedItems: [], + } + + onSearch = ({ query }) => { + const { text } = query; + const normalizedSearchText = text.toLowerCase(); + this.setState({ + queryText: normalizedSearchText, + }); + }; + + editFollowerIndex = (id) => { + const uri = routing.getFollowerIndexPath(id, '/edit', false); + routing.navigate(uri); + } + + getFilteredIndices = () => { + const { followerIndices } = this.props; + const { queryText } = this.state; + + if(queryText) { + return followerIndices.filter(followerIndex => { + const { name, shards } = followerIndex; + + const inName = name.toLowerCase().includes(queryText); + const inRemoteCluster = shards[0].remoteCluster.toLowerCase().includes(queryText); + const inLeaderIndex = shards[0].leaderIndex.toLowerCase().includes(queryText); + + return inName || inRemoteCluster || inLeaderIndex; + }); + } + + return followerIndices.slice(0); + }; + + getTableColumns() { + const { intl, selectFollowerIndex } = this.props; + + const actions = [ + /* Pause or resume follower index */ + { + render: (followerIndex) => { + const { name, isPaused } = followerIndex; + const label = isPaused + ? intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionResumeDescription', + defaultMessage: 'Resume replication', + }) + : intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionPauseDescription', + defaultMessage: 'Pause replication', + }); + + return isPaused ? ( + + {(resumeFollowerIndex) => ( + resumeFollowerIndex(name)}> + + {label} + + )} + + ) : ( + + {(pauseFollowerIndex) => ( + pauseFollowerIndex(followerIndex)}> + + {label} + + )} + + ); + }, + }, + /* Edit follower index */ + { + render: ({ name }) => { + const label = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionEditDescription', + defaultMessage: 'Edit follower index', + }); + + return ( + this.editFollowerIndex(name)}> + + {label} + + ); + }, + }, + /* Unfollow leader index */ + { + render: ({ name }) => { + const label = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionUnfollowDescription', + defaultMessage: 'Unfollow leader index', + }); + + return ( + + {(unfollowLeaderIndex) => ( + unfollowLeaderIndex(name)}> + + {label} + + )} + + ); + }, + }, + ]; + + return [{ + field: 'name', + name: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.nameColumnTitle', + defaultMessage: 'Name', + }), + sortable: true, + truncateText: false, + render: (name) => { + return ( + selectFollowerIndex(name)}> + {name} + + ); + } + }, { + field: 'isPaused', + name: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.statusColumnTitle', + defaultMessage: 'Status', + }), + truncateText: true, + sortable: true, + render: (isPaused) => { + return isPaused ? ( + + + + ) : ( + + + + ); + } + }, { + field: 'remoteCluster', + name: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.clusterColumnTitle', + defaultMessage: 'Remote cluster', + }), + truncateText: true, + sortable: true, + }, { + field: 'leaderIndex', + name: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.leaderIndexColumnTitle', + defaultMessage: 'Leader index', + }), + truncateText: true, + sortable: true, + }, { + name: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionsColumnTitle', + defaultMessage: 'Actions', + }), + actions, + width: '100px', + }]; + } + + renderLoading = () => { + const { apiStatusDelete } = this.props; + + if (apiStatusDelete === API_STATUS.DELETING) { + return ( + + + + ); + } + return null; + }; + + render() { + const { + selectedItems, + } = this.state; + + const sorting = { + sort: { + field: 'name', + direction: 'asc', + } + }; + + const pagination = { + initialPageSize: 20, + pageSizeOptions: [10, 20, 50] + }; + + const selection = { + onSelectionChange: (selectedItems) => this.setState({ selectedItems }) + }; + + const search = { + toolsLeft: selectedItems.length ? ( + + ) : undefined, + onChange: this.onSearch, + box: { + incremental: true, + }, + }; + + return ( + + + {this.renderLoading()} + + ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js new file mode 100644 index 00000000000000..8ea9cd98336c3f --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { FollowerIndicesTable } from './follower_indices_table.container'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js new file mode 100644 index 00000000000000..d81a62e17a4b72 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { FollowerIndicesTable } from './follower_indices_table'; +export { DetailPanel } from './detail_panel'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js new file mode 100644 index 00000000000000..2baf716e7b90ad --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; + +import { SECTIONS } from '../../../constants'; +import { + getListFollowerIndices, + getSelectedFollowerIndexId, + getApiStatus, + getApiError, + isApiAuthorized, +} from '../../../store/selectors'; +import { + loadFollowerIndices, selectDetailFollowerIndex, +} from '../../../store/actions'; +import { FollowerIndicesList as FollowerIndicesListView } from './follower_indices_list'; + +const scope = SECTIONS.FOLLOWER_INDEX; + +const mapStateToProps = (state) => ({ + followerIndices: getListFollowerIndices(state), + followerIndexId: getSelectedFollowerIndexId('detail')(state), + apiStatus: getApiStatus(scope)(state), + apiError: getApiError(scope)(state), + isAuthorized: isApiAuthorized(scope)(state), +}); + +const mapDispatchToProps = dispatch => ({ + loadFollowerIndices: (inBackground) => dispatch(loadFollowerIndices(inBackground)), + selectFollowerIndex: (id) => dispatch(selectDetailFollowerIndex(id)), +}); + +export const FollowerIndicesList = connect( + mapStateToProps, + mapDispatchToProps +)(FollowerIndicesListView); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js new file mode 100644 index 00000000000000..693bb304ea3066 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { PureComponent, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer, +} from '@elastic/eui'; + +import routing from '../../../services/routing'; +import { extractQueryParams } from '../../../services/query_params'; +import { API_STATUS } from '../../../constants'; +import { SectionLoading, SectionError, SectionUnauthorized } from '../../../components'; +import { FollowerIndicesTable, DetailPanel } from './components'; + +const REFRESH_RATE_MS = 30000; + +const getQueryParamName = ({ location: { search } }) => { + const { name } = extractQueryParams(search); + return name ? decodeURIComponent(name) : null; +}; + +export const FollowerIndicesList = injectI18n( + class extends PureComponent { + static propTypes = { + loadFollowerIndices: PropTypes.func, + selectFollowerIndex: PropTypes.func, + followerIndices: PropTypes.array, + apiStatus: PropTypes.string, + apiError: PropTypes.object, + } + + static getDerivedStateFromProps({ followerIndexId }, { lastFollowerIndexId }) { + if (followerIndexId !== lastFollowerIndexId) { + return { + lastFollowerIndexId: followerIndexId, + isDetailPanelOpen: !!followerIndexId, + }; + } + return null; + } + + state = { + lastFollowerIndexId: null, + isDetailPanelOpen: false, + }; + + componentDidMount() { + const { loadFollowerIndices, selectFollowerIndex, history } = this.props; + + loadFollowerIndices(); + + // Select the pattern in the URL query params + selectFollowerIndex(getQueryParamName(history)); + + // Interval to load follower indices in the background passing "true" to the fetch method + this.interval = setInterval(() => loadFollowerIndices(true), REFRESH_RATE_MS); + } + + componentDidUpdate(prevProps, prevState) { + const { history } = this.props; + const { lastFollowerIndexId } = this.state; + + /** + * Each time our state is updated (through getDerivedStateFromProps()) + * we persist the follower index id to query params for deep linking + */ + if (lastFollowerIndexId !== prevState.lastFollowerIndexId) { + if(!lastFollowerIndexId) { + history.replace({ + search: '', + }); + } else { + history.replace({ + search: `?name=${encodeURIComponent(lastFollowerIndexId)}`, + }); + } + } + } + + componentWillUnmount() { + clearInterval(this.interval); + } + + renderHeader() { + const { isAuthorized } = this.props; + + return ( + + + + +

+ +

+
+
+ + + {isAuthorized && ( + + + + )} + +
+ + +
+ ); + } + + renderContent(isEmpty) { + const { apiError, isAuthorized, intl } = this.props; + + if (!isAuthorized) { + return ( + + )} + > + + + ); + } + + if (apiError) { + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.loadingErrorTitle', + defaultMessage: 'Error loading follower indices', + }); + + return ( + + + + + ); + } + + if (isEmpty) { + return this.renderEmpty(); + } + + return this.renderList(); + } + + renderEmpty() { + return ( + + + + )} + body={ + +

+ +

+
+ } + actions={ + + + + } + /> + ); + } + + renderList() { + const { + selectFollowerIndex, + followerIndices, + apiStatus, + } = this.props; + + const { isDetailPanelOpen } = this.state; + + if (apiStatus === API_STATUS.LOADING) { + return ( + + + + ); + } + + return ( + + + {isDetailPanelOpen && selectFollowerIndex(null)} />} + + ); + } + + render() { + const { followerIndices, apiStatus } = this.props; + const isEmpty = apiStatus === API_STATUS.IDLE && !followerIndices.length; + return ( + + {!isEmpty && this.renderHeader()} + {this.renderContent(isEmpty)} + + ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js new file mode 100644 index 00000000000000..08c799176f297f --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { FollowerIndicesList } from './follower_indices_list.container'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.container.js index b5e897f93fa58c..0ae54ddc611a6e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.container.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.container.js @@ -7,12 +7,14 @@ import { connect } from 'react-redux'; import { SECTIONS } from '../../constants'; -import { getListAutoFollowPatterns, isApiAuthorized } from '../../store/selectors'; +import { getListAutoFollowPatterns, getListFollowerIndices, isApiAuthorized } from '../../store/selectors'; import { CrossClusterReplicationHome as CrossClusterReplicationHomeView } from './home'; const mapStateToProps = (state) => ({ autoFollowPatterns: getListAutoFollowPatterns(state), - isAutoFollowApiAuthorized: isApiAuthorized(SECTIONS.AUTO_FOLLOW_PATTERN)(state) + isAutoFollowApiAuthorized: isApiAuthorized(SECTIONS.AUTO_FOLLOW_PATTERN)(state), + followerIndices: getListFollowerIndices(state), + isFollowerIndexApiAuthorized: isApiAuthorized(SECTIONS.FOLLOWER_INDEX)(state), }); export const CrossClusterReplicationHome = connect( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js index 3e67f862dc16ca..e987c69ee1afd5 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js @@ -4,130 +4,97 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; -import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; import { Route, Switch } from 'react-router-dom'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; -import { BASE_PATH } from '../../../../common/constants'; import { - EuiButton, - EuiFlexGroup, - EuiFlexItem, EuiPageBody, EuiPageContent, EuiSpacer, - EuiText, + EuiTab, + EuiTabs, EuiTitle, } from '@elastic/eui'; +import { BASE_PATH } from '../../../../common/constants'; import { listBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; import { AutoFollowPatternList } from './auto_follow_pattern_list'; -import { SectionUnauthorized } from '../../components'; +import { FollowerIndicesList } from './follower_indices_list'; export const CrossClusterReplicationHome = injectI18n( class extends PureComponent { - static propTypes = { - autoFollowPatterns: PropTypes.array, - } - state = { - sectionActive: 'auto-follow' + activeSection: 'follower_indices' } + tabs = [{ + id: 'follower_indices', + name: ( + + ) + }, { + id: 'auto_follow_patterns', + name: ( + + ) + }] + componentDidMount() { chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb ]); } - getHeaderSection() { - const { isAutoFollowApiAuthorized, autoFollowPatterns } = this.props; - - // We want to show the title when the user isn't authorized. - if (isAutoFollowApiAuthorized && !autoFollowPatterns.length) { - return null; - } - - return ( - - -

- -

-
- - - - - - -

- -

-
- - -

- -

-
-
- - - {isAutoFollowApiAuthorized && ( - - - - )} - -
- - -
- ); + static getDerivedStateFromProps(props) { + const { match: { params: { section } } } = props; + return { + activeSection: section + }; } - getUnauthorizedSection() { - const { isAutoFollowApiAuthorized } = this.props; - if (!isAutoFollowApiAuthorized) { - return ( - - - - ); - } + onSectionChange = (section) => { + routing.navigate(`/${section}`); } render() { return ( - - {this.getHeaderSection()} - {this.getUnauthorizedSection()} + + +

+ +

+
+ + + + + {this.tabs.map(tab => ( + this.onSectionChange(tab.id)} + isSelected={tab.id === this.state.activeSection} + key={tab.id} + > + {tab.name} + + ))} + + + + +
diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js index 1c15088c7b6956..510812426265f6 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js @@ -7,3 +7,5 @@ export { CrossClusterReplicationHome } from './home'; export { AutoFollowPatternAdd } from './auto_follow_pattern_add'; export { AutoFollowPatternEdit } from './auto_follow_pattern_edit'; +export { FollowerIndexAdd } from './follower_index_add'; +export { FollowerIndexEdit } from './follower_index_edit'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js index 1281d369d679d2..32e8cf1460c0da 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js @@ -5,24 +5,36 @@ */ import chrome from 'ui/chrome'; -import { API_BASE_PATH, API_REMOTE_CLUSTERS_BASE_PATH } from '../../../common/constants'; +import { + API_BASE_PATH, + API_REMOTE_CLUSTERS_BASE_PATH, + API_INDEX_MANAGEMENT_BASE_PATH, +} from '../../../common/constants'; import { arrify } from '../../../common/services/utils'; const apiPrefix = chrome.addBasePath(API_BASE_PATH); const apiPrefixRemoteClusters = chrome.addBasePath(API_REMOTE_CLUSTERS_BASE_PATH); +const apiPrefixIndexManagement = chrome.addBasePath(API_INDEX_MANAGEMENT_BASE_PATH); // This is an Angular service, which is why we use this provider pattern // to access it within our React app. let httpClient; -export function setHttpClient(client) { +// The deffered AngularJS api allows us to create deferred promise +// to be resolved later. This allows us to cancel in flight Http Requests +// https://docs.angularjs.org/api/ng/service/$q#the-deferred-api +let $q; + +export function setHttpClient(client, $deffered) { httpClient = client; + $q = $deffered; } // --- const extractData = (response) => response.data; +/* Auto Follow Pattern */ export const loadAutoFollowPatterns = () => ( httpClient.get(`${apiPrefix}/auto_follow_patterns`).then(extractData) ); @@ -49,6 +61,58 @@ export const deleteAutoFollowPattern = (id) => { return httpClient.delete(`${apiPrefix}/auto_follow_patterns/${ids}`).then(extractData); }; +/* Follower Index */ +export const loadFollowerIndices = () => ( + httpClient.get(`${apiPrefix}/follower_indices`).then(extractData) +); + +export const getFollowerIndex = (id) => ( + httpClient.get(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`).then(extractData) +); + +export const createFollowerIndex = (followerIndex) => ( + httpClient.post(`${apiPrefix}/follower_indices`, followerIndex).then(extractData) +); + +export const pauseFollowerIndex = (id) => { + const ids = arrify(id).map(_id => encodeURIComponent(_id)).join(','); + return httpClient.put(`${apiPrefix}/follower_indices/${ids}/pause`).then(extractData); +}; + +export const resumeFollowerIndex = (id) => { + const ids = arrify(id).map(_id => encodeURIComponent(_id)).join(','); + return httpClient.put(`${apiPrefix}/follower_indices/${ids}/resume`).then(extractData); +}; + +export const unfollowLeaderIndex = (id) => { + const ids = arrify(id).map(_id => encodeURIComponent(_id)).join(','); + return httpClient.put(`${apiPrefix}/follower_indices/${ids}/unfollow`).then(extractData); +}; + +export const updateFollowerIndex = (id, followerIndex) => ( + httpClient.put(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`, followerIndex).then(extractData) +); + +/* Stats */ export const loadAutoFollowStats = () => ( - httpClient.get(`${apiPrefix}/stats/auto-follow`).then(extractData) + httpClient.get(`${apiPrefix}/stats/auto_follow`).then(extractData) +); + +/* Indices */ +let canceler = null; +export const loadIndices = () => { + if (canceler) { + // If there is a previous request in flight we cancel it by resolving the canceler + canceler.resolve(); + } + canceler = $q.defer(); + return httpClient.get(`${apiPrefixIndexManagement}/indices`, { timeout: canceler.promise }) + .then((response) => { + canceler = null; + return extractData(response); + }); +}; + +export const loadPermissions = () => ( + httpClient.get(`${apiPrefix}/permissions`).then(extractData) ); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js b/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js index a63bb879211624..585ca7e0f5cf17 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js @@ -9,3 +9,6 @@ import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; export const autoFollowPatternUrl = `${esBase}/ccr-put-auto-follow-pattern.html`; +export const followerIndexUrl = `${esBase}/ccr-put-follow.html`; +export const byteUnitsUrl = `${esBase}/common-options.html#byte-units`; +export const timeUnitsUrl = `${esBase}/common-options.html#time-units`; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js b/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js new file mode 100644 index 00000000000000..e2dc74729b31b2 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_default_settings.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../common/constants'; + +export const getSettingDefault = (name) => { + if(!FOLLOWER_INDEX_ADVANCED_SETTINGS[name]) { + throw new Error(`Unknown setting ${name}`); + } + + return FOLLOWER_INDEX_ADVANCED_SETTINGS[name]; +}; + +export const isSettingDefault = (name, value) => { + return getSettingDefault(name) === value; +}; + +export const areAllSettingsDefault = (settings) => { + return Object.keys(FOLLOWER_INDEX_ADVANCED_SETTINGS).every((name) => isSettingDefault(name, settings[name])); +}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js new file mode 100644 index 00000000000000..942eaa9feb6f09 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const getFirstConnectedCluster = (clusters) => { + for (let i = 0; i < clusters.length; i++) { + if (clusters[i].isConnected) { + return clusters[i]; + } + } + + // No cluster connected, we return the first one in the list + return clusters.length ? clusters[0] : {}; +}; + +export const getRemoteClusterName = (remoteClusters, selected) => { + return selected && remoteClusters.some(c => c.name === selected) + ? selected + : getFirstConnectedCluster(remoteClusters).name; +}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js new file mode 100644 index 00000000000000..877fc1d5a61052 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; + +const isEmpty = value => { + return !value || !value.trim().length; +}; + +const beginsWithPeriod = value => { + return value[0] === '.'; +}; + +const findIllegalCharacters = value => { + return INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => { + if (value.includes(char)) { + chars.push(char); + } + + return chars; + }, []); +}; + +export const indexNameValidator = (value) => { + if (isEmpty(value)) { + return [( + + )]; + } + + if (beginsWithPeriod(value)) { + return [( + + )]; + } + + const illegalCharacters = findIllegalCharacters(value); + + if (illegalCharacters.length) { + return [( + {illegalCharacters.join(' ')} }} + /> + )]; + } + + return undefined; +}; + +export const leaderIndexValidator = (value) => { + if (isEmpty(value)) { + return [( + + )]; + } + + const illegalCharacters = findIllegalCharacters(value); + + if (illegalCharacters.length) { + return [( + {illegalCharacters.join(' ')} }} + /> + )]; + } + + return undefined; +}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/license.js b/x-pack/plugins/cross_cluster_replication/public/app/services/license.js new file mode 100644 index 00000000000000..c61a363472149b --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/license.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export let isAvailable; +export let isActive; +export let getReason; + +export function setLicense(isAvailableCallback, isActiveCallback, getReasonCallback) { + isAvailable = isAvailableCallback; + isActive = isActiveCallback; + getReason = getReasonCallback; +} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js b/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js index 988201fd240f7d..eb6e0d10a6d7ff 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js @@ -16,12 +16,12 @@ const isModifiedEvent = event => !!(event.metaKey || event.altKey || event.ctrlK const isLeftClickEvent = event => event.button === 0; -const queryParamsFromObject = params => { +const queryParamsFromObject = (params, encodeParams = false) => { if (!params) { return; } - const paramsStr = stringify(params, '&', '=', { + const paramsStr = stringify(params, '&', '=', encodeParams ? {} : { encodeURIComponent: (val) => val, // Don't encode special chars }); return `?${paramsStr}`; @@ -42,8 +42,8 @@ class Routing { * * @param {*} to URL to navigate to */ - getRouterLinkProps(to, base = BASE_PATH, params = {}) { - const search = queryParamsFromObject(params) || ''; + getRouterLinkProps(to, base = BASE_PATH, params = {}, encodeParams = false) { + const search = queryParamsFromObject(params, encodeParams) || ''; const location = typeof to === "string" ? createLocation(base + to + search, null, null, this._reactRouter.history.location) : to; @@ -71,8 +71,8 @@ class Routing { return { href, onClick }; } - navigate(route = '/home', app = APPS.CCR_APP, params) { - const search = queryParamsFromObject(params); + navigate(route = '/home', app = APPS.CCR_APP, params, encodeParams = false) { + const search = queryParamsFromObject(params, encodeParams); this._reactRouter.history.push({ pathname: encodeURI(appToBasePathMap[app] + route), @@ -80,6 +80,16 @@ class Routing { }); } + getAutoFollowPatternPath = (name, section = '/edit') => { + return encodeURI(`#${BASE_PATH}/auto_follow_patterns${section}/${encodeURIComponent(name)}`); + }; + + getFollowerIndexPath = (name, section = '/edit', withBase = true) => { + return withBase + ? encodeURI(`#${BASE_PATH}/follower_indices${section}/${encodeURIComponent(name)}`) + : encodeURI(`/follower_indices${section}/${encodeURIComponent(name)}`); + }; + get reactRouter() { return this._reactRouter; } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js b/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js index 875a582580bb25..3a9a9c33bafc2a 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js @@ -11,13 +11,23 @@ export const API_REQUEST_END = 'API_REQUEST_END'; export const API_ERROR_SET = 'API_ERROR_SET'; // Auto Follow Pattern -export const AUTO_FOLLOW_PATTERN_EDIT = 'AUTO_FOLLOW_PATTERN_EDIT'; +export const AUTO_FOLLOW_PATTERN_SELECT_DETAIL = 'AUTO_FOLLOW_PATTERN_SELECT_DETAIL'; +export const AUTO_FOLLOW_PATTERN_SELECT_EDIT = 'AUTO_FOLLOW_PATTERN_SELECT_EDIT'; export const AUTO_FOLLOW_PATTERN_LOAD = 'AUTO_FOLLOW_PATTERN_LOAD'; export const AUTO_FOLLOW_PATTERN_GET = 'AUTO_FOLLOW_PATTERN_GET'; export const AUTO_FOLLOW_PATTERN_CREATE = 'AUTO_FOLLOW_PATTERN_CREATE'; export const AUTO_FOLLOW_PATTERN_UPDATE = 'AUTO_FOLLOW_PATTERN_UPDATE'; export const AUTO_FOLLOW_PATTERN_DELETE = 'AUTO_FOLLOW_PATTERN_DELETE'; -export const AUTO_FOLLOW_PATTERN_DETAIL_PANEL = 'AUTO_FOLLOW_PATTERN_DETAIL_PANEL'; + +// Follower index +export const FOLLOWER_INDEX_SELECT_DETAIL = 'FOLLOWER_INDEX_SELECT_DETAIL'; +export const FOLLOWER_INDEX_SELECT_EDIT = 'FOLLOWER_INDEX_SELECT_EDIT'; +export const FOLLOWER_INDEX_LOAD = 'FOLLOWER_INDEX_LOAD'; +export const FOLLOWER_INDEX_GET = 'FOLLOWER_INDEX_GET'; +export const FOLLOWER_INDEX_CREATE = 'FOLLOWER_INDEX_CREATE'; +export const FOLLOWER_INDEX_PAUSE = 'FOLLOWER_INDEX_PAUSE'; +export const FOLLOWER_INDEX_RESUME = 'FOLLOWER_INDEX_RESUME'; +export const FOLLOWER_INDEX_UNFOLLOW = 'FOLLOWER_INDEX_UNFOLLOW'; // Stats export const AUTO_FOLLOW_STATS_LOAD = 'AUTO_FOLLOW_STATS_LOAD'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/api.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/api.js index 7b5fa621dddea9..0bc4be5c7be04d 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/api.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/api.js @@ -7,15 +7,6 @@ import * as t from '../action_types'; import { API_STATUS } from '../../constants'; -export const sendApiRequest = ({ - label, - scope, - status, - handler, - onSuccess = () => undefined, - onError = () => undefined, -}) => ({ type: t.API, payload: { label, scope, status, handler, onSuccess, onError } }); - export const apiRequestStart = ({ label, scope, status = API_STATUS.LOADING }) => ({ type: t.API_REQUEST_START, payload: { label, scope, status }, @@ -29,3 +20,32 @@ export const setApiError = ({ error, scope }) => ({ }); export const clearApiError = scope => ({ type: t.API_ERROR_SET, payload: { error: null, scope } }); + +export const sendApiRequest = ({ + label, + scope, + status, + handler, + onSuccess = () => undefined, + onError = () => undefined, +}) => async (dispatch, getState) => { + + dispatch(clearApiError(scope)); + dispatch(apiRequestStart({ label, scope, status })); + + try { + const response = await handler(dispatch); + + dispatch(apiRequestEnd({ label, scope })); + dispatch({ type: `${label}_SUCCESS`, payload: response }); + + onSuccess(response, dispatch, getState); + + } catch (error) { + dispatch(apiRequestEnd({ label, scope })); + dispatch(setApiError({ error, scope })); + dispatch({ type: `${label}_FAILURE`, payload: error }); + + onError(error, dispatch, getState); + } +}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js index e02085ceb02901..3636befdc9bf79 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js @@ -16,25 +16,18 @@ import { import routing from '../../services/routing'; import * as t from '../action_types'; import { sendApiRequest } from './api'; -import { getDetailPanelAutoFollowPatternName } from '../selectors'; +import { getSelectedAutoFollowPatternId } from '../selectors'; const { AUTO_FOLLOW_PATTERN: scope } = SECTIONS; -export const editAutoFollowPattern = (name) => ({ - type: t.AUTO_FOLLOW_PATTERN_EDIT, - payload: name +export const selectDetailAutoFollowPattern = (id) => ({ + type: t.AUTO_FOLLOW_PATTERN_SELECT_DETAIL, + payload: id }); -export const openAutoFollowPatternDetailPanel = (name) => { - return { - type: t.AUTO_FOLLOW_PATTERN_DETAIL_PANEL, - payload: name - }; -}; - -export const closeAutoFollowPatternDetailPanel = () => ({ - type: t.AUTO_FOLLOW_PATTERN_DETAIL_PANEL, - payload: null +export const selectEditAutoFollowPattern = (id) => ({ + type: t.AUTO_FOLLOW_PATTERN_SELECT_EDIT, + payload: id }); export const loadAutoFollowPatterns = (isUpdating = false) => @@ -42,21 +35,17 @@ export const loadAutoFollowPatterns = (isUpdating = false) => label: t.AUTO_FOLLOW_PATTERN_LOAD, scope, status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING, - handler: async () => { - return await loadAutoFollowPatternsRequest(); - }, + handler: async () => ( + await loadAutoFollowPatternsRequest() + ), }); export const getAutoFollowPattern = (id) => sendApiRequest({ label: t.AUTO_FOLLOW_PATTERN_GET, - scope, - handler: async (dispatch) => ( - getAutoFollowPatternRequest(id) - .then((response) => { - dispatch(editAutoFollowPattern(id)); - return response; - }) + scope: `${scope}-get`, + handler: async () => ( + await getAutoFollowPatternRequest(id) ) }); @@ -64,7 +53,7 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false) sendApiRequest({ label: isUpdating ? t.AUTO_FOLLOW_PATTERN_UPDATE : t.AUTO_FOLLOW_PATTERN_CREATE, status: API_STATUS.SAVING, - scope, + scope: `${scope}-save`, handler: async () => { if (isUpdating) { return await updateAutoFollowPatternRequest(id, autoFollowPattern); @@ -73,11 +62,11 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false) }, onSuccess() { const successMessage = isUpdating - ? i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successMultipleNotificationTitle', { + ? i18n.translate('xpack.crossClusterReplication.autoFollowPattern.updateAction.successNotificationTitle', { defaultMessage: `Auto-follow pattern '{name}' updated successfully`, values: { name: id }, }) - : i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successSingleNotificationTitle', { + : i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successNotificationTitle', { defaultMessage: `Added auto-follow pattern '{name}'`, values: { name: id }, }); @@ -132,12 +121,12 @@ export const deleteAutoFollowPattern = (id) => ( }); toastNotifications.addSuccess(successMessage); - } - // If we've just deleted a pattern we were looking at, we need to close the panel. - const detailPanelAutoFollowPatternName = getDetailPanelAutoFollowPatternName(getState()); - if (detailPanelAutoFollowPatternName && response.itemsDeleted.includes(detailPanelAutoFollowPatternName)) { - dispatch(closeAutoFollowPatternDetailPanel()); + // If we've just deleted a pattern we were looking at, we need to close the panel. + const autoFollowPatternId = getSelectedAutoFollowPatternId('detail')(getState()); + if (response.itemsDeleted.includes(autoFollowPatternId)) { + dispatch(selectDetailAutoFollowPattern(null)); + } } } }) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js new file mode 100644 index 00000000000000..06b2511fde1ed7 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { toastNotifications } from 'ui/notify'; +import routing from '../../services/routing'; +import { SECTIONS, API_STATUS } from '../../constants'; +import { + loadFollowerIndices as loadFollowerIndicesRequest, + getFollowerIndex as getFollowerIndexRequest, + createFollowerIndex as createFollowerIndexRequest, + pauseFollowerIndex as pauseFollowerIndexRequest, + resumeFollowerIndex as resumeFollowerIndexRequest, + unfollowLeaderIndex as unfollowLeaderIndexRequest, + updateFollowerIndex as updateFollowerIndexRequest, +} from '../../services/api'; +import * as t from '../action_types'; +import { sendApiRequest } from './api'; +import { getSelectedFollowerIndexId } from '../selectors'; + +const { FOLLOWER_INDEX: scope } = SECTIONS; + +export const selectDetailFollowerIndex = (id) => ({ + type: t.FOLLOWER_INDEX_SELECT_DETAIL, + payload: id +}); + +export const selectEditFollowerIndex = (id) => ({ + type: t.FOLLOWER_INDEX_SELECT_EDIT, + payload: id +}); + +export const loadFollowerIndices = (isUpdating = false) => + sendApiRequest({ + label: t.FOLLOWER_INDEX_LOAD, + scope, + status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING, + handler: async () => ( + await loadFollowerIndicesRequest() + ), + }); + +export const getFollowerIndex = (id) => + sendApiRequest({ + label: t.FOLLOWER_INDEX_GET, + scope: `${scope}-get`, + handler: async () => ( + await getFollowerIndexRequest(id) + ) + }); + +export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => ( + sendApiRequest({ + label: t.FOLLOWER_INDEX_CREATE, + status: API_STATUS.SAVING, + scope: `${scope}-save`, + handler: async () => { + if (isUpdating) { + return await updateFollowerIndexRequest(name, followerIndex); + } + return await createFollowerIndexRequest({ name, ...followerIndex }); + }, + onSuccess() { + const successMessage = isUpdating + ? i18n.translate('xpack.crossClusterReplication.followerIndex.updateAction.successNotificationTitle', { + defaultMessage: `Follower index '{name}' updated successfully`, + values: { name }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successNotificationTitle', { + defaultMessage: `Added follower index '{name}'`, + values: { name }, + }); + + toastNotifications.addSuccess(successMessage); + routing.navigate(`/follower_indices`, undefined, { + name: encodeURIComponent(name), + }); + }, + }) +); + +export const pauseFollowerIndex = (id) => ( + sendApiRequest({ + label: t.FOLLOWER_INDEX_PAUSE, + status: API_STATUS.SAVING, + scope, + handler: async () => ( + pauseFollowerIndexRequest(id) + ), + onSuccess(response, dispatch) { + /** + * We can have 1 or more follower index pause operation + * that can fail or succeed. We will show 1 toast notification for each. + */ + if (response.errors.length) { + const hasMultipleErrors = response.errors.length > 1; + const errorMessage = hasMultipleErrors + ? i18n.translate('xpack.crossClusterReplication.followerIndex.pauseAction.errorMultipleNotificationTitle', { + defaultMessage: `Error pausing {count} follower indices`, + values: { count: response.errors.length }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.pauseAction.errorSingleNotificationTitle', { + defaultMessage: `Error pausing follower index '{name}'`, + values: { name: response.errors[0].id }, + }); + + toastNotifications.addDanger(errorMessage); + } + + if (response.itemsPaused.length) { + const hasMultiplePaused = response.itemsPaused.length > 1; + + const successMessage = hasMultiplePaused + ? i18n.translate('xpack.crossClusterReplication.followerIndex.pauseAction.successMultipleNotificationTitle', { + defaultMessage: `{count} follower indices were paused`, + values: { count: response.itemsPaused.length }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.pauseAction.successSingleNotificationTitle', { + defaultMessage: `Follower index '{name}' was paused`, + values: { name: response.itemsPaused[0] }, + }); + + toastNotifications.addSuccess(successMessage); + + // Refresh list + dispatch(loadFollowerIndices(true)); + } + } + }) +); + +export const resumeFollowerIndex = (id) => ( + sendApiRequest({ + label: t.FOLLOWER_INDEX_RESUME, + status: API_STATUS.SAVING, + scope, + handler: async () => ( + resumeFollowerIndexRequest(id) + ), + onSuccess(response, dispatch) { + /** + * We can have 1 or more follower index resume operation + * that can fail or succeed. We will show 1 toast notification for each. + */ + if (response.errors.length) { + const hasMultipleErrors = response.errors.length > 1; + const errorMessage = hasMultipleErrors + ? i18n.translate('xpack.crossClusterReplication.followerIndex.resumeAction.errorMultipleNotificationTitle', { + defaultMessage: `Error resuming {count} follower indices`, + values: { count: response.errors.length }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.resumeAction.errorSingleNotificationTitle', { + defaultMessage: `Error resuming follower index '{name}'`, + values: { name: response.errors[0].id }, + }); + + toastNotifications.addDanger(errorMessage); + } + + if (response.itemsResumed.length) { + const hasMultipleResumed = response.itemsResumed.length > 1; + + const successMessage = hasMultipleResumed + ? i18n.translate('xpack.crossClusterReplication.followerIndex.resumeAction.successMultipleNotificationTitle', { + defaultMessage: `{count} follower indices were resumed`, + values: { count: response.itemsResumed.length }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.resumeAction.successSingleNotificationTitle', { + defaultMessage: `Follower index '{name}' was resumed`, + values: { name: response.itemsResumed[0] }, + }); + + toastNotifications.addSuccess(successMessage); + } + + // Refresh list + dispatch(loadFollowerIndices(true)); + } + }) +); + +export const unfollowLeaderIndex = (id) => ( + sendApiRequest({ + label: t.FOLLOWER_INDEX_UNFOLLOW, + status: API_STATUS.DELETING, + scope: `${scope}-delete`, + handler: async () => ( + unfollowLeaderIndexRequest(id) + ), + onSuccess(response, dispatch, getState) { + /** + * We can have 1 or more follower index unfollow operation + * that can fail or succeed. We will show 1 toast notification for each. + */ + if (response.errors.length) { + const hasMultipleErrors = response.errors.length > 1; + const errorMessage = hasMultipleErrors + ? i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.errorMultipleNotificationTitle', { + defaultMessage: `Error unfollowing leader index of {count} follower indices`, + values: { count: response.errors.length }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.errorSingleNotificationTitle', { + defaultMessage: `Error unfollowing leader index of follower index '{name}'`, + values: { name: response.errors[0].id }, + }); + + toastNotifications.addDanger(errorMessage); + } + + if (response.itemsUnfollowed.length) { + const hasMultipleUnfollow = response.itemsUnfollowed.length > 1; + + const successMessage = hasMultipleUnfollow + ? i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.successMultipleNotificationTitle', { + defaultMessage: `Leader indices of {count} follower indices were unfollowed`, + values: { count: response.itemsUnfollowed.length }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.successSingleNotificationTitle', { + defaultMessage: `Leader index of follower index '{name}' was unfollowed`, + values: { name: response.itemsUnfollowed[0] }, + }); + + toastNotifications.addSuccess(successMessage); + } + + if (response.itemsNotOpen.length) { + const hasMultipleNotOpen = response.itemsNotOpen.length > 1; + + const warningMessage = hasMultipleNotOpen + ? i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.notOpenWarningMultipleNotificationTitle', { + defaultMessage: `{count} indices could not be re-opened`, + values: { count: response.itemsNotOpen.length }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.unfollowAction.notOpenWarningSingleNotificationTitle', { + defaultMessage: `Index '{name}' could not be re-opened`, + values: { name: response.itemsNotOpen[0] }, + }); + + toastNotifications.addWarning(warningMessage); + } + + // If we've just unfollowed a follower index we were looking at, we need to close the panel. + const followerIndexId = getSelectedFollowerIndexId('detail')(getState()); + if (response.itemsUnfollowed.includes(followerIndexId)) { + dispatch(selectDetailFollowerIndex(null)); + } + } + }) +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/index.js index 75f2344f76e066..1ecee23d1c79aa 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/index.js @@ -5,6 +5,6 @@ */ export * from './auto_follow_pattern'; - +export * from './follower_index'; export * from './api'; export * from './ccr'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/api.js b/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/api.js deleted file mode 100644 index 234aa24c07748c..00000000000000 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/api.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as t from '../action_types'; -import { apiRequestStart, apiRequestEnd, setApiError, clearApiError } from '../actions/api'; - -export const apiMiddleware = ({ dispatch, getState }) => next => async (action) => { - next(action); - - if (action.type !== t.API) { - return; - } - - const { label, scope, status, handler, onSuccess, onError } = action.payload; - - dispatch(clearApiError(scope)); - dispatch(apiRequestStart({ label, scope, status })); - - try { - const response = await handler(dispatch); - - dispatch(apiRequestEnd({ label, scope })); - dispatch({ type: `${label}_SUCCESS`, payload: response }); - - onSuccess(response, dispatch, getState); - - } catch (error) { - dispatch(apiRequestEnd({ label, scope })); - dispatch(setApiError({ error, scope })); - dispatch({ type: `${label}_FAILURE`, payload: error }); - - onError(error, dispatch, getState); - } -}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/auto_follow_pattern.js deleted file mode 100644 index dbb7985b409eb7..00000000000000 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/middleware/auto_follow_pattern.js +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import routing from '../../services/routing'; -import * as t from '../action_types'; -import { extractQueryParams } from '../../services/query_params'; -import { loadAutoFollowStats } from '../actions'; - -export const autoFollowPatternMiddleware = ({ dispatch }) => next => action => { - const { type, payload: name } = action; - const { history } = routing.reactRouter; - const search = history.location.search; - const { pattern: patternName } = extractQueryParams(search); - - switch (type) { - case t.AUTO_FOLLOW_PATTERN_DETAIL_PANEL: - if (!routing.userHasLeftApp) { - // Persist state to query params by removing deep link. - if(!name) { - history.replace({ - search: '', - }); - } - // Allow the user to share a deep link to this job. - else if (patternName !== name) { - history.replace({ - search: `?pattern=${encodeURIComponent(name)}`, - }); - - dispatch(loadAutoFollowStats()); - } - } - break; - } - - return next(action); -}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.js index 55570deb4b7a67..f32a1862078a4f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.js @@ -10,11 +10,11 @@ import * as t from '../action_types'; export const initialState = { status: { [SECTIONS.AUTO_FOLLOW_PATTERN]: API_STATUS.IDLE, - [SECTIONS.INDEX_FOLLOWER]: API_STATUS.IDLE, + [SECTIONS.FOLLOWER_INDEX]: API_STATUS.IDLE, }, error: { [SECTIONS.AUTO_FOLLOW_PATTERN]: null, - [SECTIONS.INDEX_FOLLOWER]: null, + [SECTIONS.FOLLOWER_INDEX]: null, }, }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js index 818045919caf7b..43d1da3f242a2e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js @@ -8,6 +8,17 @@ import { reducer, initialState } from './api'; import { API_STATUS } from '../../constants'; import { apiRequestStart, apiRequestEnd, setApiError } from '../actions'; +jest.mock('../../constants', () => ({ + API_STATUS: { + IDLE: 'idle', + LOADING: 'loading', + }, + SECTIONS: { + AUTO_FOLLOW_PATTERN: 'autoFollowPattern', + FOLLOWER_INDEX: 'followerIndex', + } +})); + describe('CCR Api reducers', () => { const scope = 'testSection'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js index 398f949db94255..2170bc539d0331 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js @@ -10,8 +10,8 @@ import { getPrefixSuffixFromFollowPattern } from '../../services/auto_follow_pat const initialState = { byId: {}, - selectedId: null, - detailPanelId: null, + selectedDetailId: null, + selectedEditId: null, }; const success = action => `${action}_SUCCESS`; @@ -31,11 +31,11 @@ export const reducer = (state = initialState, action) => { case success(t.AUTO_FOLLOW_PATTERN_GET): { return { ...state, byId: { ...state.byId, [action.payload.name]: parseAutoFollowPattern(action.payload) } }; } - case t.AUTO_FOLLOW_PATTERN_EDIT: { - return { ...state, selectedId: action.payload }; + case t.AUTO_FOLLOW_PATTERN_SELECT_DETAIL: { + return { ...state, selectedDetailId: action.payload }; } - case t.AUTO_FOLLOW_PATTERN_DETAIL_PANEL: { - return { ...state, detailPanelId: action.payload }; + case t.AUTO_FOLLOW_PATTERN_SELECT_EDIT: { + return { ...state, selectedEditId: action.payload }; } case success(t.AUTO_FOLLOW_PATTERN_DELETE): { const byId = { ...state.byId }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js new file mode 100644 index 00000000000000..6256a16316ad3d --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from '../action_types'; +import { arrayToObject } from '../../services/utils'; + +const initialState = { + byId: {}, + selectedDetailId: null, + selectedEditId: null, +}; + +const success = action => `${action}_SUCCESS`; + +const parseFollowerIndex = (followerIndex) => { + // Extract status into boolean + return { ...followerIndex, isPaused: followerIndex.status === 'paused' }; +}; +export const reducer = (state = initialState, action) => { + switch (action.type) { + case success(t.FOLLOWER_INDEX_LOAD): { + return { ...state, byId: arrayToObject(action.payload.indices.map(parseFollowerIndex), 'name') }; + } + case success(t.FOLLOWER_INDEX_GET): { + return { ...state, byId: { ...state.byId, [action.payload.name]: parseFollowerIndex(action.payload) } }; + } + case t.FOLLOWER_INDEX_SELECT_DETAIL: { + return { ...state, selectedDetailId: action.payload }; + } + case t.FOLLOWER_INDEX_SELECT_EDIT: { + return { ...state, selectedEditId: action.payload }; + } + case success(t.FOLLOWER_INDEX_UNFOLLOW): { + const byId = { ...state.byId }; + const { itemsUnfollowed } = action.payload; + itemsUnfollowed.forEach(id => delete byId[id]); + return { ...state, byId }; + } + default: + return state; + } +}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/index.js index 2dc9d42fc2b8d3..168a465aed64a2 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/index.js @@ -7,10 +7,12 @@ import { combineReducers } from 'redux'; import { reducer as api } from './api'; import { reducer as autoFollowPattern } from './auto_follow_pattern'; +import { reducer as followerIndex } from './follower_index'; import { reducer as stats } from './stats'; export const ccr = combineReducers({ autoFollowPattern, + followerIndex, api, stats, }); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js index 0bb0b099b8701f..8b0d4f18b21cd3 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js @@ -6,10 +6,11 @@ import { createSelector } from 'reselect'; import { objectToArray } from '../../services/utils'; +import { API_STATUS } from '../../constants'; // Api export const getApiState = (state) => state.api; -export const getApiStatus = (scope) => createSelector(getApiState, (apiState) => apiState.status[scope]); +export const getApiStatus = (scope) => createSelector(getApiState, (apiState) => apiState.status[scope] || API_STATUS.IDLE); export const getApiError = (scope) => createSelector(getApiState, (apiState) => apiState.error[scope]); export const isApiAuthorized = (scope) => createSelector(getApiError(scope), (error) => { if (!error) { @@ -25,26 +26,37 @@ export const getAutoFollowStats = createSelector(getStatsState, (statsState) => // Auto-follow pattern export const getAutoFollowPatternState = (state) => state.autoFollowPattern; export const getAutoFollowPatterns = createSelector(getAutoFollowPatternState, (autoFollowPatternsState) => autoFollowPatternsState.byId); -export const getDetailPanelAutoFollowPatternName = createSelector(getAutoFollowPatternState, - (autoFollowPatternsState) => autoFollowPatternsState.detailPanelId); -export const getSelectedAutoFollowPattern = createSelector(getAutoFollowPatternState, (autoFollowPatternsState) => { - if(!autoFollowPatternsState.selectedId) { - return null; - } - return autoFollowPatternsState.byId[autoFollowPatternsState.selectedId]; -}); -export const isAutoFollowPatternDetailPanelOpen = createSelector(getAutoFollowPatternState, (autoFollowPatternsState) => { - return !!autoFollowPatternsState.detailPanelId; -}); -export const getDetailPanelAutoFollowPattern = createSelector( +export const getSelectedAutoFollowPatternId = (view = 'detail') => createSelector(getAutoFollowPatternState, (autoFollowPatternsState) => ( + view === 'detail' ? autoFollowPatternsState.selectedDetailId : autoFollowPatternsState.selectedEditId +)); +export const getSelectedAutoFollowPattern = (view = 'detail') => createSelector( getAutoFollowPatternState, getAutoFollowStats, (autoFollowPatternsState, autoFollowStatsState) => { - if(!autoFollowPatternsState.detailPanelId) { + const propId = view === 'detail' ? 'selectedDetailId' : 'selectedEditId'; + + if(!autoFollowPatternsState[propId]) { return null; } - const { detailPanelId } = autoFollowPatternsState; - const autoFollowPattern = autoFollowPatternsState.byId[detailPanelId]; - const errors = autoFollowStatsState && autoFollowStatsState.recentAutoFollowErrors[detailPanelId] || []; + const id = autoFollowPatternsState[propId]; + const autoFollowPattern = autoFollowPatternsState.byId[id]; + + // Check if any error and merge them on the auto-follow pattern + const errors = autoFollowStatsState && autoFollowStatsState.recentAutoFollowErrors[id] || []; return autoFollowPattern ? { ...autoFollowPattern, errors } : null; }); export const getListAutoFollowPatterns = createSelector(getAutoFollowPatterns, (autoFollowPatterns) => objectToArray(autoFollowPatterns)); +// Follower index +export const getFollowerIndexState = (state) => state.followerIndex; +export const getFollowerIndices = createSelector(getFollowerIndexState, (followerIndexState) => followerIndexState.byId); +export const getSelectedFollowerIndexId = (view = 'detail') => createSelector(getFollowerIndexState, (followerIndexState) => ( + view === 'detail' ? followerIndexState.selectedDetailId : followerIndexState.selectedEditId +)); +export const getSelectedFollowerIndex = (view = 'detail') => createSelector(getFollowerIndexState, (followerIndexState) => { + const propId = view === 'detail' ? 'selectedDetailId' : 'selectedEditId'; + + if(!followerIndexState[propId]) { + return null; + } + return followerIndexState.byId[followerIndexState[propId]]; +}); +export const getListFollowerIndices = createSelector(getFollowerIndices, (followerIndices) => objectToArray(followerIndices)); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/store.js b/x-pack/plugins/cross_cluster_replication/public/app/store/store.js index 6d2a700deab410..b6674d89f6278b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/store.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/store.js @@ -5,12 +5,12 @@ */ import { applyMiddleware, compose, createStore } from 'redux'; +import thunk from 'redux-thunk'; -import { apiMiddleware, autoFollowPatternMiddleware } from './middleware'; import { ccr } from './reducers'; function createCrossClusterReplicationStore(initialState = {}) { - const enhancers = [applyMiddleware(apiMiddleware, autoFollowPatternMiddleware)]; + const enhancers = [applyMiddleware(thunk)]; if (window.__REDUX_DEVTOOLS_EXTENSION__) { enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__()); diff --git a/x-pack/plugins/cross_cluster_replication/public/index.js b/x-pack/plugins/cross_cluster_replication/public/index.js index b4bf57ec47f7ac..4ec268f0de7f29 100644 --- a/x-pack/plugins/cross_cluster_replication/public/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/index.js @@ -4,6 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import './register_ccr_section'; import './register_routes'; import './extend_index_management'; diff --git a/x-pack/plugins/cross_cluster_replication/public/register_ccr_section.js b/x-pack/plugins/cross_cluster_replication/public/register_ccr_section.js deleted file mode 100644 index 4383e29e5548df..00000000000000 --- a/x-pack/plugins/cross_cluster_replication/public/register_ccr_section.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { management } from 'ui/management'; -import { i18n } from '@kbn/i18n'; -import chrome from 'ui/chrome'; -import { BASE_PATH } from '../common/constants'; - -if (chrome.getInjected('ccrUiEnabled')) { - const esSection = management.getSection('elasticsearch'); - - esSection.register('ccr', { - visible: true, - display: i18n.translate('xpack.crossClusterReplication.appTitle', { defaultMessage: 'Cross Cluster Replication' }), - order: 3, - url: `#${BASE_PATH}` - }); -} diff --git a/x-pack/plugins/cross_cluster_replication/public/register_routes.js b/x-pack/plugins/cross_cluster_replication/public/register_routes.js index d8084f1d3415c2..d78e9fd12cb84d 100644 --- a/x-pack/plugins/cross_cluster_replication/public/register_routes.js +++ b/x-pack/plugins/cross_cluster_replication/public/register_routes.js @@ -4,38 +4,62 @@ * you may not use this file except in compliance with the Elastic License. */ -import routes from 'ui/routes'; import { unmountComponentAtNode } from 'react-dom'; import chrome from 'ui/chrome'; +import { management } from 'ui/management'; +import routes from 'ui/routes'; +import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; +import { i18n } from '@kbn/i18n'; import template from './main.html'; -import { BASE_PATH } from '../common/constants/base_path'; +import { BASE_PATH } from '../common/constants'; import { renderReact } from './app'; import { setHttpClient } from './app/services/api'; +import { setLicense } from './app/services/license'; if (chrome.getInjected('ccrUiEnabled')) { + const esSection = management.getSection('elasticsearch'); + + esSection.register('ccr', { + visible: true, + display: i18n.translate('xpack.crossClusterReplication.appTitle', { defaultMessage: 'Cross Cluster Replication' }), + order: 3, + url: `#${BASE_PATH}` + }); + let elem; const CCR_REACT_ROOT = 'ccrReactRoot'; const unmountReactApp = () => elem && unmountComponentAtNode(elem); - routes.when(`${BASE_PATH}/:section?/:view?/:id?`, { - template: template, + routes.when(`${BASE_PATH}/:section?/:subsection?/:view?/:id?`, { + template, + resolve: { + license(Private) { + const xpackInfo = Private(XPackInfoProvider); + return { + isAvailable: () => xpackInfo.get('features.crossClusterReplication.isAvailable'), + isActive: () => xpackInfo.get('features.crossClusterReplication.isActive'), + getReason: () => xpackInfo.get('features.crossClusterReplication.message'), + }; + } + }, controllerAs: 'ccr', controller: class CrossClusterReplicationController { - constructor($scope, $route, $http) { - /** - * React-router's does not play well with the angular router. It will cause this controller - * to re-execute without the $destroy handler being called. This means that the app will be mounted twice - * creating a memory leak when leaving (only 1 app will be unmounted). - * To avoid this, we unmount the React app each time we enter the controller. - */ + constructor($scope, $route, $http, $q) { + const { license: { isAvailable, isActive, getReason } } = $route.current.locals; + setLicense(isAvailable, isActive, getReason); + + // React-router's does not play well with the angular router. It will cause this controller + // to re-execute without the $destroy handler being called. This means that the app will be mounted twice + // creating a memory leak when leaving (only 1 app will be unmounted). + // To avoid this, we unmount the React app each time we enter the controller. unmountReactApp(); // NOTE: We depend upon Angular's $http service because it's decorated with interceptors, // e.g. to check license status per request. - setHttpClient($http); + setHttpClient($http, $q); $scope.$$postDigest(() => { elem = document.getElementById(CCR_REACT_ROOT); diff --git a/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js b/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js index c3ed6570c44c90..85d001674c5b6e 100644 --- a/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js +++ b/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js @@ -10,6 +10,16 @@ export const elasticsearchJsPlugin = (Client, config, components) => { Client.prototype.ccr = components.clientAction.namespaceFactory(); const ccr = Client.prototype.ccr.prototype; + ccr.permissions = ca({ + urls: [ + { + fmt: '/_security/user/_has_privileges', + } + ], + needBody: true, + method: 'POST' + }); + ccr.autoFollowPatterns = ca({ urls: [ { @@ -63,6 +73,20 @@ export const elasticsearchJsPlugin = (Client, config, components) => { method: 'DELETE' }); + ccr.info = ca({ + urls: [ + { + fmt: '/<%=id%>/_ccr/info', + req: { + id: { + type: 'string' + } + } + } + ], + method: 'GET' + }); + ccr.stats = ca({ urls: [ { @@ -71,4 +95,76 @@ export const elasticsearchJsPlugin = (Client, config, components) => { ], method: 'GET' }); + + ccr.followerIndexStats = ca({ + urls: [ + { + fmt: '/<%=id%>/_ccr/stats', + req: { + id: { + type: 'string' + } + } + } + ], + method: 'GET' + }); + + ccr.saveFollowerIndex = ca({ + urls: [ + { + fmt: '/<%=name%>/_ccr/follow', + req: { + name: { + type: 'string' + } + } + } + ], + needBody: true, + method: 'PUT' + }); + + ccr.pauseFollowerIndex = ca({ + urls: [ + { + fmt: '/<%=id%>/_ccr/pause_follow', + req: { + id: { + type: 'string' + } + } + } + ], + method: 'POST' + }); + + ccr.resumeFollowerIndex = ca({ + urls: [ + { + fmt: '/<%=id%>/_ccr/resume_follow', + req: { + id: { + type: 'string' + } + } + } + ], + needBody: true, + method: 'POST' + }); + + ccr.unfollowLeaderIndex = ca({ + urls: [ + { + fmt: '/<%=id%>/_ccr/unfollow', + req: { + id: { + type: 'string' + } + } + } + ], + method: 'POST' + }); }; diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap b/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap index 1c20f73287259f..d001459e8234d5 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap +++ b/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap @@ -2,7 +2,19 @@ exports[`[CCR] follower index serialization deserializeFollowerIndex() deserializes Elasticsearch follower index object 1`] = ` Object { - "name": "follower index name", + "leaderIndex": undefined, + "maxOutstandingReadRequests": undefined, + "maxOutstandingWriteRequests": undefined, + "maxReadRequestOperationCount": undefined, + "maxReadRequestSize": undefined, + "maxRetryDelay": undefined, + "maxWriteBufferCount": undefined, + "maxWriteBufferSize": undefined, + "maxWriteRequestOperationCount": undefined, + "maxWriteRequestSize": undefined, + "name": undefined, + "readPollTimeout": undefined, + "remoteCluster": undefined, "shards": Array [ Object { "bytesReadCount": undefined, @@ -61,6 +73,7 @@ Object { "writeBufferSizeBytes": undefined, }, ], + "status": "active", } `; @@ -96,3 +109,20 @@ Object { "writeBufferSizeBytes": "write buffer size in bytes", } `; + +exports[`[CCR] follower index serialization serializeFollowerIndex() serializes object to Elasticsearch follower index object 1`] = ` +Object { + "leader_index": "leader index", + "max_outstanding_read_requests": "foo", + "max_outstanding_write_requests": "foo", + "max_read_request_operation_count": "foo", + "max_read_request_size": "foo", + "max_retry_delay": "foo", + "max_write_buffer_count": "foo", + "max_write_buffer_size": "foo", + "max_write_request_operation_count": "foo", + "max_write_request_size": "foo", + "read_poll_timeout": "foo", + "remote_cluster": "remote cluster", +} +`; diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js b/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js index 35e5e3783e6285..fb99de8ab5d971 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js +++ b/x-pack/plugins/cross_cluster_replication/server/lib/check_license/check_license.js @@ -26,7 +26,7 @@ export function checkLicense(xpackLicenseInfo) { }; } - const VALID_LICENSE_MODES = ['trial', 'basic', 'standard', 'gold', 'platinum']; + const VALID_LICENSE_MODES = [ 'trial', 'platinum' ]; const isLicenseModeValid = xpackLicenseInfo.license.isOneOf(VALID_LICENSE_MODES); const isLicenseActive = xpackLicenseInfo.license.isActive(); @@ -36,7 +36,7 @@ export function checkLicense(xpackLicenseInfo) { if (!isLicenseModeValid) { return { isAvailable: false, - showLinks: false, + isActive: false, message: i18n.translate( 'xpack.crossClusterReplication.checkLicense.errorUnsupportedMessage', { @@ -50,9 +50,8 @@ export function checkLicense(xpackLicenseInfo) { // License is valid but not active if (!isLicenseActive) { return { - isAvailable: false, - showLinks: true, - enableLinks: false, + isAvailable: true, + isActive: false, message: i18n.translate( 'xpack.crossClusterReplication.checkLicense.errorExpiredMessage', { @@ -66,7 +65,6 @@ export function checkLicense(xpackLicenseInfo) { // License is valid and active return { isAvailable: true, - showLinks: true, - enableLinks: true, + isActive: true, }; } diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js index 7b5a0e453b65fe..d4605996b471da 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js +++ b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js @@ -63,15 +63,73 @@ export const deserializeShard = ({ }); /* eslint-enable camelcase */ -export const deserializeFollowerIndex = ({ index, shards }) => ({ - name: index, - shards: shards.map(deserializeShard), +/* eslint-disable camelcase */ +export const deserializeFollowerIndex = ({ + follower_index, + remote_cluster, + leader_index, + status, + parameters: { + max_read_request_operation_count, + max_outstanding_read_requests, + max_read_request_size, + max_write_request_operation_count, + max_write_request_size, + max_outstanding_write_requests, + max_write_buffer_count, + max_write_buffer_size, + max_retry_delay, + read_poll_timeout, + } = {}, + shards, +}) => ({ + name: follower_index, + remoteCluster: remote_cluster, + leaderIndex: leader_index, + status, + maxReadRequestOperationCount: max_read_request_operation_count, + maxOutstandingReadRequests: max_outstanding_read_requests, + maxReadRequestSize: max_read_request_size, + maxWriteRequestOperationCount: max_write_request_operation_count, + maxWriteRequestSize: max_write_request_size, + maxOutstandingWriteRequests: max_outstanding_write_requests, + maxWriteBufferCount: max_write_buffer_count, + maxWriteBufferSize: max_write_buffer_size, + maxRetryDelay: max_retry_delay, + readPollTimeout: read_poll_timeout, + shards: shards && shards.map(deserializeShard), }); +/* eslint-enable camelcase */ export const deserializeListFollowerIndices = followerIndices => followerIndices.map(deserializeFollowerIndex); -export const serializeFollowerIndex = ({ remoteCluster, leaderIndex }) => ({ - remote_cluster: remoteCluster, - leader_index: leaderIndex, +export const serializeAdvancedSettings = ({ + maxReadRequestOperationCount, + maxOutstandingReadRequests, + maxReadRequestSize, + maxWriteRequestOperationCount, + maxWriteRequestSize, + maxOutstandingWriteRequests, + maxWriteBufferCount, + maxWriteBufferSize, + maxRetryDelay, + readPollTimeout, +}) => ({ + max_read_request_operation_count: maxReadRequestOperationCount, + max_outstanding_read_requests: maxOutstandingReadRequests, + max_read_request_size: maxReadRequestSize, + max_write_request_operation_count: maxWriteRequestOperationCount, + max_write_request_size: maxWriteRequestSize, + max_outstanding_write_requests: maxOutstandingWriteRequests, + max_write_buffer_count: maxWriteBufferCount, + max_write_buffer_size: maxWriteBufferSize, + max_retry_delay: maxRetryDelay, + read_poll_timeout: readPollTimeout, +}); + +export const serializeFollowerIndex = (followerIndex) => ({ + remote_cluster: followerIndex.remoteCluster, + leader_index: followerIndex.leaderIndex, + ...serializeAdvancedSettings(followerIndex) }); diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js index d4b5526c16a6ba..c6a09d635b7112 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js +++ b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js @@ -51,6 +51,7 @@ describe('[CCR] follower index serialization', () => { it('deserializes Elasticsearch follower index object', () => { const serializedFollowerIndex = { index: 'follower index name', + status: 'active', shards: [{ shard_id: 'shard 1', }, { @@ -65,18 +66,74 @@ describe('[CCR] follower index serialization', () => { describe('deserializeListFollowerIndices()', () => { it('deserializes list of Elasticsearch follower index objects', () => { const serializedFollowerIndexList = [{ - index: 'follower index 1', + follower_index: 'follower index 1', + remote_cluster: 'cluster 1', + leader_index: 'leader 1', + status: 'active', + parameters: { + max_read_request_operation_count: 1, + max_outstanding_read_requests: 1, + max_read_request_size: 1, + max_write_request_operation_count: 1, + max_write_request_size: 1, + max_outstanding_write_requests: 1, + max_write_buffer_count: 1, + max_write_buffer_size: 1, + max_retry_delay: 1, + read_poll_timeout: 1, + }, shards: [], }, { - index: 'follower index 2', + follower_index: 'follower index 2', + remote_cluster: 'cluster 2', + leader_index: 'leader 2', + status: 'paused', + parameters: { + max_read_request_operation_count: 2, + max_outstanding_read_requests: 2, + max_read_request_size: 2, + max_write_request_operation_count: 2, + max_write_request_size: 2, + max_outstanding_write_requests: 2, + max_write_buffer_count: 2, + max_write_buffer_size: 2, + max_retry_delay: 2, + read_poll_timeout: 2, + }, shards: [], }]; const deserializedFollowerIndexList = [{ name: 'follower index 1', + remoteCluster: 'cluster 1', + leaderIndex: 'leader 1', + status: 'active', + maxReadRequestOperationCount: 1, + maxOutstandingReadRequests: 1, + maxReadRequestSize: 1, + maxWriteRequestOperationCount: 1, + maxWriteRequestSize: 1, + maxOutstandingWriteRequests: 1, + maxWriteBufferCount: 1, + maxWriteBufferSize: 1, + maxRetryDelay: 1, + readPollTimeout: 1, shards: [], }, { name: 'follower index 2', + remoteCluster: 'cluster 2', + leaderIndex: 'leader 2', + status: 'paused', + maxReadRequestOperationCount: 2, + maxOutstandingReadRequests: 2, + maxReadRequestSize: 2, + maxWriteRequestOperationCount: 2, + maxWriteRequestSize: 2, + maxOutstandingWriteRequests: 2, + maxWriteBufferCount: 2, + maxWriteBufferSize: 2, + maxRetryDelay: 2, + readPollTimeout: 2, shards: [], }]; @@ -90,14 +147,19 @@ describe('[CCR] follower index serialization', () => { const deserializedFollowerIndex = { remoteCluster: 'remote cluster', leaderIndex: 'leader index', + maxReadRequestOperationCount: 'foo', + maxOutstandingReadRequests: 'foo', + maxReadRequestSize: 'foo', + maxWriteRequestOperationCount: 'foo', + maxWriteRequestSize: 'foo', + maxOutstandingWriteRequests: 'foo', + maxWriteBufferCount: 'foo', + maxWriteBufferSize: 'foo', + maxRetryDelay: 'foo', + readPollTimeout: 'foo', }; - const serializedFollowerIndex = { - remote_cluster: 'remote cluster', - leader_index: 'leader index', - }; - - expect(serializeFollowerIndex(deserializedFollowerIndex)).toEqual(serializedFollowerIndex); + expect(serializeFollowerIndex(deserializedFollowerIndex)).toMatchSnapshot(); }); }); }); diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js index 17354de2fb11ec..3f8e149659ae6f 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js @@ -15,14 +15,12 @@ import { import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory'; import { API_BASE_PATH } from '../../../common/constants'; -// import { esErrors } from '../../../fixtures'; // Temp for development to test ES error in UI - export const registerAutoFollowPatternRoutes = (server) => { const isEsError = isEsErrorFactory(server); const licensePreRouting = licensePreRoutingFactory(server); /** - * Returns a list of all Auto follow patterns + * Returns a list of all auto-follow patterns */ server.route({ path: `${API_BASE_PATH}/auto_follow_patterns`, @@ -33,8 +31,6 @@ export const registerAutoFollowPatternRoutes = (server) => { handler: async (request) => { const callWithRequest = callWithRequestFactory(server, request); - // throw wrapEsError(esErrors[403]); // Temp for development to test ES error in UI. MUST be commented in CR - try { const response = await callWithRequest('ccr.autoFollowPatterns'); return ({ @@ -118,7 +114,7 @@ export const registerAutoFollowPatternRoutes = (server) => { }); /** - * Returns a single Auto follow pattern + * Returns a single auto-follow pattern */ server.route({ path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, @@ -145,7 +141,7 @@ export const registerAutoFollowPatternRoutes = (server) => { }); /** - * Delete an auto follow pattern + * Delete an auto-follow pattern */ server.route({ path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js index 781f4d6ec6cd56..33012ddbc779f0 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/ccr.js @@ -7,7 +7,6 @@ import { callWithRequestFactory } from '../../lib/call_with_request_factory'; import { isEsErrorFactory } from '../../lib/is_es_error_factory'; import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; import { deserializeAutoFollowStats } from '../../lib/ccr_stats_serialization'; -import { deserializeListFollowerIndices } from '../../lib/follower_index_serialization'; import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory'; import { API_BASE_PATH } from '../../../common/constants'; @@ -15,49 +14,72 @@ export const registerCcrRoutes = (server) => { const isEsError = isEsErrorFactory(server); const licensePreRouting = licensePreRoutingFactory(server); - const getStatsHandler = async (request) => { - const callWithRequest = callWithRequestFactory(server, request); - - try { - const response = await callWithRequest('ccr.stats'); - return { - autoFollow: deserializeAutoFollowStats(response.auto_follow_stats), - follow: { - indices: deserializeListFollowerIndices(response.follow_stats.indices) - } - }; - } catch(err) { - if (isEsError(err)) { - throw wrapEsError(err); - } - throw wrapUnknownError(err); - } - }; - /** - * Returns CCR stats + * Returns Auto-follow stats */ server.route({ - path: `${API_BASE_PATH}/stats`, + path: `${API_BASE_PATH}/stats/auto_follow`, method: 'GET', config: { pre: [ licensePreRouting ] }, - handler: getStatsHandler, + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const { + auto_follow_stats: autoFollowStats, + } = await callWithRequest('ccr.stats'); + + return deserializeAutoFollowStats(autoFollowStats); + } catch(err) { + if (isEsError(err)) { + throw wrapEsError(err); + } + throw wrapUnknownError(err); + } + }, }); /** - * Returns Auto-follow stats + * Returns whether the user has CCR permissions */ server.route({ - path: `${API_BASE_PATH}/stats/auto-follow`, + path: `${API_BASE_PATH}/permissions`, method: 'GET', config: { pre: [ licensePreRouting ] }, handler: async (request) => { - const { autoFollow } = await getStatsHandler(request); - return autoFollow; + const callWithRequest = callWithRequestFactory(server, request); + + try { + const { + has_all_requested: hasPermission, + cluster, + } = await callWithRequest('ccr.permissions', { + body: { + cluster: ['manage', 'manage_ccr'], + }, + }); + + const missingClusterPrivileges = Object.keys(cluster).reduce((permissions, permissionName) => { + if (!cluster[permissionName]) { + permissions.push(permissionName); + return permissions; + } + }, []); + + return { + hasPermission, + missingClusterPrivileges, + }; + } catch(err) { + if (isEsError(err)) { + throw wrapEsError(err); + } + throw wrapUnknownError(err); + } }, }); }; diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js new file mode 100644 index 00000000000000..81e0990c7691c0 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js @@ -0,0 +1,311 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../lib/is_es_error_factory'; +import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; +import { + deserializeFollowerIndex, + deserializeListFollowerIndices, + serializeFollowerIndex, + serializeAdvancedSettings, +} from '../../lib/follower_index_serialization'; +import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory'; +import { API_BASE_PATH } from '../../../common/constants'; +import { removeEmptyFields } from '../../../common/services/utils'; + +export const registerFollowerIndexRoutes = (server) => { + const isEsError = isEsErrorFactory(server); + const licensePreRouting = licensePreRoutingFactory(server); + + /** + * Returns a list of all follower indices + */ + server.route({ + path: `${API_BASE_PATH}/follower_indices`, + method: 'GET', + config: { + pre: [ licensePreRouting ] + }, + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + + try { + const { + follower_indices: followerIndices + } = await callWithRequest('ccr.info', { id: '_all' }); + + const { + follow_stats: { + indices: followerIndicesStats + } + } = await callWithRequest('ccr.stats'); + + const followerIndicesStatsMap = followerIndicesStats.reduce((map, stats) => { + map[stats.index] = stats; + return map; + }, {}); + + const collatedFollowerIndices = followerIndices.map(followerIndex => { + return { + ...followerIndex, + ...followerIndicesStatsMap[followerIndex.follower_index] + }; + }); + + return ({ + indices: deserializeListFollowerIndices(collatedFollowerIndices) + }); + } catch(err) { + if (isEsError(err)) { + throw wrapEsError(err); + } + throw wrapUnknownError(err); + } + }, + }); + + /** + * Returns a single follower index pattern + */ + server.route({ + path: `${API_BASE_PATH}/follower_indices/{id}`, + method: 'GET', + config: { + pre: [ licensePreRouting ] + }, + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const { id } = request.params; + + try { + const { + follower_indices: followerIndices + } = await callWithRequest('ccr.info', { id }); + + const followerIndexInfo = followerIndices && followerIndices[0]; + + if(!followerIndexInfo) { + const error = Boom.notFound(`The follower index "${id}" does not exist.`); + throw(error); + } + + // If this follower is paused, skip call to ES stats api since it will return 404 + if(followerIndexInfo.status === 'paused') { + return deserializeFollowerIndex({ + ...followerIndexInfo + }); + } else { + const { + indices: followerIndicesStats + } = await callWithRequest('ccr.followerIndexStats', { id }); + + return deserializeFollowerIndex({ + ...followerIndexInfo, + ...(followerIndicesStats ? followerIndicesStats[0] : {}) + }); + } + } catch(err) { + if (isEsError(err)) { + throw wrapEsError(err); + } + throw wrapUnknownError(err); + } + }, + }); + + /** + * Create a follower index + */ + server.route({ + path: `${API_BASE_PATH}/follower_indices`, + method: 'POST', + config: { + pre: [ licensePreRouting ] + }, + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const { name, ...rest } = request.payload; + const body = removeEmptyFields(serializeFollowerIndex(rest)); + + try { + return await callWithRequest('ccr.saveFollowerIndex', { name, body }); + } catch(err) { + if (isEsError(err)) { + throw wrapEsError(err); + } + throw wrapUnknownError(err); + } + }, + }); + + /** + * Edit a follower index + */ + server.route({ + path: `${API_BASE_PATH}/follower_indices/{id}`, + method: 'PUT', + config: { + pre: [ licensePreRouting ] + }, + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const { id: _id } = request.params; + const { isPaused = false } = request.payload; + const body = removeEmptyFields(serializeAdvancedSettings(request.payload)); + + // We need to first pause the follower and then resume it passing the advanced settings + try { + // Pause follower if not already paused + if(!isPaused) { + await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); + } + + // Resume follower + return await callWithRequest('ccr.resumeFollowerIndex', { id: _id, body }); + } catch(err) { + if (isEsError(err)) { + throw wrapEsError(err); + } + throw wrapUnknownError(err); + } + }, + }); + + /** + * Pauses a follower index + */ + server.route({ + path: `${API_BASE_PATH}/follower_indices/{id}/pause`, + method: 'PUT', + config: { + pre: [ licensePreRouting ] + }, + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsPaused = []; + const errors = []; + + await Promise.all(ids.map((_id) => ( + callWithRequest('ccr.pauseFollowerIndex', { id: _id }) + .then(() => itemsPaused.push(_id)) + .catch(err => { + if (isEsError(err)) { + errors.push({ id: _id, error: wrapEsError(err) }); + } else { + errors.push({ id: _id, error: wrapUnknownError(err) }); + } + }) + ))); + + return { + itemsPaused, + errors + }; + }, + }); + + /** + * Resumes a follower index + */ + server.route({ + path: `${API_BASE_PATH}/follower_indices/{id}/resume`, + method: 'PUT', + config: { + pre: [ licensePreRouting ] + }, + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsResumed = []; + const errors = []; + + await Promise.all(ids.map((_id) => ( + callWithRequest('ccr.resumeFollowerIndex', { id: _id }) + .then(() => itemsResumed.push(_id)) + .catch(err => { + if (isEsError(err)) { + errors.push({ id: _id, error: wrapEsError(err) }); + } else { + errors.push({ id: _id, error: wrapUnknownError(err) }); + } + }) + ))); + + return { + itemsResumed, + errors + }; + }, + }); + + /** + * Unfollow follower index's leader index + */ + server.route({ + path: `${API_BASE_PATH}/follower_indices/{id}/unfollow`, + method: 'PUT', + config: { + pre: [ licensePreRouting ] + }, + handler: async (request) => { + + const callWithRequest = callWithRequestFactory(server, request); + const { id } = request.params; + const ids = id.split(','); + + const itemsUnfollowed = []; + const itemsNotOpen = []; + const errors = []; + + await Promise.all(ids.map(async (_id) => { + try { + // Try to pause follower, let it fail silently since it may already be paused + try { + await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); + } catch (e) { + // Swallow errors + } + + // Close index + await callWithRequest('indices.close', { index: _id }); + + // Unfollow leader + await callWithRequest('ccr.unfollowLeaderIndex', { id: _id }); + + // Try to re-open the index, store failures in a separate array to surface warnings in the UI + // This will allow users to query their index normally after unfollowing + try { + await callWithRequest('indices.open', { index: _id }); + } catch (e) { + itemsNotOpen.push(_id); + } + + // Push success + itemsUnfollowed.push(_id); + } catch (err) { + if (isEsError(err)) { + errors.push({ id: _id, error: wrapEsError(err) }); + } else { + errors.push({ id: _id, error: wrapUnknownError(err) }); + } + } + })); + + return { + itemsUnfollowed, + itemsNotOpen, + errors + }; + }, + }); +}; diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js new file mode 100644 index 00000000000000..5d4a6c2a567952 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js @@ -0,0 +1,294 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { callWithRequestFactory } from '../../lib/call_with_request_factory'; +import { isEsErrorFactory } from '../../lib/is_es_error_factory'; +import { registerFollowerIndexRoutes } from './follower_index'; +import { + getFollowerIndexStatsMock, + getFollowerIndexListStatsMock, + getFollowerIndexInfoMock, + getFollowerIndexListInfoMock, +} from '../../../fixtures'; +import { deserializeFollowerIndex } from '../../lib/follower_index_serialization'; + +jest.mock('../../lib/call_with_request_factory'); +jest.mock('../../lib/is_es_error_factory'); +jest.mock('../../lib/license_pre_routing_factory'); + +const DESERIALIZED_KEYS = Object.keys(deserializeFollowerIndex({ + ...getFollowerIndexInfoMock(), + ...getFollowerIndexStatsMock() +})); + +/** + * Hashtable to save the route handlers + */ +const routeHandlers = {}; + +/** + * Helper to extract all the different server route handler so we can easily call them in our tests. + * + * Important: This method registers the handlers in the order that they appear in the file, so + * if a 'server.route()' call is moved or deleted, then the HANDLER_INDEX_TO_ACTION must be updated here. + */ +const registerHandlers = () => { + let index = 0; + + const HANDLER_INDEX_TO_ACTION = { + 0: 'list', + 1: 'get', + 2: 'create', + 3: 'edit', + 4: 'pause', + 5: 'resume', + 6: 'unfollow', + }; + + const server = { + route({ handler }) { + // Save handler and increment index + routeHandlers[HANDLER_INDEX_TO_ACTION[index]] = handler; + index++; + }, + }; + + registerFollowerIndexRoutes(server); +}; + +/** + * Queue to save request response and errors + * It allows us to fake multiple responses from the + * callWithRequestFactory() when the request handler call it + * multiple times. + */ +let requestResponseQueue = []; + +/** + * Helper to mock the response from the call to Elasticsearch + * + * @param {*} err The mock error to throw + * @param {*} response The response to return + */ +const setHttpRequestResponse = (error, response) => { + requestResponseQueue.push({ error, response }); +}; + +const resetHttpRequestResponses = () => requestResponseQueue = []; + +const getNextResponseFromQueue = () => { + if (!requestResponseQueue.length) { + return null; + } + + const next = requestResponseQueue.shift(); + if (next.error) { + return Promise.reject(next.error); + } + return Promise.resolve(next.response); +}; + +describe('[CCR API Routes] Follower Index', () => { + let routeHandler; + + beforeAll(() => { + isEsErrorFactory.mockReturnValue(() => false); + callWithRequestFactory.mockReturnValue(getNextResponseFromQueue); + registerHandlers(); + }); + + describe('list()', () => { + beforeEach(() => { + routeHandler = routeHandlers.list; + }); + + it('deserializes the response from Elasticsearch', async () => { + const totalResult = 2; + const infoResult = getFollowerIndexListInfoMock(totalResult); + const statsResult = getFollowerIndexListStatsMock(totalResult, infoResult.follower_indices.map(index => index.follower_index)); + setHttpRequestResponse(null, infoResult); + setHttpRequestResponse(null, statsResult); + + const response = await routeHandler(); + const followerIndex = response.indices[0]; + + expect(response.indices.length).toEqual(totalResult); + expect(Object.keys(followerIndex)).toEqual(DESERIALIZED_KEYS); + }); + }); + + describe('get()', () => { + beforeEach(() => { + routeHandler = routeHandlers.get; + }); + + it('should return a single resource even though ES return an array with 1 item', async () => { + const mockId = 'test1'; + const followerIndexInfo = getFollowerIndexInfoMock(mockId); + const followerIndexStats = getFollowerIndexStatsMock(mockId); + + setHttpRequestResponse(null, { follower_indices: [followerIndexInfo] }); + setHttpRequestResponse(null, { indices: [followerIndexStats] }); + + const response = await routeHandler({ params: { id: mockId } }); + expect(Object.keys(response)).toEqual(DESERIALIZED_KEYS); + }); + }); + + describe('create()', () => { + beforeEach(() => { + resetHttpRequestResponses(); + routeHandler = routeHandlers.create; + }); + + it('should return 200 status when follower index is created', async () => { + setHttpRequestResponse(null, { acknowledge: true }); + + const response = await routeHandler({ + payload: { + name: 'follower_index', + remoteCluster: 'remote_cluster', + leaderIndex: 'leader_index', + }, + }); + + expect(response).toEqual({ acknowledge: true }); + }); + }); + + describe('pause()', () => { + beforeEach(() => { + resetHttpRequestResponses(); + routeHandler = routeHandlers.pause; + }); + + it('should pause a single item', async () => { + setHttpRequestResponse(null, { acknowledge: true }); + + const response = await routeHandler({ params: { id: '1' } }); + + expect(response.itemsPaused).toEqual(['1']); + expect(response.errors).toEqual([]); + }); + + it('should accept a list of ids to pause', async () => { + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + + const response = await routeHandler({ params: { id: '1,2,3' } }); + + expect(response.itemsPaused).toEqual(['1', '2', '3']); + }); + + it('should catch error and return them in array', async () => { + const error = new Error('something went wrong'); + error.response = '{ "error": {} }'; + + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(error); + + const response = await routeHandler({ params: { id: '1,2' } }); + + expect(response.itemsPaused).toEqual(['1']); + expect(response.errors[0].id).toEqual('2'); + }); + }); + + describe('resume()', () => { + beforeEach(() => { + resetHttpRequestResponses(); + routeHandler = routeHandlers.resume; + }); + + it('should resume a single item', async () => { + setHttpRequestResponse(null, { acknowledge: true }); + + const response = await routeHandler({ params: { id: '1' } }); + + expect(response.itemsResumed).toEqual(['1']); + expect(response.errors).toEqual([]); + }); + + it('should accept a list of ids to resume', async () => { + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + + const response = await routeHandler({ params: { id: '1,2,3' } }); + + expect(response.itemsResumed).toEqual(['1', '2', '3']); + }); + + it('should catch error and return them in array', async () => { + const error = new Error('something went wrong'); + error.response = '{ "error": {} }'; + + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(error); + + const response = await routeHandler({ params: { id: '1,2' } }); + + expect(response.itemsResumed).toEqual(['1']); + expect(response.errors[0].id).toEqual('2'); + }); + }); + + describe('unfollow()', () => { + beforeEach(() => { + resetHttpRequestResponses(); + routeHandler = routeHandlers.unfollow; + }); + + it('should unfollow await single item', async () => { + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + + const response = await routeHandler({ params: { id: '1' } }); + + expect(response.itemsUnfollowed).toEqual(['1']); + expect(response.errors).toEqual([]); + }); + + it('should accept a list of ids to unfollow', async () => { + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + + const response = await routeHandler({ params: { id: '1,2,3' } }); + + expect(response.itemsUnfollowed).toEqual(['1', '2', '3']); + }); + + it('should catch error and return them in array', async () => { + const error = new Error('something went wrong'); + error.response = '{ "error": {} }'; + + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(null, { acknowledge: true }); + setHttpRequestResponse(error); + + const response = await routeHandler({ params: { id: '1,2' } }); + + expect(response.itemsUnfollowed).toEqual(['1']); + expect(response.errors[0].id).toEqual('2'); + }); + }); +}); diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/register_routes.js b/x-pack/plugins/cross_cluster_replication/server/routes/register_routes.js index fec6d152f160c2..6e4088ec8600f3 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/register_routes.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/register_routes.js @@ -5,9 +5,11 @@ */ import { registerAutoFollowPatternRoutes } from './api/auto_follow_pattern'; +import { registerFollowerIndexRoutes } from './api/follower_index'; import { registerCcrRoutes } from './api/ccr'; export function registerRoutes(server) { registerAutoFollowPatternRoutes(server); + registerFollowerIndexRoutes(server); registerCcrRoutes(server); } diff --git a/x-pack/plugins/remote_clusters/public/index.scss b/x-pack/plugins/remote_clusters/public/index.scss index b25832255cecec..2d1a6374352e9a 100644 --- a/x-pack/plugins/remote_clusters/public/index.scss +++ b/x-pack/plugins/remote_clusters/public/index.scss @@ -1,5 +1,6 @@ // Import the EUI global scope so we can use EUI constants @import 'ui/public/styles/_styling_constants'; +@import './sections/remote_cluster_list/components/connection_status/index'; // Index management plugin styles @@ -10,14 +11,6 @@ // remoteClustersChart__legend--small // remoteClustersChart__legend-isLoading -/** - * 1. Override EUI styles. - */ -.remoteClusterAddPage { - max-width: 1000px !important; /* 1 */ - width: 100% !important; /* 1 */ -} - /** * 1. Override EuiFormRow styles. Otherwise the switch will jump around when toggled on and off, * as the 'Reset to defaults' link is added to and removed from the DOM. diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap new file mode 100644 index 00000000000000..b4b6eb2afc962e --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/__snapshots__/remote_cluster_form.test.js.snap @@ -0,0 +1,539 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RemoteClusterForm renders untouched state 1`] = ` +Array [ +
+
+
+
+

+ Name +

+
+
+ A unique name for the remote cluster. +
+
+
+
+
+ +
+
+ +
+
+
+ Name can only contain letters, numbers, underscores, and dashes. +
+
+
+
+
+
+
+
+

+ Seed nodes for cluster discovery +

+
+
+

+ A list of remote cluster nodes to query for the cluster state. Specify multiple seed nodes so discovery doesn't fail if a node is unavailable. +

+
+
+
+
+
+ + +
+
+
+
+
+
+

+ Make remote cluster optional +

+
+
+

+ By default, a request fails if any of the queried remote clusters are unavailable. To continue sending a request to other remote clusters if this cluster is unavailable, enable + + Skip if unavailable + + . + + Learn more. + +

+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+
+
, +
, +
+
+ +
+
, +] +`; + +exports[`RemoteClusterForm validation renders invalid state and a global form error when the user tries to submit an invalid form 1`] = ` +Array [ +
+ +
+
+ +
+
+
+ Name is required. +
+
+ Name can only contain letters, numbers, underscores, and dashes. +
+
, +
+ + , +
+
+ + + + + + + + + + + + + +
+
, +
+
+ + + Fix errors before continuing. + +
+
, +] +`; diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/remote_cluster_form.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/remote_cluster_form.js index dee024944d33ab..e927d6b4b2b48e 100644 --- a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/remote_cluster_form.js +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/remote_cluster_form.js @@ -32,11 +32,10 @@ import { } from '@elastic/eui'; import { - isSeedNodeValid, - isSeedNodePortValid, -} from '../../../services'; - -import { skippingDisconnectedClustersUrl } from '../../../services/documentation_links'; + skippingDisconnectedClustersUrl, + transportPortUrl, +} from '../../../services/documentation_links'; +import { validateName, validateSeeds, validateSeed } from './validators'; const defaultFields = { name: '', @@ -78,39 +77,10 @@ export const RemoteClusterForm = injectI18n( getFieldsErrors(fields, seedInput = '') { const { name, seeds } = fields; - const errors = {}; - - if (!name || !name.trim()) { - errors.name = ( - - ); - } else if (name.match(/[^a-zA-Z\d\-_]/)) { - errors.name = ( - - ); - } - - if (!seeds.some(seed => Boolean(seed.trim()))) { - // If the user hasn't entered any seeds then we only want to prompt them for some if they - // aren't already in the process of entering one in. In this case, we'll just show the - // combobox-specific validation. - if (!seedInput) { - errors.seeds = ( - - ); - } - } - - return errors; + return { + name: validateName(name), + seeds: validateSeeds(seeds, seedInput), + }; } onFieldsChange = (changedFields) => { @@ -156,44 +126,13 @@ export const RemoteClusterForm = injectI18n( save(cluster); }; - getLocalSeedErrors = (seedNode) => { - const { intl } = this.props; - - const errors = []; - - if (!seedNode) { - return errors; - } - - const isInvalid = !isSeedNodeValid(seedNode); - - if (isInvalid) { - errors.push(intl.formatMessage({ - id: 'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidCharactersMessage', - defaultMessage: `Seed node must use host:port format. Example: 127.0.0.1:9400, localhost:9400. - Hosts can only consist of letters, numbers, and dashes.`, - })); - } - - const isPortInvalid = !isSeedNodePortValid(seedNode); - - if (isPortInvalid) { - errors.push(intl.formatMessage({ - id: 'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage', - defaultMessage: 'A port is required.', - })); - } - - return errors; - }; - onCreateSeed = (newSeed) => { // If the user just hit enter without typing anything, treat it as a no-op. if (!newSeed) { return; } - const localSeedErrors = this.getLocalSeedErrors(newSeed); + const localSeedErrors = validateSeed(newSeed); if (localSeedErrors.length !== 0) { this.setState({ @@ -228,7 +167,7 @@ export const RemoteClusterForm = injectI18n( const { seeds } = fields; // Allow typing to clear the errors, but not to add new ones. - const errors = (!seedInput || this.getLocalSeedErrors(seedInput).length === 0) ? [] : localSeedErrors; + const errors = (!seedInput || validateSeed(seedInput).length === 0) ? [] : localSeedErrors; // EuiComboBox internally checks for duplicates and prevents calling onCreateOption if the // input is a duplicate. So we need to surface this error here instead. @@ -267,7 +206,7 @@ export const RemoteClusterForm = injectI18n( hasErrors = () => { const { fieldsErrors, localSeedErrors } = this.state; const errorValues = Object.values(fieldsErrors); - const hasErrors = errorValues.some(error => error !== undefined) || localSeedErrors.length; + const hasErrors = errorValues.some(error => error != null) || localSeedErrors.length; return hasErrors; }; @@ -318,6 +257,7 @@ export const RemoteClusterForm = injectI18n( fullWidth > + + + ), + }} /> )} isInvalid={showErrors} @@ -404,6 +354,7 @@ export const RemoteClusterForm = injectI18n( fullWidth > () => 'mockId'); + +describe('RemoteClusterForm', () => { + test(`renders untouched state`, () => { + const component = renderWithIntl( + {}} + /> + ); + 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( + {}}/> + ); + + findTestSubject(component, 'remoteClusterFormSaveButton').simulate('click'); + + const fieldsSnapshot = [ + 'remoteClusterFormNameFormRow', + 'remoteClusterFormSeedNodesFormRow', + 'remoteClusterFormSkipUnavailableFormRow', + 'remoteClusterFormGlobalError', + ].map(testSubject => { + const mountedField = findTestSubject(component, testSubject); + return takeMountedSnapshot(mountedField); + }); + + expect(fieldsSnapshot).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_name.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_name.test.js.snap new file mode 100644 index 00000000000000..520b78329a9761 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_name.test.js.snap @@ -0,0 +1,161 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`validateName rejects empty input ' ' 1`] = ` + +`; + +exports[`validateName rejects empty input 'null' 1`] = ` + +`; + +exports[`validateName rejects empty input 'undefined' 1`] = ` + +`; + +exports[`validateName rejects invalid characters ' ' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '!' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '#' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '$' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '%' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '&' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '(' 1`] = ` + +`; + +exports[`validateName rejects invalid characters ')' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '*' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '+' 1`] = ` + +`; + +exports[`validateName rejects invalid characters ',' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '.' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '<' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '>' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '?' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '@' 1`] = ` + +`; + +exports[`validateName rejects invalid characters '^' 1`] = ` + +`; diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_seeds.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_seeds.test.js.snap new file mode 100644 index 00000000000000..29eaa7105c8dc0 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/__snapshots__/validate_seeds.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`validateSeeds rejects empty seeds when there's no input 1`] = ` + +`; diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/index.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/index.js new file mode 100644 index 00000000000000..66a1016c7fcc84 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { validateName } from './validate_name'; +export { validateSeed } from './validate_seed'; +export { validateSeeds } from './validate_seeds'; diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.js new file mode 100644 index 00000000000000..3c7d4615bc3adb --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export function validateName(name) { + if (name == null || !name.trim()) { + return ( + + ); + } + + if (name.match(/[^a-zA-Z\d\-_]/)) { + return ( + + ); + } + + return null; +} diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.test.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.test.js new file mode 100644 index 00000000000000..f551968bb5fd88 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_name.test.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateName } from './validate_name'; + +describe('validateName', () => { + describe('rejects empty input', () => { + [' ', undefined, null].forEach(input => { + test(`'${input}'`, () => { + expect(validateName(input)).toMatchSnapshot(); + }); + }); + }); + + describe('rejects invalid characters', () => { + '!@#$%^&*()+?<> ,.'.split('').forEach(input => { + test(`'${input}'`, () => { + expect(validateName(input)).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.js new file mode 100644 index 00000000000000..fda426f86874f6 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +import { + isSeedNodeValid, + isSeedNodePortValid, +} from '../../../../services'; + +export function validateSeed(seed) { + const errors = []; + + if (!seed) { + return errors; + } + + const isValid = isSeedNodeValid(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.`, + }, + )); + } + + const isPortValid = isSeedNodePortValid(seed); + + if (!isPortValid) { + errors.push(i18n.translate( + 'xpack.remoteClusters.remoteClusterForm.localSeedError.invalidPortMessage', + { + defaultMessage: 'A port is required.', + }, + )); + } + + return errors; +} diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.test.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.test.js new file mode 100644 index 00000000000000..d648202f8d6a6d --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seed.test.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateSeed } from './validate_seed'; + +describe('validateSeeds', () => { + test(`rejects invalid seeds and invalid ports`, () => { + const errorsCount = validateSeed('&').length; + expect(errorsCount).toBe(2); + }); + + test(`accepts no seed`, () => { + const errorsCount = validateSeed('').length; + expect(errorsCount).toBe(0); + }); + + test(`accepts a valid seed with a valid port`, () => { + const errorsCount = validateSeed('seed:10').length; + expect(errorsCount).toBe(0); + }); +}); diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.js new file mode 100644 index 00000000000000..4fca4bf6e84e1e --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export function validateSeeds(seeds, seedInput) { + const seedsHaveBeenCreated = seeds.some(seed => Boolean(seed.trim())); + + if (seedsHaveBeenCreated) { + return null; + } + + // If the user hasn't entered any seeds then we only want to prompt them for some if they + // aren't already in the process of entering one in. In this case, we'll just show the + // combobox-specific validation. + if (seedInput) { + return null; + } + + return ( + + ); +} diff --git a/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.test.js b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.test.js new file mode 100644 index 00000000000000..9f9814ee214078 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/components/remote_cluster_form/validators/validate_seeds.test.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateSeeds } from './validate_seeds'; + +describe('validateSeeds', () => { + test(`rejects empty seeds when there's no input`, () => { + expect(validateSeeds([], '')).toMatchSnapshot(); + }); + + test(`accepts empty seeds when there's input`, () => { + expect(validateSeeds([], 'input')).toBe(null); + }); + + test(`accepts existing seeds`, () => { + expect(validateSeeds(['seed'])).toBe(null); + }); +}); diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js index 4187bfdd82fba4..69684ec234f1fb 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js @@ -4,20 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { - EuiPage, - EuiPageBody, EuiPageContent, } from '@elastic/eui'; import { CRUD_APP_BASE_PATH } from '../../constants'; -import { listBreadcrumb, addBreadcrumb } from '../../services'; +import { listBreadcrumb, addBreadcrumb, getRouter, redirect, extractQueryParams } from '../../services'; import { RemoteClusterPageTitle, RemoteClusterForm } from '../components'; export const RemoteClusterAdd = injectI18n( @@ -44,40 +42,41 @@ export const RemoteClusterAdd = injectI18n( }; cancel = () => { - const { history } = this.props; - history.push(CRUD_APP_BASE_PATH); + const { history, route: { location: { search } } } = getRouter(); + const { redirect: redirectUrl } = extractQueryParams(search); + + if (redirectUrl) { + const decodedRedirect = decodeURIComponent(redirectUrl); + redirect(decodedRedirect); + } else { + history.push(CRUD_APP_BASE_PATH); + } }; render() { const { isAddingCluster, addClusterError } = this.props; return ( - - - - - - )} - /> + + + )} + /> - - - - - + + ); } } diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js index b0b3f65e5c99ad..c4c40374b42b21 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js @@ -12,12 +12,10 @@ import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiLoadingSpinner, - EuiPage, - EuiPageBody, EuiPageContent, EuiSpacer, EuiText, @@ -25,7 +23,7 @@ import { } from '@elastic/eui'; import { CRUD_APP_BASE_PATH } from '../../constants'; -import { buildListBreadcrumb, editBreadcrumb } from '../../services'; +import { buildListBreadcrumb, editBreadcrumb, extractQueryParams, getRouter, getRouterLinkProps, redirect } from '../../services'; import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components'; const disabledFields = { @@ -85,13 +83,25 @@ export const RemoteClusterEdit = injectI18n( }; cancel = () => { - const { history, openDetailPanel } = this.props; + const { openDetailPanel } = this.props; const { clusterName } = this.state; - history.push(CRUD_APP_BASE_PATH); - openDetailPanel(clusterName); + const { history, route: { location: { search } } } = getRouter(); + const { redirect: redirectUrl } = extractQueryParams(search); + + if (redirectUrl) { + const decodedRedirect = decodeURIComponent(redirectUrl); + redirect(decodedRedirect); + } else { + history.push(CRUD_APP_BASE_PATH); + openDetailPanel(clusterName); + } }; renderContent() { + const { + clusterName, + } = this.state; + const { isLoading, cluster, @@ -126,26 +136,39 @@ export const RemoteClusterEdit = injectI18n( if (!cluster) { return ( - - - - - - - - + + + )} + color="danger" + iconType="alert" + > + + + + + + - - - - + + + + ); } @@ -184,33 +207,22 @@ export const RemoteClusterEdit = injectI18n( } render() { - const { - clusterName, - } = this.state; - return ( - - - - - - )} - /> + + + )} + /> - {this.renderContent()} - - - - + {this.renderContent()} + ); } } diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/_index.scss b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/_index.scss new file mode 100644 index 00000000000000..c85cb36c5dc5a0 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/_index.scss @@ -0,0 +1,6 @@ +/** + * 1. Prevent inherited flexbox layout from compressing this element on IE. + */ + .remoteClustersConnectionStatus__message { + flex-basis: auto !important; /* 1 */ +} diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/connection_status.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/connection_status.js index d316267f435e00..71d9e671fdae8a 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/connection_status.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/connection_status/connection_status.js @@ -56,7 +56,7 @@ export function ConnectionStatus({ isConnected }) { {icon} - + {message} diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js index cce5edebf7ecf6..fb73fd5bc92168 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js @@ -80,6 +80,7 @@ export const RemoveClusterButtonProvider = injectI18n( { /* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */ } +
+ + +`; diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/detail_panel/detail_panel.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/detail_panel/detail_panel.js index b90ad0148709f5..17b359801ff0d5 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/detail_panel/detail_panel.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/detail_panel/detail_panel.js @@ -29,6 +29,7 @@ import { } from '@elastic/eui'; import { CRUD_APP_BASE_PATH } from '../../../constants'; +import { getRouterLinkProps } from '../../../services'; import { ConfiguredByNodeWarning } from '../../components'; import { ConnectionStatus, RemoveClusterButtonProvider } from '../components'; @@ -287,11 +288,6 @@ export const DetailPanel = injectI18n( closeDetailPanel, } = this.props; - // Remote clusters configured by a node's elasticsearch.yml file can't be edited or removed. - if (!cluster || cluster.isConfiguredByNode) { - return null; - } - return ( @@ -308,38 +304,41 @@ export const DetailPanel = injectI18n( - - - - - {(removeCluster) => ( - - - - )} - - - - - - - - - - + {cluster && !cluster.isConfiguredByNode && ( + + + + + {(removeCluster) => ( + + + + )} + + + + + + + + + + + )} ); diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/detail_panel/detail_panel.test.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/detail_panel/detail_panel.test.js new file mode 100644 index 00000000000000..90a3a7966e0d4a --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/detail_panel/detail_panel.test.js @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Provider } from 'react-redux'; +import { renderWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +import { remoteClustersStore } from '../../../store'; +import { DetailPanel } from './detail_panel'; + +// Make sure we have deterministic aria IDs. +jest.mock('@elastic/eui/lib/components/form/form_row/make_id', () => () => 'fakeId'); + +jest.mock('../../../services', () => { + const services = require.requireActual('../../../services'); + return { + ...services, + getRouterLinkProps: (link) => ({ href: link }), + }; +}); + +describe('DetailPanel', () => { + const name = 'test-cluster'; + + const cluster = { + name, + seeds: ['seed'], + }; + + describe('is rendered', () => { + const component = renderWithIntl( + + {}} + /> + + ); + + expect(component).toMatchSnapshot(); + }); + + describe('actions', () => { + test('remove button displays a confirmation modal when clicked', () => { + const component = mountWithIntl( + + {}} + /> + + ); + + const rowName = findTestSubject(component, 'remoteClusterDetailPanelRemoveButton'); + rowName.simulate('click'); + const confirmModal = findTestSubject(component, 'remoteClustersDeleteConfirmModal'); + expect(confirmModal).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_list.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_list.js index 205bdf5428919b..b5be1b72426a61 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_list.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_list.js @@ -56,7 +56,7 @@ export const RemoteClusterList = injectI18n( isRemovingCluster: PropTypes.bool, } - static getDerivedStateFromProps(props) { + componentDidUpdate() { const { openDetailPanel, closeDetailPanel, @@ -66,7 +66,7 @@ export const RemoteClusterList = injectI18n( search, }, }, - } = props; + } = this.props; const { cluster: clusterName } = extractQueryParams(search); @@ -76,12 +76,8 @@ export const RemoteClusterList = injectI18n( } else if (isDetailPanelOpen) { closeDetailPanel(); } - - return null; } - state = {}; - componentDidMount() { this.props.loadClusters(); this.interval = setInterval(this.props.refreshClusters, REFRESH_RATE_MS); @@ -92,18 +88,37 @@ export const RemoteClusterList = injectI18n( clearInterval(this.interval); } - getHeaderSection() { + getHeaderSection(isAuthorized) { return ( - - -

- -

-
-
+ + + + +

+ +

+
+
+ + { isAuthorized && ( + + + + + + )} +
+ +
); } @@ -128,20 +143,16 @@ export const RemoteClusterList = injectI18n( defaultMessage: 'Permission error', }); return ( - - {this.getHeaderSection()} - - - - - + + + ); } @@ -159,23 +170,20 @@ export const RemoteClusterList = injectI18n( defaultMessage: 'Error loading remote clusters', }); return ( - - {this.getHeaderSection()} - - - {statusCode} {errorString} - - + + {statusCode} {errorString} + ); } renderEmpty() { return ( @@ -212,58 +220,37 @@ export const RemoteClusterList = injectI18n( ); } - renderList() { - const { isLoading, clusters } = this.props; - - let table; - - if (isLoading) { - table = ( - - - - - - - - - - - - - - ); - } else { - table = ; - } - + renderLoading() { return ( - - - {this.getHeaderSection()} - - - + + + + + + + + - - - + + + + + ); + } - {table} + renderList() { + const { clusters } = this.props; + return ( + + ); @@ -271,28 +258,31 @@ export const RemoteClusterList = injectI18n( render() { const { isLoading, clusters, clusterLoadError } = this.props; + const isEmpty = !isLoading && !clusters.length; + const isAuthorized = !clusterLoadError || clusterLoadError.status !== 403; + const isHeaderVisible = clusterLoadError || !isEmpty; let content; if (clusterLoadError) { - if (clusterLoadError.status === 403) { + if (!isAuthorized) { content = this.renderNoPermission(); } else { content = this.renderError(clusterLoadError); } - } else if (!isLoading && !clusters.length) { + } else if (isEmpty) { content = this.renderEmpty(); + } else if (isLoading) { + content = this.renderLoading(); } else { content = this.renderList(); } return ( - + + {(isHeaderVisible) && this.getHeaderSection(isAuthorized)} {content} - {this.renderBlockingAction()} diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_list.test.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_list.test.js new file mode 100644 index 00000000000000..186bf36cc3b45a --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_list.test.js @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Provider } from 'react-redux'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +import { remoteClustersStore } from '../../store'; +import { RemoteClusterList } from './remote_cluster_list'; + +jest.mock('ui/chrome', () => ({ + addBasePath: () => {}, + breadcrumbs: { + set: () => {}, + }, +})); + +jest.mock('../../services', () => { + const services = require.requireActual('../../services'); + return { + ...services, + getRouterLinkProps: (link) => ({ href: link }), + }; +}); + +describe('RemoteClusterList', () => { + test('renders empty prompt when loading is complete and there are no clusters', () => { + const component = mountWithIntl( + + {}} + refreshClusters={() => {}} + openDetailPanel={() => {}} + closeDetailPanel={() => {}} + isDetailPanelOpen={false} + clusters={[]} + isLoading={false} + isCopyingCluster={false} + isRemovingCluster={false} + history={{ location: { search: '' } }} + /> + + ); + + const emptyPrompt = findTestSubject(component, 'remoteClusterListEmptyPrompt'); + expect(emptyPrompt).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/__snapshots__/remote_cluster_table.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/__snapshots__/remote_cluster_table.test.js.snap new file mode 100644 index 00000000000000..bf21cd3e7de359 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/__snapshots__/remote_cluster_table.test.js.snap @@ -0,0 +1,470 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RemoteClusterTable renders a row for an API-defined remote cluster 1`] = ` + + +
+
+ +
+
+
+ + +
+ +
+ + +
+ seed, seed2 +
+ + +
+
+
+
+
+ + + +
+
+
+ Not connected +
+
+
+
+
+ + + + + +
+
+
+ + +
+ +
+ + +
+
+ + + +
+ +
+ + +`; + +exports[`RemoteClusterTable renders a row with a tooltip for a remote cluster defined in elasticsearch.yml 1`] = ` + + +
+
+ +
+
+
+ + +
+
+
+ +
+
+ + + + + +
+
+
+ + +
+ seed, seed2 +
+ + +
+
+
+
+
+ + + +
+
+
+ Not connected +
+
+
+
+
+ + + + + +
+
+
+ + +
+ +
+ + +
+
+ + + +
+
+ + + +
+
+ + +`; diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js index 5976c182f22614..1c5b01700d5726 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.js @@ -21,6 +21,7 @@ import { } from '@elastic/eui'; import { CRUD_APP_BASE_PATH } from '../../../constants'; +import { getRouterLinkProps } from '../../../services'; import { ConnectionStatus, RemoveClusterButtonProvider } from '../components'; export const RemoteClusterTable = injectI18n( @@ -90,7 +91,10 @@ export const RemoteClusterTable = injectI18n( truncateText: false, render: (name, { isConfiguredByNode }) => { const link = ( - openDetailPanel(name)}> + openDetailPanel(name)} + > {name} ); @@ -168,6 +172,7 @@ export const RemoteClusterTable = injectI18n( {(removeCluster) => ( diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.test.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.test.js new file mode 100644 index 00000000000000..aa3d8e6d66e400 --- /dev/null +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/remote_cluster_table/remote_cluster_table.test.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Provider } from 'react-redux'; +import { renderWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +import { remoteClustersStore } from '../../../store'; +import { RemoteClusterTable } from './remote_cluster_table'; + +// Make sure we have deterministic aria IDs. +jest.mock('@elastic/eui/lib/components/form/form_row/make_id', () => () => 'fakeId'); + +jest.mock('../../../services', () => { + const services = require.requireActual('../../../services'); + return { + ...services, + getRouterLinkProps: (link) => ({ href: link }), + }; +}); + +describe('RemoteClusterTable', () => { + test('renders a row for an API-defined remote cluster', () => { + const clusters = [{ + name: 'test-cluster', + seeds: ['seed', 'seed2'], + }]; + + const component = renderWithIntl( + + {}} + /> + + ); + + expect(component.find('tbody > tr').first()).toMatchSnapshot(); + }); + + test('renders a row with a tooltip for a remote cluster defined in elasticsearch.yml', () => { + const clusters = [{ + name: 'test-cluster-in-elasticsearch-yml', + seeds: ['seed', 'seed2'], + isConfiguredByNode: true, + }]; + + const component = renderWithIntl( + + {}} + /> + + ); + + expect(component.find('tbody > tr').first()).toMatchSnapshot(); + }); + + describe('row actions', () => { + const name = 'test-cluster'; + + const clusters = [{ + name, + seeds: ['seed'], + }]; + + let component; + + beforeEach(() => { + component = mountWithIntl( + + {}} + /> + + ); + }); + + test('name link opens detail panel when clicked', () => { + const rowName = findTestSubject(component, `remoteClusterTableRowName-${name}`); + rowName.simulate('click'); + const detailPanel = findTestSubject(component, 'remoteClusterDetailFlyout'); + expect(detailPanel).toBeTruthy(); + }); + + test('remove button displays a confirmation modal when clicked', () => { + const removeButton = findTestSubject(component, `remoteClusterTableRowRemoveButton-${name}`); + removeButton.simulate('click'); + const confirmModal = findTestSubject(component, 'remoteClustersDeleteConfirmModal'); + expect(confirmModal).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/remote_clusters/public/services/documentation_links.js b/x-pack/plugins/remote_clusters/public/services/documentation_links.js index a7ccaa7df88009..d094f05ce1eda3 100644 --- a/x-pack/plugins/remote_clusters/public/services/documentation_links.js +++ b/x-pack/plugins/remote_clusters/public/services/documentation_links.js @@ -10,3 +10,4 @@ const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LIN export const skippingDisconnectedClustersUrl = `${esBase}/modules-cross-cluster-search.html#_skipping_disconnected_clusters`; export const remoteClustersUrl = `${esBase}/modules-remote-clusters.html`; +export const transportPortUrl = `${esBase}/modules-transport.html`; diff --git a/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js b/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js index eace258fd083d2..5506bc75108c34 100644 --- a/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js +++ b/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js @@ -94,7 +94,7 @@ export const addCluster = (cluster) => async (dispatch) => { })); const decodedRedirect = decodeURIComponent(redirectUrl); - redirect(decodedRedirect); + redirect(`${decodedRedirect}?cluster=${cluster.name}`); } else { // This will open the new job in the detail panel. Note that we're *not* showing a success toast // here, because it would partially obscure the detail panel. diff --git a/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js b/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js index 2d0b61e6dad01d..f209c575498f75 100644 --- a/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js +++ b/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js @@ -5,13 +5,14 @@ */ import { i18n } from '@kbn/i18n'; -import { fatalError } from 'ui/notify'; +import { fatalError, toastNotifications } from 'ui/notify'; import { CRUD_APP_BASE_PATH } from '../../constants'; import { loadClusters } from './load_clusters'; import { - editCluster as sendEditClusterRequest, + editCluster as sendEditClusterRequest, extractQueryParams, getRouter, + redirect, } from '../../services'; import { @@ -66,12 +67,26 @@ export const editCluster = (cluster) => async (dispatch) => { type: EDIT_CLUSTER_SUCCESS, }); - // This will open the new job in the detail panel. Note that we're *not* showing a success toast - // here, because it would partially obscure the detail panel. - getRouter().history.push({ - pathname: `${CRUD_APP_BASE_PATH}/list`, - search: `?cluster=${cluster.name}`, - }); + const { history, route: { location: { search } } } = getRouter(); + const { redirect: redirectUrl } = extractQueryParams(search); + + if (redirectUrl) { + // A toast is only needed if we're leaving the app. + toastNotifications.addSuccess(i18n.translate('xpack.remoteClusters.editAction.successTitle', { + defaultMessage: `Edited remote cluster '{name}'`, + values: { name: cluster.name }, + })); + + const decodedRedirect = decodeURIComponent(redirectUrl); + redirect(`${decodedRedirect}?cluster=${cluster.name}`); + } else { + // This will open the edited cluster in the detail panel. Note that we're *not* showing a success toast + // here, because it would partially obscure the detail panel. + history.push({ + pathname: `${CRUD_APP_BASE_PATH}/list`, + search: `?cluster=${cluster.name}`, + }); + } }; export const startEditingCluster = ({ clusterName }) => (dispatch) => { diff --git a/x-pack/plugins/remote_clusters/server/lib/check_license/check_license.js b/x-pack/plugins/remote_clusters/server/lib/check_license/check_license.js index c589cbd0c89650..973ece733eedbf 100644 --- a/x-pack/plugins/remote_clusters/server/lib/check_license/check_license.js +++ b/x-pack/plugins/remote_clusters/server/lib/check_license/check_license.js @@ -26,6 +26,7 @@ export function checkLicense(xpackLicenseInfo) { }; } + // Remote Clusters are used in both CCS and CCR, and CCS is available for all licenses. const VALID_LICENSE_MODES = [ 'trial', 'basic', diff --git a/x-pack/plugins/remote_clusters/server/routes/api/remote_clusters/register_update_route.js b/x-pack/plugins/remote_clusters/server/routes/api/remote_clusters/register_update_route.js index caabe49093726c..bf09a331f46c3b 100644 --- a/x-pack/plugins/remote_clusters/server/routes/api/remote_clusters/register_update_route.js +++ b/x-pack/plugins/remote_clusters/server/routes/api/remote_clusters/register_update_route.js @@ -37,6 +37,12 @@ export function registerUpdateRoute(server) { } try { + // Delete existing cluster settings + // This is a workaround for: https://github.com/elastic/elasticsearch/issues/37799 + const deleteClusterPayload = serializeCluster({ name }); + await callWithRequest('cluster.putSettings', { body: deleteClusterPayload }); + + // Update cluster as new settings const updateClusterPayload = serializeCluster({ name, seeds, skipUnavailable }); const response = await callWithRequest('cluster.putSettings', { body: updateClusterPayload }); const acknowledged = get(response, 'acknowledged'); diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js index d3007e4b1bf953..8274b3b003ff1b 100644 --- a/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/auto_follow_pattern.js @@ -5,42 +5,21 @@ */ import expect from 'expect.js'; -import Chance from 'chance'; -import { API_BASE_PATH, REMOTE_CLUSTERS_API_BASE_PATH } from './constants'; -const chance = new Chance(); -const CHARS_POOL = 'abcdefghijklmnopqrstuvwxyz'; -const getRandomName = () => chance.string({ pool: CHARS_POOL }); -const CLUSTER_NAME = `test-${getRandomName()}`; +import { API_BASE_PATH } from './constants'; + +import { initClusterHelpers, getRandomString } from './lib'; + const AUTO_FOLLOW_PATTERNS_API_BASE_PATH = API_BASE_PATH + '/auto_follow_patterns'; export default function ({ getService }) { let autoFollowPatternsCreated = []; const supertest = getService('supertest'); - const addCluster = async (name = CLUSTER_NAME) => ( - await supertest - .post(`${REMOTE_CLUSTERS_API_BASE_PATH}`) - .set('kbn-xsrf', 'xxx') - .send({ - "name": name, - "seeds": [ - "localhost:9300" - ], - "skipUnavailable": true, - }) - ); - - const deleteCluster = (name = CLUSTER_NAME) => { - return ( - supertest - .delete(`${REMOTE_CLUSTERS_API_BASE_PATH}/${name}`) - .set('kbn-xsrf', 'xxx') - ); - }; + const { CLUSTER_NAME, addCluster, deleteAllClusters } = initClusterHelpers(supertest); - const deleteAutoFollowPattern = async (name) => ( - await supertest + const deleteAutoFollowPatternRequest = (name) => ( + supertest .delete(`${AUTO_FOLLOW_PATTERNS_API_BASE_PATH}/${name}`) .set('kbn-xsrf', 'xxx') ); @@ -51,7 +30,7 @@ export default function ({ getService }) { followIndexPattern: '{{leader_index}}_follower' }); - const createAutoFollowIndexRequest = (name = getRandomName(), payload = getAutoFollowIndexPayload()) => { + const createAutoFollowIndexRequest = (name = getRandomString(), payload = getAutoFollowIndexPayload()) => { autoFollowPatternsCreated.push(name); return supertest @@ -61,14 +40,13 @@ export default function ({ getService }) { }; const cleanUp = () => ( - Promise.all([deleteCluster(), ...autoFollowPatternsCreated.map(name => deleteAutoFollowPattern(name))]) + Promise.all([deleteAllClusters(), ...autoFollowPatternsCreated.map(deleteAutoFollowPatternRequest)]) .then(() => { autoFollowPatternsCreated = []; }) ); describe('auto follow patterns', () => { - afterEach(() => { return cleanUp(); }); @@ -102,7 +80,7 @@ export default function ({ getService }) { it('should create an auto-follow pattern when cluster is known', async () => { await addCluster(); - const name = getRandomName(); + const name = getRandomString(); const { body } = await createAutoFollowIndexRequest(name).expect(200); expect(body.acknowledged).to.eql(true); @@ -111,7 +89,7 @@ export default function ({ getService }) { describe('get()', () => { it('should return a 404 when the auto-follow pattern is not found', async () => { - const name = getRandomName(); + const name = getRandomString(); const { body } = await supertest .get(`${AUTO_FOLLOW_PATTERNS_API_BASE_PATH}/${name}`) .expect(404); @@ -120,7 +98,7 @@ export default function ({ getService }) { }); it('should return an auto-follow pattern that was created', async () => { - const name = getRandomName(); + const name = getRandomString(); const autoFollowPattern = getAutoFollowIndexPayload(); await addCluster(); diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js new file mode 100644 index 00000000000000..bd53f9f998aa1d --- /dev/null +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; + +import { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../../../plugins/cross_cluster_replication/common/constants'; +import { API_BASE_PATH } from './constants'; +import { initClusterHelpers, initElasticsearchIndicesHelpers, getRandomString } from './lib'; + +const FOLLOWER_INDICES_API_BASE_PATH = API_BASE_PATH + '/follower_indices'; + +export default function ({ getService }) { + let followerIndicesCreated = []; + const supertest = getService('supertest'); + const es = getService('es'); + + const { CLUSTER_NAME, addCluster, deleteAllClusters } = initClusterHelpers(supertest); + const { createIndex, deleteAllIndices } = initElasticsearchIndicesHelpers(es); + + const getFollowerIndexPayload = (leaderIndexName = getRandomString(), remoteCluster = CLUSTER_NAME, advancedSettings = {}) => ({ + remoteCluster, + leaderIndex: leaderIndexName, + ...advancedSettings, + }); + + const createFollowerIndexRequest = (name = getRandomString(), payload = getFollowerIndexPayload()) => { + followerIndicesCreated.push(name); + + return supertest + .post(FOLLOWER_INDICES_API_BASE_PATH) + .set('kbn-xsrf', 'xxx') + .send({ ...payload, name }); + }; + + const deleteFollowerIndexRequest = (name) => ( + supertest + .put(`${FOLLOWER_INDICES_API_BASE_PATH}/${name}/unfollow`) + .set('kbn-xsrf', 'xxx') + ); + + const cleanUp = () => ( + Promise.all([ + deleteAllClusters(), + deleteAllIndices(), + ...followerIndicesCreated.map(deleteFollowerIndexRequest) + ]).then(() => { followerIndicesCreated = []; }) + ); + + describe('follower indices', () => { + afterEach(() => cleanUp()); + + describe('list()', () => { + it('should return an empty array when there are no follower indices', async () => { + const { body } = await supertest + .get(FOLLOWER_INDICES_API_BASE_PATH) + .expect(200); + + expect(body).to.eql({ indices: [] }); + }); + }); + + describe('create()', () => { + let payload; + + beforeEach(async () => { + await addCluster(); + payload = getFollowerIndexPayload(); + }); + + it('should throw a 404 error when cluster is unknown', async () => { + payload.remoteCluster = 'unknown-cluster'; + + const { body } = await createFollowerIndexRequest(undefined, payload).expect(404); + expect(body.cause[0]).to.contain('no such remote cluster'); + }); + + it('should throw a 404 error trying to follow an unknown index', async () => { + const { body } = await createFollowerIndexRequest(undefined, payload).expect(404); + expect(body.cause[0]).to.contain('no such index'); + }); + + it('should create a follower index that follows an existing remote index', async () => { + // First let's create an index to follow + const leaderIndex = await createIndex(); + + const payload = getFollowerIndexPayload(leaderIndex); + const { body } = await createFollowerIndexRequest(undefined, payload).expect(200); + + expect(body).to.eql({ + follow_index_created: true, + follow_index_shards_acked: true, + index_following_started: true + }); + }); + }); + + describe('get()', () => { + beforeEach(async () => addCluster()); + + it('should return a 404 when the follower index does not exist', async () => { + const name = getRandomString(); + const { body } = await supertest + .get(`${FOLLOWER_INDICES_API_BASE_PATH}/${name}`) + .expect(404); + + expect(body.cause[0]).to.contain('no such index'); + }); + + it('should return a follower index that was created', async () => { + const leaderIndex = await createIndex(); + + const name = getRandomString(); + const payload = getFollowerIndexPayload(leaderIndex); + await createFollowerIndexRequest(name, payload).expect(200); + + const { body } = await supertest + .get(`${FOLLOWER_INDICES_API_BASE_PATH}/${name}`) + .expect(200); + + expect(body.leaderIndex).to.eql(leaderIndex); + expect(body.remoteCluster).to.eql(payload.remoteCluster); + }); + }); + + describe('Advanced settings', () => { + beforeEach(() => addCluster()); + + it('hard-coded values should match Elasticsearch default values', async () => { + /** + * To make sure that the hard-coded values in the client match the default + * from Elasticsearch, we will create a follower index without any advanced settings. + * When we then retrieve the follower index it will have all the advanced settings + * coming from ES. We can then compare those settings with our hard-coded values. + */ + const leaderIndex = await createIndex(); + + const name = getRandomString(); + const payload = getFollowerIndexPayload(leaderIndex); + await createFollowerIndexRequest(name, payload).expect(200); + + const { body } = await supertest.get(`${FOLLOWER_INDICES_API_BASE_PATH}/${name}`); + + Object.entries(FOLLOWER_INDEX_ADVANCED_SETTINGS).forEach(([key, value]) => { + expect(value).to.eql(body[key]); + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/index.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/index.js index f5c8124557360c..a033a03d613f9a 100644 --- a/x-pack/test/api_integration/apis/management/cross_cluster_replication/index.js +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/index.js @@ -7,5 +7,6 @@ export default function ({ loadTestFile }) { describe('cross cluster replication', () => { loadTestFile(require.resolve('./auto_follow_pattern')); + loadTestFile(require.resolve('./follower_indices')); }); } diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/clusters.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/clusters.js new file mode 100644 index 00000000000000..2a52064088c71c --- /dev/null +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/clusters.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getRandomString } from './random'; +import { REMOTE_CLUSTERS_API_BASE_PATH } from '../constants'; + +const CLUSTER_NAME = `test-${getRandomString()}`; + +/** + * Helpers for the CCR application to easily create and delete + * Remote clusters for the tests. + * @param {Supertest} supertest The supertest instance + */ +export const initClusterHelpers = (supertest) => { + let clusters = []; + + const addCluster = (name = CLUSTER_NAME) => { + clusters.push(name); + return ( + supertest + .post(`${REMOTE_CLUSTERS_API_BASE_PATH}`) + .set('kbn-xsrf', 'xxx') + .send({ + "name": name, + "seeds": [ + "localhost:9300" + ], + "skipUnavailable": true, + }) + ); + }; + + const deleteCluster = (name = CLUSTER_NAME) => { + clusters = clusters.filter(c => c !== name); + return ( + supertest + .delete(`${REMOTE_CLUSTERS_API_BASE_PATH}/${name}`) + .set('kbn-xsrf', 'xxx') + ); + }; + + const deleteAllClusters = () => ( + Promise.all(clusters.map(deleteCluster)).then(() => { + clusters = []; + }) + ); + + return ({ + CLUSTER_NAME, + addCluster, + deleteCluster, + deleteAllClusters, + }); +}; diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/es_index.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/es_index.js new file mode 100644 index 00000000000000..7ff1737da02990 --- /dev/null +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/es_index.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getRandomString } from './random'; + +/** + * Helpers to create and delete indices on the Elasticsearch instance + * during our tests. + * @param {ElasticsearchClient} es The Elasticsearch client instance + */ +export const initElasticsearchIndicesHelpers = (es) => { + let indicesCreated = []; + + const createIndex = (index = getRandomString()) => { + indicesCreated.push(index); + return es.indices.create({ index }).then(() => index); + }; + + const deleteIndex = (index) => { + indicesCreated = indicesCreated.filter(i => i !== index); + return es.indices.delete({ index }); + }; + + const deleteAllIndices = () => ( + Promise.all(indicesCreated.map(deleteIndex)).then(() => indicesCreated = []) + ); + + return ({ + createIndex, + deleteIndex, + deleteAllIndices, + }); +}; diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/index.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/index.js new file mode 100644 index 00000000000000..e5036f7d652a83 --- /dev/null +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/index.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + initClusterHelpers +} from './clusters'; + +export { + initElasticsearchIndicesHelpers +} from './es_index'; + +export { + getRandomString, +} from './random'; diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/random.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/random.js new file mode 100644 index 00000000000000..28fc55986afb45 --- /dev/null +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/lib/random.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Chance from 'chance'; + +const chance = new Chance(); +const CHARS_POOL = 'abcdefghijklmnopqrstuvwxyz'; + +export const getRandomString = () => chance.string({ pool: CHARS_POOL });