From 3d511522b23f13825ad8dc3f398153072063bfa2 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Tue, 10 Jan 2023 11:20:29 -0800 Subject: [PATCH] Merge 2.5 changes to 2.x (#537) (#557) * Feature/common 2.5 (#519) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Feature/index management 2.5 (#520) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * fix: detail of data stream Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Temp/template management 2.5 (#523) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: fix template error Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Temp/alias management 2.5 (#524) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Feature/unittest fix 2.5 (#525) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Merge/index operation reindex (#526) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * enable fullwidth for JSON editor (#479) * enable fullwidth for JSON editor Signed-off-by: Hailong Cui * update width of import settings & mappings Signed-off-by: Hailong Cui * wording change Signed-off-by: Hailong Cui Signed-off-by: Hailong Cui * advanced settings Signed-off-by: Hailong Cui * fix integration test Signed-off-by: Hailong Cui * wording change Signed-off-by: Hailong Cui * filter system index and alias from destination Signed-off-by: Hailong Cui * fix code merge issue Signed-off-by: Hailong Cui Signed-off-by: suzhou Signed-off-by: Hailong Cui Co-authored-by: suzhou * Add shrink index page (#530) * Add shrink index page Signed-off-by: Binlong Gao * Fix bug for shrink & close operation Signed-off-by: Binlong Gao Signed-off-by: Binlong Gao * feat: update Signed-off-by: suzhou * feat: update wording Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: change flyout to modal Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: enable global template warning Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * Merge/index operation (#534) * Feature/common 2.5 (#519) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Feature/index management 2.5 (#520) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * fix: detail of data stream Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Temp/template management 2.5 (#523) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: fix template error Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Temp/alias management 2.5 (#524) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Feature/unittest fix 2.5 (#525) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Merge/index operation reindex (#526) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * enable fullwidth for JSON editor (#479) * enable fullwidth for JSON editor Signed-off-by: Hailong Cui * update width of import settings & mappings Signed-off-by: Hailong Cui * wording change Signed-off-by: Hailong Cui Signed-off-by: Hailong Cui * advanced settings Signed-off-by: Hailong Cui * fix integration test Signed-off-by: Hailong Cui * wording change Signed-off-by: Hailong Cui * filter system index and alias from destination Signed-off-by: Hailong Cui * fix code merge issue Signed-off-by: Hailong Cui Signed-off-by: suzhou Signed-off-by: Hailong Cui Co-authored-by: suzhou * Add split index operation Signed-off-by: Xuesong Luo * Add split index operation Signed-off-by: Xuesong Luo Signed-off-by: suzhou Signed-off-by: Hailong Cui Signed-off-by: Xuesong Luo Co-authored-by: suzhou Co-authored-by: Hailong-amzn * Merge/index operation (#535) * Feature/common 2.5 (#519) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Feature/index management 2.5 (#520) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * fix: detail of data stream Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Temp/template management 2.5 (#523) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: fix template error Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Temp/alias management 2.5 (#524) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Feature/unittest fix 2.5 (#525) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * Merge/index operation reindex (#526) * Feature/common 2.5 (#506) * feat: split to common change Signed-off-by: suzhou * feat: update Signed-off-by: suzhou Signed-off-by: suzhou * enable fullwidth for JSON editor (#479) * enable fullwidth for JSON editor Signed-off-by: Hailong Cui * update width of import settings & mappings Signed-off-by: Hailong Cui * wording change Signed-off-by: Hailong Cui Signed-off-by: Hailong Cui * advanced settings Signed-off-by: Hailong Cui * fix integration test Signed-off-by: Hailong Cui * wording change Signed-off-by: Hailong Cui * filter system index and alias from destination Signed-off-by: Hailong Cui * fix code merge issue Signed-off-by: Hailong Cui Signed-off-by: suzhou Signed-off-by: Hailong Cui Co-authored-by: suzhou * Add split index operation Signed-off-by: Xuesong Luo * Add split index operation Signed-off-by: Xuesong Luo * Return if exception is thrown when calling backend API Signed-off-by: Xuesong Luo * Remove replica props that's not taking effect. Signed-off-by: Xuesong Luo Signed-off-by: suzhou Signed-off-by: Hailong Cui Signed-off-by: Xuesong Luo Co-authored-by: suzhou Co-authored-by: Hailong-amzn * feat: rearrange main.tsx Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: add difference Signed-off-by: suzhou * feat: add timeout Signed-off-by: suzhou * feat: add timeout Signed-off-by: suzhou * feat: add timeout to 60000 for macos has the timeout issue Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: add unsaved check logic Signed-off-by: suzhou * feat: change timeout to 24h Signed-off-by: suzhou * feat: remove warning Signed-off-by: suzhou * feat: enable parse message for mapping_parsing_exception Signed-off-by: suzhou * feat: update wording Signed-off-by: suzhou * feat: rewording Signed-off-by: suzhou * feat: update snapshot Signed-off-by: suzhou * feat: optimize with new type of parsing error Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update alias empty content Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update alias modal header style Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: update e2e test Signed-off-by: suzhou * Minor wording change after review (#544) Signed-off-by: Xuesong Luo Signed-off-by: Xuesong Luo * feat: use correct version of doc Signed-off-by: suzhou * feat: update typo Signed-off-by: suzhou * feat: update snapshot Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * feat: add destType for reindex notification toast Signed-off-by: suzhou * support reindex unique documents (#546) Signed-off-by: Hailong Cui Signed-off-by: Hailong Cui * feat: update patterns for template name Signed-off-by: suzhou * feat: update validation Signed-off-by: suzhou * feat: add validation for alias select Signed-off-by: suzhou * feat: update test Signed-off-by: suzhou * feat: add force in E2E test Signed-off-by: suzhou * toast link support data stream and alias (#551) Signed-off-by: Hailong Cui Signed-off-by: Hailong Cui * fix: index detail for data streams index Signed-off-by: suzhou * feat: add error wrapper Signed-off-by: suzhou * feat: change to multiple select Signed-off-by: suzhou * feat: expand width Signed-off-by: suzhou * feat: update Signed-off-by: suzhou * Change some wording in shrink index page (#553) Signed-off-by: gaobinlong Signed-off-by: gaobinlong * reindex page wording change (#554) Signed-off-by: Hailong Cui Signed-off-by: Hailong Cui * feat: add comment on Better* component Signed-off-by: suzhou * refractor: job handler Signed-off-by: suzhou * feat: remove useless functions Signed-off-by: suzhou * refractor: move functions to helpers.ts Signed-off-by: suzhou * refractor: move types to interfaces.ts Signed-off-by: suzhou * feat: remove duplicate div Signed-off-by: suzhou * feat: use fragment Signed-off-by: suzhou * feat: use cannot Signed-off-by: suzhou * refractor: split TemplateDetail into multiple files Signed-off-by: suzhou * feat: update timeout Signed-off-by: suzhou * feat: move some constant lines to constant.tsx Signed-off-by: suzhou * Remove unused TODO (#555) Signed-off-by: Hailong Cui Signed-off-by: Hailong Cui * fix: catch error and do not jump in split page Signed-off-by: suzhou * Add a space before the learn more link in shrink index page (#556) Signed-off-by: gaobinlong Signed-off-by: suzhou Signed-off-by: Hailong Cui Signed-off-by: Binlong Gao Signed-off-by: Xuesong Luo Signed-off-by: gaobinlong Co-authored-by: Hailong-amzn Co-authored-by: Binlong Gao Co-authored-by: xluo-aws (cherry picked from commit f860818a1382bdcd56af2db2686eb43a1daaf2f6) Co-authored-by: suzhou --- cypress/integration/aliases.js | 119 ++ cypress/integration/create_index.js | 257 ++++ cypress/integration/indices_spec.js | 298 ++++- cypress/integration/reindex_spec.js | 225 ++++ cypress/integration/split_index.js | 184 +++ cypress/integration/templates.js | 91 ++ cypress/support/commands.js | 54 +- cypress/support/index.d.ts | 16 + models/interfaces.ts | 92 +- opensearch_dashboards.json | 2 +- package.json | 6 +- public/JobHandler/callbacks/reindex.tsx | 122 ++ public/JobHandler/callbacks/shrink.tsx | 66 + public/JobHandler/callbacks/split.tsx | 65 + public/JobHandler/components/DetailLink.tsx | 7 + public/JobHandler/index.ts | 27 + public/JobHandler/interface.ts | 9 + public/JobHandler/utils.ts | 20 + .../AdvancedSettings.test.tsx | 40 + .../AdvancedSettings.test.tsx.snap | 177 +++ public/components/AdvancedSettings/index.scss | 5 + public/components/AdvancedSettings/index.tsx | 91 ++ .../ChannelNotification.test.tsx | 77 ++ .../ChannelNotification.tsx | 87 ++ .../ChannelNotification.test.tsx.snap | 108 ++ .../components/ChannelNotification/index.ts | 8 + .../ComboBoxWithoutWarning/index.tsx | 20 + .../components/ContentPanel/ContentPanel.tsx | 2 +- .../ContentPanel/ContentPanelActions.tsx | 7 +- .../CustomFormRow/CustomFormRow.test.tsx | 19 + .../__snapshots__/CustomFormRow.test.tsx.snap | 26 + public/components/CustomFormRow/index.tsx | 36 + .../DeleteModal/DeleteModal.test.tsx | 17 + public/components/DeleteModal/DeleteModal.tsx | 80 ++ .../__snapshots__/DeleteModal.test.tsx.snap | 147 +++ public/components/DeleteModal/index.ts | 8 + .../DescriptionListHoz.test.tsx | 16 + .../DescriptionListHoz/DescriptionListHoz.tsx | 34 + .../DescriptionListHoz.test.tsx.snap | 28 + public/components/DescriptionListHoz/index.ts | 3 + .../EuiToolTipWrapper.test.tsx | 32 + .../EuiToolTipWrapper.test.tsx.snap | 13 + public/components/EuiToolTipWrapper/index.tsx | 79 ++ .../FormGenerator/FormGenerator.test.tsx | 304 +++++ .../__snapshots__/FormGenerator.test.tsx.snap | 141 +++ .../built_in_components/index.tsx | 70 ++ public/components/FormGenerator/index.tsx | 185 +++ .../JSONDiffEditor/JSONDiffEditor.scss | 3 + .../JSONDiffEditor/JSONDiffEditor.test.tsx | 16 + .../JSONDiffEditor/JSONDiffEditor.tsx | 151 +++ .../JSONDiffEditor/JSONTextArea.tsx | 89 ++ .../JSONDiffEditor.test.tsx.snap | 7 + public/components/JSONDiffEditor/index.tsx | 28 + public/components/JSONDiffEditor/interface.ts | 9 + .../components/JSONEditor/JSONEditor.test.tsx | 106 ++ public/components/JSONEditor/JSONEditor.tsx | 90 ++ .../__snapshots__/JSONEditor.test.tsx.snap | 125 ++ public/components/JSONEditor/index.ts | 9 + .../LegacyNotification.test.tsx | 19 + .../LegacyNotification/LegacyNotification.tsx | 50 + .../LegacyNotification.test.tsx.snap | 43 + public/components/LegacyNotification/index.ts | 8 + public/components/Modal/Modal.tsx | 155 ++- public/components/Modal/index.ts | 4 +- .../RemoteSelect/RemoteSelect.test.tsx | 70 ++ .../__snapshots__/RemoteSelect.test.tsx.snap | 109 ++ public/components/RemoteSelect/index.tsx | 99 ++ .../SimplePopover/SimplePopover.test.tsx | 53 + .../SimplePopover/SimplePopover.tsx | 104 ++ .../__snapshots__/SimplePopover.test.tsx.snap | 19 + public/components/SimplePopover/index.ts | 8 + .../SwitchableEditor/SwitchableEditor.scss | 6 + .../SwitchableEditor.test.tsx | 35 + .../SwitchableEditor/SwitchableEditor.tsx | 50 + .../SwitchableEditor.test.tsx.snap | 279 +++++ public/components/SwitchableEditor/index.tsx | 8 + public/components/Toast/Toast.test.tsx | 30 + .../Toast/__snapshots__/Toast.test.tsx.snap | 47 + public/components/Toast/index.tsx | 65 + .../ToolTipWithoutWarning/index.tsx | 24 + .../UnsavedChangesBottomBar.test.tsx | 15 + .../UnsavedChangesBottomBar.tsx | 71 ++ .../UnsavedChangesBottomBar.test.tsx.snap | 74 ++ .../UnsavedChangesBottomBar/index.ts | 3 + .../ErrorNotification.test.tsx | 57 + .../ErrorNotification/ErrorNotification.tsx | 161 +++ .../ErrorNotification.test.tsx.snap | 218 ++++ public/containers/ErrorNotification/index.ts | 8 + .../IndexDetail/IndexDetail.test.tsx | 95 ++ .../__snapshots__/IndexDetail.test.tsx.snap | 38 + public/containers/IndexDetail/index.tsx | 70 ++ public/context/JobSchedulerContext.tsx | 12 + public/index_management_app.tsx | 3 + public/lib/JobScheduler/JobScheduler.test.ts | 120 ++ public/lib/JobScheduler/JobScheduler.ts | 157 +++ public/lib/JobScheduler/index.ts | 2 + public/lib/JobScheduler/interface.ts | 33 + public/lib/JobScheduler/store-localstorage.ts | 44 + public/lib/field/index.tsx | 186 +++ public/lib/field/interfaces.ts | 202 ++++ public/lib/field/messages.ts | 30 + public/lib/field/rules/index.ts | 90 ++ public/lib/field/util.ts | 34 + public/models/interfaces.ts | 22 +- .../IndexControls/IndexControls.test.tsx | 41 + .../IndexControls/IndexControls.tsx | 49 + .../__snapshots__/IndexControls.test.tsx.snap | 130 ++ .../Aliases/components/IndexControls/index.ts | 9 + .../AliasActions/AliasActions.test.tsx | 119 ++ .../__snapshots__/AliasActions.test.tsx.snap | 55 + .../Aliases/containers/AliasActions/index.tsx | 76 ++ .../containers/Aliases/Aliases.test.tsx | 239 ++++ .../Aliases/containers/Aliases/Aliases.tsx | 451 +++++++ .../__snapshots__/Aliases.test.tsx.snap | 433 +++++++ .../pages/Aliases/containers/Aliases/index.ts | 8 + .../CreateAlias/CreateAlias.test.tsx | 29 + .../__snapshots__/CreateAlias.test.tsx.snap | 240 ++++ .../Aliases/containers/CreateAlias/index.tsx | 237 ++++ .../DeleteAliasModal.test.tsx | 17 + .../DeleteAliasModal/DeleteAliasModal.tsx | 110 ++ .../DeleteAliasModal.test.tsx.snap | 142 +++ .../containers/DeleteAliasModal/index.ts | 8 + public/pages/Aliases/index.ts | 8 + public/pages/Aliases/interface.ts | 9 + public/pages/Aliases/utils/constants.tsx | 17 + .../AliasSelect/AliasSelect.test.tsx | 117 ++ .../__snapshots__/AliasSelect.test.tsx.snap | 115 ++ .../components/AliasSelect/index.tsx | 68 ++ .../IndexDetail/IndexDetail.test.tsx | 139 +++ .../components/IndexDetail/IndexDetail.tsx | 622 ++++++++++ .../__snapshots__/IndexDetail.test.tsx.snap | 177 +++ .../components/IndexDetail/index.ts | 9 + .../components/IndexMapping/IndexMapping.scss | 19 + .../IndexMapping/IndexMapping.test.tsx | 167 +++ .../components/IndexMapping/IndexMapping.tsx | 296 +++++ .../__snapshots__/IndexMapping.test.tsx.snap | 794 ++++++++++++ .../components/IndexMapping/helper.ts | 42 + .../components/IndexMapping/index.ts | 9 + .../components/IndexMapping/interfaces.ts | 32 + .../MappingLabel/MappingLabel.test.tsx | 39 + .../components/MappingLabel/MappingLabel.tsx | 268 ++++ .../__snapshots__/MappingLabel.test.tsx.snap | 240 ++++ .../components/MappingLabel/index.ts | 4 + .../CreateIndex/CreateIndex.test.tsx | 95 ++ .../containers/CreateIndex/CreateIndex.tsx | 61 + .../containers/CreateIndex/index.ts | 8 + .../containers/IndexForm/IndexForm.test.tsx | 289 +++++ .../containers/IndexForm/index.tsx | 456 +++++++ public/pages/CreateIndex/index.ts | 8 + .../DefineTemplate/DefineTemplate.tsx | 152 +++ .../components/DefineTemplate/index.ts | 3 + .../IndexSettings/IndexSettings.tsx | 150 +++ .../components/IndexSettings/index.ts | 3 + .../components/TemplateType/TemplateType.tsx | 31 + .../components/TemplateType/index.ts | 4 + .../CreateIndexTemplate.test.tsx | 129 ++ .../CreateIndexTemplate.tsx | 73 ++ .../CreateIndexTemplate.test.tsx.snap | 720 +++++++++++ .../containers/CreateIndexTemplate/index.ts | 8 + .../containers/IndexAlias/IndexAlias.tsx | 79 ++ .../containers/IndexAlias/index.ts | 3 + .../TemplateDetail/TemplateDetail.test.tsx | 109 ++ .../TemplateDetail/TemplateDetail.tsx | 210 ++++ .../TemplateDetail.test.tsx.snap | 1076 +++++++++++++++++ .../containers/TemplateDetail/hooks.tsx | 55 + .../containers/TemplateDetail/index.ts | 9 + .../TemplateMappings/TemplateMappings.tsx | 69 ++ .../containers/TemplateMappings/index.ts | 3 + public/pages/CreateIndexTemplate/hooks.tsx | 10 + public/pages/CreateIndexTemplate/index.ts | 8 + public/pages/CreateIndexTemplate/interface.ts | 15 + .../components/DefinePolicy/DefinePolicy.tsx | 2 +- .../containers/CreatePolicy/CreatePolicy.tsx | 10 + .../AdvancedAggregation.tsx | 2 +- .../MetricsCalculation/MetricsCalculation.tsx | 18 +- .../RollupIndices/RollupIndices.tsx | 20 +- .../TimeAggregations/TimeAggregation.tsx | 2 +- .../CreateRollupForm/CreateRollupForm.tsx | 10 +- .../CreateTransformSteps.tsx | 8 +- .../TransformIndices/TransformIndices.tsx | 14 +- .../CreateTransformForm.tsx | 9 + .../IndexDetail/IndexDetail.test.tsx | 83 ++ .../containers/IndexDetail/IndexDetail.tsx | 301 +++++ .../__snapshots__/IndexDetail.test.tsx.snap | 3 + .../containers/IndexDetail/constants.tsx | 92 ++ .../containers/IndexDetail/index.ts | 3 + .../containers/IndexDetail/interface.ts | 4 + public/pages/IndexDetail/index.ts | 8 + .../CloseIndexModal/CloseIndexModal.test.tsx | 41 + .../CloseIndexModal/CloseIndexModal.tsx | 82 ++ .../CloseIndexModal.test.tsx.snap | 159 +++ .../components/CloseIndexModal/index.ts | 8 + .../DeleteIndexModal.test.tsx | 28 + .../DeleteIndexModal/DeleteIndexModal.tsx | 80 ++ .../DeleteIndexModal.test.tsx.snap | 164 +++ .../components/DeleteIndexModal/index.ts | 8 + .../OpenIndexModal/OpenIndexModal.test.tsx | 24 + .../OpenIndexModal/OpenIndexModal.tsx | 59 + .../OpenIndexModal.test.tsx.snap | 112 ++ .../components/OpenIndexModal/index.ts | 8 + .../IndexDetail/IndexDetail.test.tsx | 47 + .../__snapshots__/IndexDetail.test.tsx.snap | 21 + .../Indices/containers/IndexDetail/index.tsx | 29 + .../containers/Indices/Indices.test.tsx | 10 +- .../Indices/containers/Indices/Indices.tsx | 80 +- .../__snapshots__/Indices.test.tsx.snap | 51 +- .../Indices/containers/Indices/index.scss | 9 + .../IndicesActions/IndicesActions.test.tsx | 499 ++++++++ .../IndicesActions.test.tsx.snap | 271 +++++ .../containers/IndicesActions/index.tsx | 242 ++++ public/pages/Indices/utils/constants.tsx | 250 ++-- public/pages/Indices/utils/helpers.ts | 189 +++ public/pages/Main/Main.tsx | 133 +- .../CreateIndexFlyout.test.tsx | 33 + .../CreateIndexFlyout/CreateIndexFlyout.tsx | 48 + .../CreateIndexFlyout.test.tsx.snap | 921 ++++++++++++++ .../components/CreateIndexFlyout/index.ts | 8 + .../IndexSelect/IndexSelect.test.tsx | 64 + .../components/IndexSelect/IndexSelect.tsx | 73 ++ .../__snapshots__/IndexSelect.test.tsx.snap | 190 +++ .../Reindex/components/IndexSelect/index.ts | 8 + .../ReindexAdvancedOptions.test.tsx | 101 ++ .../ReindexAdvancedOptions.tsx | 192 +++ .../ReindexAdvancedOptions.test.tsx.snap | 760 ++++++++++++ .../ReindexAdvancedOptions/index.ts | 8 + .../container/Reindex/Reindex.test.tsx | 627 ++++++++++ .../Reindex/container/Reindex/Reindex.tsx | 678 +++++++++++ .../__snapshots__/Reindex.test.tsx.snap | 452 +++++++ .../pages/Reindex/container/Reindex/index.ts | 7 + public/pages/Reindex/index.ts | 8 + public/pages/Reindex/models/interfaces.ts | 41 + public/pages/Reindex/utils/constants.ts | 17 + public/pages/Reindex/utils/helper.test.ts | 102 ++ public/pages/Reindex/utils/helper.ts | 69 ++ .../Rollups/containers/Rollups/Rollups.tsx | 2 +- .../ShrinkIndex/ShrinkIndex.test.tsx | 615 ++++++++++ .../container/ShrinkIndex/ShrinkIndex.tsx | 663 ++++++++++ .../__snapshots__/ShrinkIndex.test.tsx.snap | 704 +++++++++++ .../container/ShrinkIndex/index.ts | 7 + public/pages/ShrinkIndex/index.ts | 8 + public/pages/ShrinkIndex/utils/constants.ts | 13 + .../SplitIndexForm/SplitIndexForm.tsx | 230 ++++ .../components/SplitIndexForm/index.ts | 8 + .../container/SplitIndex/SplitIndex.test.tsx | 534 ++++++++ .../container/SplitIndex/SplitIndex.tsx | 251 ++++ .../__snapshots__/SplitIndex.test.tsx.snap | 51 + .../SplitIndex/container/SplitIndex/index.ts | 7 + public/pages/SplitIndex/index.ts | 8 + .../IndexControls/IndexControls.test.tsx | 32 + .../IndexControls/IndexControls.tsx | 36 + .../__snapshots__/IndexControls.test.tsx.snap | 47 + .../components/IndexControls/index.ts | 9 + .../DeleteTemplateModal.test.tsx | 17 + .../DeleteTemplateModal.tsx | 52 + .../DeleteTemplateModal.test.tsx.snap | 143 +++ .../containers/DeleteTemplatesModal/index.ts | 8 + .../containers/Templates/Templates.test.tsx | 96 ++ .../containers/Templates/Templates.tsx | 388 ++++++ .../__snapshots__/Templates.test.tsx.snap | 423 +++++++ .../Templates/containers/Templates/index.ts | 3 + .../TemplatesActions.test.tsx | 142 +++ .../TemplatesActions.test.tsx.snap | 55 + .../containers/TemplatesActions/index.tsx | 77 ++ public/pages/Templates/index.ts | 8 + public/pages/Templates/interface.ts | 10 + public/pages/Templates/utils/constants.tsx | 16 + .../Transforms/TransformDetails.test.tsx | 2 +- .../components/FlyoutFooter/FlyoutFooter.tsx | 5 +- .../components/States/State.tsx | 8 +- .../components/States/States.tsx | 2 +- .../ErrorNotification.test.tsx | 17 +- .../ErrorNotification/ErrorNotification.tsx | 2 +- public/plugin.ts | 2 + public/services/CommonService.test.ts | 24 + public/services/CommonService.ts | 28 + public/services/IndexService.test.ts | 8 + public/services/IndexService.ts | 16 +- public/services/SnapshotManagementService.ts | 14 +- public/services/index.ts | 2 + public/utils/constants.ts | 229 +++- public/utils/helpers.test.ts | 41 + public/utils/helpers.ts | 28 + server/models/interfaces.ts | 32 +- server/models/types.ts | 2 +- server/plugin.ts | 23 +- server/routes/aliases.ts | 25 + server/routes/common.ts | 27 + server/routes/index.ts | 4 +- server/routes/indices.ts | 1 + server/services/AliasServices.ts | 91 ++ server/services/CommonService.ts | 58 + server/services/IndexService.ts | 91 +- server/services/index.ts | 4 + test/jest.config.js | 4 + test/mocks/browserServicesMock.ts | 6 + test/mocks/coreServicesMock.ts | 23 +- test/mocks/httpClientMock.ts | 1 + test/mocks/index.ts | 116 +- test/setup.jest.ts | 2 +- utils/constants.ts | 28 + utils/helper.ts | 15 + yarn.lock | 538 +++++---- 302 files changed, 29909 insertions(+), 476 deletions(-) create mode 100644 cypress/integration/aliases.js create mode 100644 cypress/integration/create_index.js create mode 100644 cypress/integration/reindex_spec.js create mode 100644 cypress/integration/split_index.js create mode 100644 cypress/integration/templates.js create mode 100644 public/JobHandler/callbacks/reindex.tsx create mode 100644 public/JobHandler/callbacks/shrink.tsx create mode 100644 public/JobHandler/callbacks/split.tsx create mode 100644 public/JobHandler/components/DetailLink.tsx create mode 100644 public/JobHandler/index.ts create mode 100644 public/JobHandler/interface.ts create mode 100644 public/JobHandler/utils.ts create mode 100644 public/components/AdvancedSettings/AdvancedSettings.test.tsx create mode 100644 public/components/AdvancedSettings/__snapshots__/AdvancedSettings.test.tsx.snap create mode 100644 public/components/AdvancedSettings/index.scss create mode 100644 public/components/AdvancedSettings/index.tsx create mode 100644 public/components/ChannelNotification/ChannelNotification.test.tsx create mode 100644 public/components/ChannelNotification/ChannelNotification.tsx create mode 100644 public/components/ChannelNotification/__snapshots__/ChannelNotification.test.tsx.snap create mode 100644 public/components/ChannelNotification/index.ts create mode 100644 public/components/ComboBoxWithoutWarning/index.tsx create mode 100644 public/components/CustomFormRow/CustomFormRow.test.tsx create mode 100644 public/components/CustomFormRow/__snapshots__/CustomFormRow.test.tsx.snap create mode 100644 public/components/CustomFormRow/index.tsx create mode 100644 public/components/DeleteModal/DeleteModal.test.tsx create mode 100644 public/components/DeleteModal/DeleteModal.tsx create mode 100644 public/components/DeleteModal/__snapshots__/DeleteModal.test.tsx.snap create mode 100644 public/components/DeleteModal/index.ts create mode 100644 public/components/DescriptionListHoz/DescriptionListHoz.test.tsx create mode 100644 public/components/DescriptionListHoz/DescriptionListHoz.tsx create mode 100644 public/components/DescriptionListHoz/__snapshots__/DescriptionListHoz.test.tsx.snap create mode 100644 public/components/DescriptionListHoz/index.ts create mode 100644 public/components/EuiToolTipWrapper/EuiToolTipWrapper.test.tsx create mode 100644 public/components/EuiToolTipWrapper/__snapshots__/EuiToolTipWrapper.test.tsx.snap create mode 100644 public/components/EuiToolTipWrapper/index.tsx create mode 100644 public/components/FormGenerator/FormGenerator.test.tsx create mode 100644 public/components/FormGenerator/__snapshots__/FormGenerator.test.tsx.snap create mode 100644 public/components/FormGenerator/built_in_components/index.tsx create mode 100644 public/components/FormGenerator/index.tsx create mode 100644 public/components/JSONDiffEditor/JSONDiffEditor.scss create mode 100644 public/components/JSONDiffEditor/JSONDiffEditor.test.tsx create mode 100644 public/components/JSONDiffEditor/JSONDiffEditor.tsx create mode 100644 public/components/JSONDiffEditor/JSONTextArea.tsx create mode 100644 public/components/JSONDiffEditor/__snapshots__/JSONDiffEditor.test.tsx.snap create mode 100644 public/components/JSONDiffEditor/index.tsx create mode 100644 public/components/JSONDiffEditor/interface.ts create mode 100644 public/components/JSONEditor/JSONEditor.test.tsx create mode 100644 public/components/JSONEditor/JSONEditor.tsx create mode 100644 public/components/JSONEditor/__snapshots__/JSONEditor.test.tsx.snap create mode 100644 public/components/JSONEditor/index.ts create mode 100644 public/components/LegacyNotification/LegacyNotification.test.tsx create mode 100644 public/components/LegacyNotification/LegacyNotification.tsx create mode 100644 public/components/LegacyNotification/__snapshots__/LegacyNotification.test.tsx.snap create mode 100644 public/components/LegacyNotification/index.ts create mode 100644 public/components/RemoteSelect/RemoteSelect.test.tsx create mode 100644 public/components/RemoteSelect/__snapshots__/RemoteSelect.test.tsx.snap create mode 100644 public/components/RemoteSelect/index.tsx create mode 100644 public/components/SimplePopover/SimplePopover.test.tsx create mode 100644 public/components/SimplePopover/SimplePopover.tsx create mode 100644 public/components/SimplePopover/__snapshots__/SimplePopover.test.tsx.snap create mode 100644 public/components/SimplePopover/index.ts create mode 100644 public/components/SwitchableEditor/SwitchableEditor.scss create mode 100644 public/components/SwitchableEditor/SwitchableEditor.test.tsx create mode 100644 public/components/SwitchableEditor/SwitchableEditor.tsx create mode 100644 public/components/SwitchableEditor/__snapshots__/SwitchableEditor.test.tsx.snap create mode 100644 public/components/SwitchableEditor/index.tsx create mode 100644 public/components/Toast/Toast.test.tsx create mode 100644 public/components/Toast/__snapshots__/Toast.test.tsx.snap create mode 100644 public/components/Toast/index.tsx create mode 100644 public/components/ToolTipWithoutWarning/index.tsx create mode 100644 public/components/UnsavedChangesBottomBar/UnsavedChangesBottomBar.test.tsx create mode 100644 public/components/UnsavedChangesBottomBar/UnsavedChangesBottomBar.tsx create mode 100644 public/components/UnsavedChangesBottomBar/__snapshots__/UnsavedChangesBottomBar.test.tsx.snap create mode 100644 public/components/UnsavedChangesBottomBar/index.ts create mode 100644 public/containers/ErrorNotification/ErrorNotification.test.tsx create mode 100644 public/containers/ErrorNotification/ErrorNotification.tsx create mode 100644 public/containers/ErrorNotification/__snapshots__/ErrorNotification.test.tsx.snap create mode 100644 public/containers/ErrorNotification/index.ts create mode 100644 public/containers/IndexDetail/IndexDetail.test.tsx create mode 100644 public/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap create mode 100644 public/containers/IndexDetail/index.tsx create mode 100644 public/context/JobSchedulerContext.tsx create mode 100644 public/lib/JobScheduler/JobScheduler.test.ts create mode 100644 public/lib/JobScheduler/JobScheduler.ts create mode 100644 public/lib/JobScheduler/index.ts create mode 100644 public/lib/JobScheduler/interface.ts create mode 100644 public/lib/JobScheduler/store-localstorage.ts create mode 100644 public/lib/field/index.tsx create mode 100644 public/lib/field/interfaces.ts create mode 100644 public/lib/field/messages.ts create mode 100644 public/lib/field/rules/index.ts create mode 100644 public/lib/field/util.ts create mode 100644 public/pages/Aliases/components/IndexControls/IndexControls.test.tsx create mode 100644 public/pages/Aliases/components/IndexControls/IndexControls.tsx create mode 100644 public/pages/Aliases/components/IndexControls/__snapshots__/IndexControls.test.tsx.snap create mode 100644 public/pages/Aliases/components/IndexControls/index.ts create mode 100644 public/pages/Aliases/containers/AliasActions/AliasActions.test.tsx create mode 100644 public/pages/Aliases/containers/AliasActions/__snapshots__/AliasActions.test.tsx.snap create mode 100644 public/pages/Aliases/containers/AliasActions/index.tsx create mode 100644 public/pages/Aliases/containers/Aliases/Aliases.test.tsx create mode 100644 public/pages/Aliases/containers/Aliases/Aliases.tsx create mode 100644 public/pages/Aliases/containers/Aliases/__snapshots__/Aliases.test.tsx.snap create mode 100644 public/pages/Aliases/containers/Aliases/index.ts create mode 100644 public/pages/Aliases/containers/CreateAlias/CreateAlias.test.tsx create mode 100644 public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap create mode 100644 public/pages/Aliases/containers/CreateAlias/index.tsx create mode 100644 public/pages/Aliases/containers/DeleteAliasModal/DeleteAliasModal.test.tsx create mode 100644 public/pages/Aliases/containers/DeleteAliasModal/DeleteAliasModal.tsx create mode 100644 public/pages/Aliases/containers/DeleteAliasModal/__snapshots__/DeleteAliasModal.test.tsx.snap create mode 100644 public/pages/Aliases/containers/DeleteAliasModal/index.ts create mode 100644 public/pages/Aliases/index.ts create mode 100644 public/pages/Aliases/interface.ts create mode 100644 public/pages/Aliases/utils/constants.tsx create mode 100644 public/pages/CreateIndex/components/AliasSelect/AliasSelect.test.tsx create mode 100644 public/pages/CreateIndex/components/AliasSelect/__snapshots__/AliasSelect.test.tsx.snap create mode 100644 public/pages/CreateIndex/components/AliasSelect/index.tsx create mode 100644 public/pages/CreateIndex/components/IndexDetail/IndexDetail.test.tsx create mode 100644 public/pages/CreateIndex/components/IndexDetail/IndexDetail.tsx create mode 100644 public/pages/CreateIndex/components/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap create mode 100644 public/pages/CreateIndex/components/IndexDetail/index.ts create mode 100644 public/pages/CreateIndex/components/IndexMapping/IndexMapping.scss create mode 100644 public/pages/CreateIndex/components/IndexMapping/IndexMapping.test.tsx create mode 100644 public/pages/CreateIndex/components/IndexMapping/IndexMapping.tsx create mode 100644 public/pages/CreateIndex/components/IndexMapping/__snapshots__/IndexMapping.test.tsx.snap create mode 100644 public/pages/CreateIndex/components/IndexMapping/helper.ts create mode 100644 public/pages/CreateIndex/components/IndexMapping/index.ts create mode 100644 public/pages/CreateIndex/components/IndexMapping/interfaces.ts create mode 100644 public/pages/CreateIndex/components/MappingLabel/MappingLabel.test.tsx create mode 100644 public/pages/CreateIndex/components/MappingLabel/MappingLabel.tsx create mode 100644 public/pages/CreateIndex/components/MappingLabel/__snapshots__/MappingLabel.test.tsx.snap create mode 100644 public/pages/CreateIndex/components/MappingLabel/index.ts create mode 100644 public/pages/CreateIndex/containers/CreateIndex/CreateIndex.test.tsx create mode 100644 public/pages/CreateIndex/containers/CreateIndex/CreateIndex.tsx create mode 100644 public/pages/CreateIndex/containers/CreateIndex/index.ts create mode 100644 public/pages/CreateIndex/containers/IndexForm/IndexForm.test.tsx create mode 100644 public/pages/CreateIndex/containers/IndexForm/index.tsx create mode 100644 public/pages/CreateIndex/index.ts create mode 100644 public/pages/CreateIndexTemplate/components/DefineTemplate/DefineTemplate.tsx create mode 100644 public/pages/CreateIndexTemplate/components/DefineTemplate/index.ts create mode 100644 public/pages/CreateIndexTemplate/components/IndexSettings/IndexSettings.tsx create mode 100644 public/pages/CreateIndexTemplate/components/IndexSettings/index.ts create mode 100644 public/pages/CreateIndexTemplate/components/TemplateType/TemplateType.tsx create mode 100644 public/pages/CreateIndexTemplate/components/TemplateType/index.ts create mode 100644 public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/CreateIndexTemplate.test.tsx create mode 100644 public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/CreateIndexTemplate.tsx create mode 100644 public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/__snapshots__/CreateIndexTemplate.test.tsx.snap create mode 100644 public/pages/CreateIndexTemplate/containers/CreateIndexTemplate/index.ts create mode 100644 public/pages/CreateIndexTemplate/containers/IndexAlias/IndexAlias.tsx create mode 100644 public/pages/CreateIndexTemplate/containers/IndexAlias/index.ts create mode 100644 public/pages/CreateIndexTemplate/containers/TemplateDetail/TemplateDetail.test.tsx create mode 100644 public/pages/CreateIndexTemplate/containers/TemplateDetail/TemplateDetail.tsx create mode 100644 public/pages/CreateIndexTemplate/containers/TemplateDetail/__snapshots__/TemplateDetail.test.tsx.snap create mode 100644 public/pages/CreateIndexTemplate/containers/TemplateDetail/hooks.tsx create mode 100644 public/pages/CreateIndexTemplate/containers/TemplateDetail/index.ts create mode 100644 public/pages/CreateIndexTemplate/containers/TemplateMappings/TemplateMappings.tsx create mode 100644 public/pages/CreateIndexTemplate/containers/TemplateMappings/index.ts create mode 100644 public/pages/CreateIndexTemplate/hooks.tsx create mode 100644 public/pages/CreateIndexTemplate/index.ts create mode 100644 public/pages/CreateIndexTemplate/interface.ts create mode 100644 public/pages/IndexDetail/containers/IndexDetail/IndexDetail.test.tsx create mode 100644 public/pages/IndexDetail/containers/IndexDetail/IndexDetail.tsx create mode 100644 public/pages/IndexDetail/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap create mode 100644 public/pages/IndexDetail/containers/IndexDetail/constants.tsx create mode 100644 public/pages/IndexDetail/containers/IndexDetail/index.ts create mode 100644 public/pages/IndexDetail/containers/IndexDetail/interface.ts create mode 100644 public/pages/IndexDetail/index.ts create mode 100644 public/pages/Indices/components/CloseIndexModal/CloseIndexModal.test.tsx create mode 100644 public/pages/Indices/components/CloseIndexModal/CloseIndexModal.tsx create mode 100644 public/pages/Indices/components/CloseIndexModal/__snapshots__/CloseIndexModal.test.tsx.snap create mode 100644 public/pages/Indices/components/CloseIndexModal/index.ts create mode 100644 public/pages/Indices/components/DeleteIndexModal/DeleteIndexModal.test.tsx create mode 100644 public/pages/Indices/components/DeleteIndexModal/DeleteIndexModal.tsx create mode 100644 public/pages/Indices/components/DeleteIndexModal/__snapshots__/DeleteIndexModal.test.tsx.snap create mode 100644 public/pages/Indices/components/DeleteIndexModal/index.ts create mode 100644 public/pages/Indices/components/OpenIndexModal/OpenIndexModal.test.tsx create mode 100644 public/pages/Indices/components/OpenIndexModal/OpenIndexModal.tsx create mode 100644 public/pages/Indices/components/OpenIndexModal/__snapshots__/OpenIndexModal.test.tsx.snap create mode 100644 public/pages/Indices/components/OpenIndexModal/index.ts create mode 100644 public/pages/Indices/containers/IndexDetail/IndexDetail.test.tsx create mode 100644 public/pages/Indices/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap create mode 100644 public/pages/Indices/containers/IndexDetail/index.tsx create mode 100644 public/pages/Indices/containers/Indices/index.scss create mode 100644 public/pages/Indices/containers/IndicesActions/IndicesActions.test.tsx create mode 100644 public/pages/Indices/containers/IndicesActions/__snapshots__/IndicesActions.test.tsx.snap create mode 100644 public/pages/Indices/containers/IndicesActions/index.tsx create mode 100644 public/pages/Reindex/components/CreateIndexFlyout/CreateIndexFlyout.test.tsx create mode 100644 public/pages/Reindex/components/CreateIndexFlyout/CreateIndexFlyout.tsx create mode 100644 public/pages/Reindex/components/CreateIndexFlyout/__snapshots__/CreateIndexFlyout.test.tsx.snap create mode 100644 public/pages/Reindex/components/CreateIndexFlyout/index.ts create mode 100644 public/pages/Reindex/components/IndexSelect/IndexSelect.test.tsx create mode 100644 public/pages/Reindex/components/IndexSelect/IndexSelect.tsx create mode 100644 public/pages/Reindex/components/IndexSelect/__snapshots__/IndexSelect.test.tsx.snap create mode 100644 public/pages/Reindex/components/IndexSelect/index.ts create mode 100644 public/pages/Reindex/components/ReindexAdvancedOptions/ReindexAdvancedOptions.test.tsx create mode 100644 public/pages/Reindex/components/ReindexAdvancedOptions/ReindexAdvancedOptions.tsx create mode 100644 public/pages/Reindex/components/ReindexAdvancedOptions/__snapshots__/ReindexAdvancedOptions.test.tsx.snap create mode 100644 public/pages/Reindex/components/ReindexAdvancedOptions/index.ts create mode 100644 public/pages/Reindex/container/Reindex/Reindex.test.tsx create mode 100644 public/pages/Reindex/container/Reindex/Reindex.tsx create mode 100644 public/pages/Reindex/container/Reindex/__snapshots__/Reindex.test.tsx.snap create mode 100644 public/pages/Reindex/container/Reindex/index.ts create mode 100644 public/pages/Reindex/index.ts create mode 100644 public/pages/Reindex/models/interfaces.ts create mode 100644 public/pages/Reindex/utils/constants.ts create mode 100644 public/pages/Reindex/utils/helper.test.ts create mode 100644 public/pages/Reindex/utils/helper.ts create mode 100644 public/pages/ShrinkIndex/container/ShrinkIndex/ShrinkIndex.test.tsx create mode 100644 public/pages/ShrinkIndex/container/ShrinkIndex/ShrinkIndex.tsx create mode 100644 public/pages/ShrinkIndex/container/ShrinkIndex/__snapshots__/ShrinkIndex.test.tsx.snap create mode 100644 public/pages/ShrinkIndex/container/ShrinkIndex/index.ts create mode 100644 public/pages/ShrinkIndex/index.ts create mode 100644 public/pages/ShrinkIndex/utils/constants.ts create mode 100644 public/pages/SplitIndex/components/SplitIndexForm/SplitIndexForm.tsx create mode 100644 public/pages/SplitIndex/components/SplitIndexForm/index.ts create mode 100644 public/pages/SplitIndex/container/SplitIndex/SplitIndex.test.tsx create mode 100644 public/pages/SplitIndex/container/SplitIndex/SplitIndex.tsx create mode 100644 public/pages/SplitIndex/container/SplitIndex/__snapshots__/SplitIndex.test.tsx.snap create mode 100644 public/pages/SplitIndex/container/SplitIndex/index.ts create mode 100644 public/pages/SplitIndex/index.ts create mode 100644 public/pages/Templates/components/IndexControls/IndexControls.test.tsx create mode 100644 public/pages/Templates/components/IndexControls/IndexControls.tsx create mode 100644 public/pages/Templates/components/IndexControls/__snapshots__/IndexControls.test.tsx.snap create mode 100644 public/pages/Templates/components/IndexControls/index.ts create mode 100644 public/pages/Templates/containers/DeleteTemplatesModal/DeleteTemplateModal.test.tsx create mode 100644 public/pages/Templates/containers/DeleteTemplatesModal/DeleteTemplateModal.tsx create mode 100644 public/pages/Templates/containers/DeleteTemplatesModal/__snapshots__/DeleteTemplateModal.test.tsx.snap create mode 100644 public/pages/Templates/containers/DeleteTemplatesModal/index.ts create mode 100644 public/pages/Templates/containers/Templates/Templates.test.tsx create mode 100644 public/pages/Templates/containers/Templates/Templates.tsx create mode 100644 public/pages/Templates/containers/Templates/__snapshots__/Templates.test.tsx.snap create mode 100644 public/pages/Templates/containers/Templates/index.ts create mode 100644 public/pages/Templates/containers/TemplatesActions/TemplatesActions.test.tsx create mode 100644 public/pages/Templates/containers/TemplatesActions/__snapshots__/TemplatesActions.test.tsx.snap create mode 100644 public/pages/Templates/containers/TemplatesActions/index.tsx create mode 100644 public/pages/Templates/index.ts create mode 100644 public/pages/Templates/interface.ts create mode 100644 public/pages/Templates/utils/constants.tsx create mode 100644 public/services/CommonService.test.ts create mode 100644 public/services/CommonService.ts create mode 100644 public/utils/helpers.test.ts create mode 100644 server/routes/aliases.ts create mode 100644 server/routes/common.ts create mode 100644 server/services/AliasServices.ts create mode 100644 server/services/CommonService.ts create mode 100644 utils/helper.ts diff --git a/cypress/integration/aliases.js b/cypress/integration/aliases.js new file mode 100644 index 000000000..5b9aa9d9b --- /dev/null +++ b/cypress/integration/aliases.js @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { PLUGIN_NAME } from "../support/constants"; + +const SAMPLE_INDEX_PREFIX = "index-for-alias-test"; +const SAMPLE_ALIAS_PREFIX = "alias-for-test"; +const CREATE_ALIAS = "create-alias"; +const EDIT_INDEX = "index-edit-index-for-alias-test"; + +describe("Aliases", () => { + before(() => { + // Set welcome screen tracking to false + localStorage.setItem("home:welcome:show", "false"); + cy.deleteAllIndices(); + for (let i = 0; i < 11; i++) { + cy.createIndex(`${SAMPLE_INDEX_PREFIX}-${i}`, null); + } + cy.createIndex(EDIT_INDEX, null); + for (let i = 0; i < 30; i++) { + cy.addAlias(`${SAMPLE_ALIAS_PREFIX}-${i}`, `${SAMPLE_INDEX_PREFIX}-${i % 11}`); + } + cy.removeAlias(`${SAMPLE_ALIAS_PREFIX}-0`); + cy.addAlias(`${SAMPLE_ALIAS_PREFIX}-0`, `${SAMPLE_INDEX_PREFIX}-*`); + }); + + beforeEach(() => { + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/aliases`); + + // Common text to wait for to confirm page loaded, give up to 60 seconds for initial load + cy.contains("Rows per page", { timeout: 60000 }); + }); + + describe("can be searched / sorted / paginated", () => { + it("successfully", () => { + cy.get('[data-test-subj="pagination-button-1"]').should("exist"); + cy.get('[placeholder="Search..."]').type("alias-for-test-0{enter}"); + cy.contains("alias-for-test-0"); + cy.get(".euiTableRow").should("have.length", 1); + cy.get('[data-test-subj="comboBoxSearchInput"]').type("closed{enter}"); + + cy.contains("There are no aliases matching your applied filters. Reset your filters to view your aliases."); + }); + }); + + describe("shows more modal", () => { + it("successfully", () => { + cy.get('[placeholder="Search..."]').type("alias-for-test-0{enter}"); + cy.contains("alias-for-test-0"); + cy.get(".euiTableRow").should("have.length", 1); + cy.get('.euiTableRowCell [data-test-subj="8 more"]') + .click() + .get('[data-test-subj="indices-table"] .euiTableRow') + .should("have.length", 10); + }); + }); + + describe("can create a alias with wildcard and specific name", () => { + it("successfully", () => { + cy.get('[data-test-subj="Create AliasButton"]').click(); + cy.get('[data-test-subj="form-name-alias"]').type(CREATE_ALIAS); + cy.get('[data-test-subj="form-name-indexArray"] [data-test-subj="comboBoxSearchInput"]').type( + `${EDIT_INDEX}{enter}${SAMPLE_INDEX_PREFIX}-*{enter}` + ); + cy.get(".euiModalFooter .euiButton--fill").click({ force: true }).get('[data-test-subj="9 more"]').should("exist"); + }); + }); + + describe("can edit / delete a alias", () => { + it("successfully", () => { + cy.get('[placeholder="Search..."]').type(`${SAMPLE_ALIAS_PREFIX}-0{enter}`); + cy.contains(`${SAMPLE_ALIAS_PREFIX}-0`); + cy.get('[data-test-subj="moreAction"] button') + .click() + .get('[data-test-subj="editAction"]') + .should("be.disabled") + .get(`#_selection_column_${SAMPLE_ALIAS_PREFIX}-0-checkbox`) + .click() + .get('[data-test-subj="moreAction"] button') + .click() + .get('[data-test-subj="editAction"]') + .click() + .get('[data-test-subj="form-name-indexArray"] [data-test-subj="comboBoxInput"]') + .click() + .type(`${EDIT_INDEX}{enter}`) + .get(`[title="${SAMPLE_INDEX_PREFIX}-0"] button`) + .click() + .get(`[title="${SAMPLE_INDEX_PREFIX}-1"] button`) + .click() + .get(".euiModalFooter .euiButton--fill") + .click({ force: true }) + .end(); + + cy.get('[data-test-subj="7 more"]').should("exist"); + + cy.get('[data-test-subj="moreAction"] button').click().get('[data-test-subj="deleteAction"]').click(); + // The confirm button should be disabled + cy.get('[data-test-subj="deleteConfirmButton"]').should("be.disabled"); + // type delete + cy.wait(500).get('[data-test-subj="deleteInput"]').type("delete"); + cy.get('[data-test-subj="deleteConfirmButton"]').should("not.be.disabled"); + // click to delete + cy.get('[data-test-subj="deleteConfirmButton"]').click(); + // the alias should not exist + cy.wait(500); + cy.get(`#_selection_column_${SAMPLE_ALIAS_PREFIX}-0-checkbox`).should("not.exist"); + }); + }); + + after(() => { + cy.deleteAllIndices(); + for (let i = 0; i < 30; i++) { + cy.removeAlias(`${SAMPLE_ALIAS_PREFIX}-${i}`); + } + cy.removeAlias(CREATE_ALIAS); + }); +}); diff --git a/cypress/integration/create_index.js b/cypress/integration/create_index.js new file mode 100644 index 000000000..f1a78f55d --- /dev/null +++ b/cypress/integration/create_index.js @@ -0,0 +1,257 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { PLUGIN_NAME } from "../support/constants"; + +const SAMPLE_INDEX = "index-specific-index"; + +describe("Create Index", () => { + before(() => { + // Set welcome screen tracking to false + localStorage.setItem("home:welcome:show", "false"); + cy.deleteAllIndices(); + cy.deleteTemplate("index-common-template"); + cy.deleteTemplate("index-specific-template"); + cy.createIndexTemplate("index-common-template", { + index_patterns: ["index-*"], + template: { + aliases: { + alias_for_common_1: {}, + alias_for_common_2: {}, + }, + settings: { + number_of_shards: 2, + number_of_replicas: 1, + }, + }, + }); + cy.createIndexTemplate("index-specific-template", { + index_patterns: ["index-specific-*"], + priority: 1, + template: { + aliases: { + alias_for_specific_1: {}, + }, + settings: { + number_of_shards: 3, + number_of_replicas: 2, + }, + mappings: { + properties: { + text: { + type: "text", + }, + }, + }, + }, + }); + }); + + describe("can be created and updated", () => { + beforeEach(() => { + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/indices`); + cy.contains("Rows per page", { timeout: 60000 }); + }); + + it("Create a index successfully", () => { + // enter create page + cy.get('[data-test-subj="Create IndexButton"]').click(); + cy.contains("Create index"); + + // type field name + cy.get('[placeholder="Specify a name for the new index."]').type(SAMPLE_INDEX).blur(); + + cy.wait(1000); + + cy.get('[data-test-subj="comboBoxSearchInput"]').get('[title="alias_for_specific_1"]').should("exist"); + + cy.get('[data-test-subj="comboBoxSearchInput"]').type("some_test_alias{enter}"); + + cy.get('[data-test-subj="editorTypeJsonEditor"]').click().end(); + + cy.get('[data-test-subj="mappingsJsonEditorFormRow"] [data-test-subj="jsonEditor-valueDisplay"]').should(($editor) => { + expect(JSON.parse($editor.val())).to.deep.equal({ + properties: { + text: { + type: "text", + }, + }, + }); + }); + + cy.get('[data-test-subj="mappingsJsonEditorFormRow"] .ace_text-input') + .focus() + .clear({ force: true }) + .type( + JSON.stringify({ + properties: { + text: { + type: "text", + }, + }, + dynamic: true, + }), + { parseSpecialCharSequences: false, force: true } + ) + .end() + .wait(1000) + .get('[data-test-subj="editorTypeVisualEditor"]') + .click() + .end(); + + // add a field + cy.get('[data-test-subj="createIndexAddFieldButton"]').click().end(); + cy.get('[data-test-subj="mapping-visual-editor-1-field-name"]').type("text_mappings"); + + // click create + cy.get('[data-test-subj="createIndexCreateButton"]').click({ force: true }); + + // The index should exist + cy.get(`#_selection_column_${SAMPLE_INDEX}-checkbox`).should("have.exist"); + + // check the index detail + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/create-index/${SAMPLE_INDEX}`); + + // index name and alias should exist + cy.get(`[title="${SAMPLE_INDEX}"]`) + .should("have.exist") + .end() + .get('[title="some_test_alias"]') + .should("have.exist") + .end() + .get('[data-test-subj="mapping-visual-editor-0-field-type"]') + .should("have.attr", "title", "text") + .end() + .get('[data-test-subj="mapping-visual-editor-1-field-name"]') + .should("have.attr", "title", "text_mappings") + .end() + .get('[data-test-subj="editorTypeJsonEditor"]') + .click() + .end() + .get('[data-test-subj="mappingsJsonEditorFormRow"] [data-test-subj="jsonEditor-valueDisplay"]') + .should(($editor) => { + expect(JSON.parse($editor.val())).to.deep.equal({ + dynamic: "true", + properties: {}, + }); + }); + }); + + it("Update alias successfully", () => { + cy.get(`[data-test-subj="viewIndexDetailButton-${SAMPLE_INDEX}"]`).click().get("#indexDetailModalAlias").click(); + + // add a alias and remove the exist alias + cy.get('[data-test-subj="comboBoxSearchInput"]') + .type("some_new_test_alias{enter}") + .end() + .get('[title="some_test_alias"] .euiBadge__iconButton') + .click() + .end() + .get('[data-test-subj="createIndexCreateButton"]') + .click({ force: true }) + .end(); + + cy.get('[title="some_test_alias"]').should("not.exist").end().get('[title="some_new_test_alias"]').should("exist").end(); + }); + + it("Update settings successfully", () => { + cy.get(`[data-test-subj="viewIndexDetailButton-${SAMPLE_INDEX}"]`).click().get("#indexDetailModalSettings").click(); + + cy.get('[aria-controls="accordionForCreateIndexSettings"]') + .click() + .end() + .get(".ace_text-input") + .focus() + .clear({ force: true }) + .type('{ "index.blocks.write": true, "index.number_of_shards": 2, "index.number_of_replicas": 3 }', { + parseSpecialCharSequences: false, + force: true, + }) + .blur(); + + cy.get('[data-test-subj="createIndexCreateButton"]').click({ force: true }); + + cy.contains(`Can't update non dynamic settings`).should("exist"); + + cy.get(".ace_text-input") + .focus() + .clear({ force: true }) + .type('{ "index.blocks.write": true, "index.number_of_shards": "3" }', { parseSpecialCharSequences: false, force: true }) + .end() + .wait(1000) + .get('[placeholder="The number of replica shards each primary shard should have."]') + .clear() + .type(2) + .end(); + + cy.get('[data-test-subj="createIndexCreateButton"]').click({ force: true }); + + cy.wait(1000).get('[data-test-subj="form-name-index.number_of_replicas"] input').should("have.value", "2"); + }); + + it("Update mappings successfully", () => { + cy.get(`[data-test-subj="viewIndexDetailButton-${SAMPLE_INDEX}"]`).click().get("#indexDetailModalMappings").click(); + + cy.get('[data-test-subj="createIndexAddFieldButton"]') + .click() + .end() + .get('[data-test-subj="mapping-visual-editor-2-field-name"]') + .type("text_mappings_2") + .end() + .get('[data-test-subj="createIndexCreateButton"]') + .click({ force: true }); + + cy.get('[data-test-subj="mapping-visual-editor-2-field-type"]').should("have.attr", "title", "text").end(); + + cy.get('[data-test-subj="editorTypeJsonEditor"]') + .click() + .end() + .get(".ace_text-input") + .focus() + .clear({ force: true }) + .type('{ "dynamic": true }', { parseSpecialCharSequences: false, force: true }) + .blur() + .end() + .wait(1000) + .get('[data-test-subj="createIndexCreateButton"]') + .click({ force: true }); + + cy.wait(1000) + .get('[data-test-subj="editorTypeJsonEditor"]') + .click() + .end() + .get('[data-test-subj="previousMappingsJsonButton"]') + .click() + .end() + .get('[data-test-subj="previousMappingsJsonModal"] [data-test-subj="jsonEditor-valueDisplay"]') + .should( + "have.text", + JSON.stringify( + { + dynamic: "true", + properties: { + text: { + type: "text", + }, + text_mappings: { + type: "text", + }, + text_mappings_2: { + type: "text", + }, + }, + }, + null, + 2 + ) + ); + }); + }); + + after(() => { + cy.deleteTemplate("index-common-template"); + cy.deleteTemplate("index-specific-template"); + }); +}); diff --git a/cypress/integration/indices_spec.js b/cypress/integration/indices_spec.js index e20986940..28c66eee7 100644 --- a/cypress/integration/indices_spec.js +++ b/cypress/integration/indices_spec.js @@ -132,12 +132,10 @@ describe("Indices", () => { cy.get(`[data-test-subj="checkboxSelectRow-${SAMPLE_INDEX}"]`).check({ force: true }); // Click apply policy button + cy.get('[data-test-subj="moreAction"]').click(); cy.get(`[data-test-subj="Apply policyButton"]`).click({ force: true }); - cy.get(`input[data-test-subj="comboBoxSearchInput"]`).focus().type(POLICY_ID, { - parseSpecialCharSequences: false, - delay: 1, - }); + cy.get(`input[data-test-subj="comboBoxSearchInput"]`).click().type(POLICY_ID); // Click the policy option cy.get(`button[role="option"]`).first().click({ force: true }); @@ -153,6 +151,298 @@ describe("Indices", () => { // Confirm our index is now being managed cy.get(`tbody > tr:contains("${SAMPLE_INDEX}") > td`).filter(`:nth-child(4)`).contains("Yes"); + + // Confirm the information shows in detail modal + cy.get(`[data-test-subj="viewIndexDetailButton-${SAMPLE_INDEX}"]`).click(); + cy.get(`[data-test-subj="indexDetailOverviewItem-Managed by policy"] .euiDescriptionList__description a`).contains(POLICY_ID); + }); + }); + + describe("can make indices deleted", () => { + before(() => { + cy.deleteAllIndices(); + cy.createIndex(SAMPLE_INDEX); + }); + + it("successfully", () => { + // Confirm we have our initial index + cy.contains(SAMPLE_INDEX); + + // Click actions button + cy.get('[data-test-subj="moreAction"]').click(); + + // Delete btn should be disabled if no items selected + cy.get('[data-test-subj="deleteAction"]').should("have.class", "euiContextMenuItem-isDisabled"); + + // click any where to hide actions + cy.get("#_selection_column_sample_index-checkbox").click(); + cy.get('[data-test-subj="deleteAction"]').should("not.exist"); + + // Click actions button + cy.get('[data-test-subj="moreAction"]').click(); + // Delete btn should be enabled + cy.get('[data-test-subj="deleteAction"]').should("exist").should("not.have.class", "euiContextMenuItem-isDisabled").click(); + // The confirm button should be disabled + cy.get('[data-test-subj="Delete Confirm button"]').should("have.class", "euiButton-isDisabled"); + // type delete + cy.get('[placeholder="delete"]').type("delete"); + cy.get('[data-test-subj="Delete Confirm button"]').should("not.have.class", "euiContextMenuItem-isDisabled"); + // click to delete + cy.get('[data-test-subj="Delete Confirm button"]').click(); + // the sample_index should not exist + cy.wait(500); + cy.get("#_selection_column_sample_index-checkbox").should("not.exist"); + }); + }); + + describe("shows detail of a index when click the item", () => { + before(() => { + cy.deleteAllIndices(); + cy.createIndex(SAMPLE_INDEX); + }); + + it("successfully", () => { + cy.get(`[data-test-subj="viewIndexDetailButton-${SAMPLE_INDEX}"]`).click(); + cy.get(`[data-test-subj="indexDetailOverviewItem-Index name"] .euiDescriptionList__description > span`).should( + "have.text", + SAMPLE_INDEX + ); + }); + }); + + describe("can search with reindex & recovery status", () => { + const reindexedIndex = "reindex_opensearch_dashboards_sample_data_ecommerce"; + const splittedIndex = "split_opensearch_dashboards_sample_data_logs"; + before(() => { + cy.deleteAllIndices(); + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/indices`); + + // Common text to wait for to confirm page loaded, give up to 60 seconds for initial load + cy.contains("Rows per page", { timeout: 60000 }); + + cy.request({ + method: "POST", + url: `${Cypress.env("opensearch_dashboards")}/api/sample_data/ecommerce`, + headers: { + "osd-xsrf": true, + }, + }).then((response) => { + expect(response.status).equal(200); + }); + + cy.request({ + method: "POST", + url: `${Cypress.env("opensearch_dashboards")}/api/sample_data/logs`, + headers: { + "osd-xsrf": true, + }, + }).then((response) => { + expect(response.status).equal(200); + }); + + cy.request({ + method: "PUT", + url: `${Cypress.env("opensearch")}/${splittedIndex}/_settings`, + body: { + "index.blocks.read_only": false, + }, + failOnStatusCode: false, + }); + cy.request({ + method: "DELETE", + url: `${Cypress.env("opensearch")}/${reindexedIndex}`, + failOnStatusCode: false, + }); + cy.request({ + method: "DELETE", + url: `${Cypress.env("opensearch")}/${splittedIndex}`, + failOnStatusCode: false, + }); + }); + + it("Successfully", () => { + cy.request({ + method: "PUT", + url: `${Cypress.env("opensearch")}/${reindexedIndex}`, + body: { + settings: { + index: { + number_of_shards: 1, + number_of_replicas: "0", + }, + }, + }, + }); + // do a simple reindex + cy.request("POST", `${Cypress.env("opensearch")}/_reindex?wait_for_completion=false`, { + source: { + index: "opensearch_dashboards_sample_data_ecommerce", + }, + dest: { + index: reindexedIndex, + }, + }); + + cy.get('[placeholder="Search"]').type("o"); + + // do a simple split + cy.request("PUT", `${Cypress.env("opensearch")}/opensearch_dashboards_sample_data_logs/_settings`, { + "index.blocks.write": true, + }); + + cy.request({ + method: "POST", + url: `${Cypress.env("opensearch_dashboards")}/api/ism/apiCaller`, + headers: { + "osd-xsrf": true, + }, + body: { + endpoint: "indices.split", + data: { + index: "opensearch_dashboards_sample_data_logs", + target: splittedIndex, + body: { + settings: { + index: { + number_of_shards: 2, + }, + }, + }, + }, + }, + }); + + cy.get('[placeholder="Search"]').type("p"); + }); + + after(() => { + cy.request({ + method: "DELETE", + url: `${Cypress.env("opensearch")}/${reindexedIndex}`, + failOnStatusCode: false, + }); + cy.request({ + method: "DELETE", + url: `${Cypress.env("opensearch")}/${splittedIndex}`, + failOnStatusCode: false, + }); + }); + }); + + describe("can shrink an index", () => { + before(() => { + cy.deleteAllIndices(); + cy.createIndex(SAMPLE_INDEX, null, { + settings: { "index.blocks.write": true, "index.number_of_shards": 2, "index.number_of_replicas": 0 }, + }); + }); + + it("successfully shrink an index", () => { + // Type in SAMPLE_INDEX in search input + cy.get(`input[type="search"]`).focus().type(SAMPLE_INDEX); + + cy.wait(1000).get(".euiTableRow").should("have.length", 1); + // Confirm we have our initial index + cy.contains(SAMPLE_INDEX); + + cy.get('[data-test-subj="moreAction"]').click(); + // Shrink btn should be disabled if no items selected + cy.get('[data-test-subj="Shrink Action"]').should("have.class", "euiContextMenuItem-isDisabled"); + + // Select an index + cy.get(`[data-test-subj="checkboxSelectRow-${SAMPLE_INDEX}"]`).check({ force: true }); + + cy.get('[data-test-subj="moreAction"]').click(); + // Shrink btn should be enabled + cy.get('[data-test-subj="Shrink Action"]').should("exist").should("not.have.class", "euiContextMenuItem-isDisabled").click(); + + // Check for Shrink page + cy.contains("Shrink index"); + + // Enter target index name + cy.get(`input[data-test-subj="targetIndexNameInput"]`).type(`${SAMPLE_INDEX}_shrunken`); + + // Click shrink index button + cy.get("button").contains("Shrink").click({ force: true }); + + // Check for success toast + cy.contains(`Successfully started shrinking ${SAMPLE_INDEX}. The shrunken index will be named ${SAMPLE_INDEX}_shrunken.`); + }); + }); + + describe("can close and open an index", () => { + before(() => { + cy.deleteAllIndices(); + cy.createIndex(SAMPLE_INDEX); + }); + + it("successfully close an index", () => { + cy.contains(SAMPLE_INDEX); + + cy.get('[data-test-subj="moreAction"]').click(); + // Close btn should be disabled if no items selected + cy.get('[data-test-subj="Close Action"]').should("have.class", "euiContextMenuItem-isDisabled"); + + // Select an index + cy.get(`[data-test-subj="checkboxSelectRow-${SAMPLE_INDEX}"]`).check({ force: true }); + + cy.get('[data-test-subj="moreAction"]').click(); + // Close btn should be enabled + cy.get('[data-test-subj="Close Action"]').should("exist").should("not.have.class", "euiContextMenuItem-isDisabled").click(); + + // Check for close index modal + cy.contains("Close indices"); + + // Close confirm button should be disabled + cy.get('[data-test-subj="Close Confirm button"]').should("have.class", "euiButton-isDisabled"); + // type close + cy.get('[placeholder="close"]').type("close"); + cy.get('[data-test-subj="Close Confirm button"]').should("not.have.class", "euiContextMenuItem-isDisabled"); + + // Click close confirm button + cy.get('[data-test-subj="Close Confirm button"]').click(); + + // Check for success toast + cy.contains("Close [sample_index] successfully"); + + // Confirm the index is closed + cy.get(`input[type="search"]`).focus().type(SAMPLE_INDEX); + cy.get("tbody > tr").should(($tr) => { + expect($tr, "1 row").to.have.length(1); + expect($tr, "item").to.contain("close"); + }); + }); + + it("successfully open an index", () => { + // Confirm we have our initial index + cy.contains(SAMPLE_INDEX); + + cy.get('[data-test-subj="moreAction"]').click(); + // Open btn should be disabled if no items selected + cy.get('[data-test-subj="Open Action"]').should("have.class", "euiContextMenuItem-isDisabled"); + + // Select an index + cy.get(`[data-test-subj="checkboxSelectRow-${SAMPLE_INDEX}"]`).check({ force: true }); + + cy.get('[data-test-subj="moreAction"]').click(); + // Open btn should be enabled + cy.get('[data-test-subj="Open Action"]').should("exist").should("not.have.class", "euiContextMenuItem-isDisabled").click(); + + // Check for open index modal + cy.contains("Open indices"); + + cy.get('[data-test-subj="Open Confirm button"]').click(); + + // Check for success toast + cy.contains("Open [sample_index] successfully"); + + // Confirm the index is open + cy.get(`input[type="search"]`).focus().type(SAMPLE_INDEX); + cy.get("tbody > tr").should(($tr) => { + expect($tr, "1 row").to.have.length(1); + expect($tr, "item").to.contain("open"); + }); }); }); }); diff --git a/cypress/integration/reindex_spec.js b/cypress/integration/reindex_spec.js new file mode 100644 index 000000000..4f31b0270 --- /dev/null +++ b/cypress/integration/reindex_spec.js @@ -0,0 +1,225 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PLUGIN_NAME } from "../support/constants"; +const REINDEX_DEST = "test-ecomm-rdx"; +const REINDEX_DEST_NO_SOURCE = "test-reindex-nosource"; +const REINDEX_NEW_CREATED = "test-logs-new"; + +describe("Reindex", () => { + beforeEach(() => { + // Set welcome screen tracking to false + localStorage.setItem("home:welcome:show", "false"); + + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/indices`); + + // Common text to wait for to confirm page loaded, give up to 60 seconds for initial load + cy.contains("Rows per page", { timeout: 60000 }); + }); + + describe("Reindex validation error", () => { + before(() => { + cy.deleteAllIndices(); + // Load ecommerce data + cy.request({ + method: "POST", + url: `${Cypress.env("opensearch_dashboards")}/api/sample_data/ecommerce`, + headers: { + "osd-xsrf": true, + }, + }).then((response) => { + expect(response.status).equal(200); + }); + + cy.createIndex(REINDEX_DEST_NO_SOURCE, null, { + mappings: { + _source: { + enabled: false, + }, + properties: { + name: { + type: "keyword", + }, + }, + }, + }); + }); + + it("source validation failed", () => { + // Confirm we have our initial index + cy.contains(REINDEX_DEST_NO_SOURCE); + + cy.get(`[data-test-subj="checkboxSelectRow-${REINDEX_DEST_NO_SOURCE}"]`).check({ force: true }); + + // Click actions button + cy.get('[data-test-subj="moreAction"]').click(); + // Reindex should show as activate + cy.get('[data-test-subj="Reindex Action"]').should("exist").should("not.have.class", "euiContextMenuItem-isDisabled").click(); + + cy.contains(/_sources is not enabled/); + }); + }); + + describe("Reindex successfully", () => { + before(() => { + cy.deleteAllIndices(); + // Load ecommerce data + cy.request({ + method: "POST", + url: `${Cypress.env("opensearch_dashboards")}/api/sample_data/ecommerce`, + headers: { + "osd-xsrf": true, + }, + }).then((response) => { + expect(response.status).equal(200); + }); + + cy.createIndex(REINDEX_DEST, null, { settings: { "index.number_of_replicas": 0 } }); + + cy.createPipeline("bumpOrderId", { + description: "sample description", + processors: [ + { + set: { + field: "order_id", + value: "200{{order_id}}", + }, + }, + ], + }); + }); + + it("successfully", () => { + // Confirm we have our initial index + cy.contains("opensearch_dashboards_sample_data_ecommerce"); + + // Click actions button + cy.get('[data-test-subj="moreAction"]').click(); + // Reindex should show as activate + cy.get('[data-test-subj="Reindex Action"]').should("exist").should("not.have.class", "euiContextMenuItem-isDisabled").click(); + + cy.get(`div[data-test-subj="sourceSelector"]`) + .find(`input[data-test-subj="comboBoxSearchInput"]`) + .type(`opensearch_dashboards_sample_data_ecommerce{downArrow}{enter}`); + + cy.get(`div[data-test-subj="destinationSelector"]`) + .find(`input[data-test-subj="comboBoxSearchInput"]`) + .type(`${REINDEX_DEST}{downArrow}{enter}`); + + // open advance option + cy.get('[data-test-subj="advanceOptionToggle"]').click(); + + // enable subset query + cy.get('[data-test-subj="subsetOption"] #subset').click({ force: true }); + + // input query to reindex subset + cy.get('[data-test-subj="queryJsonEditor"] textarea') + .focus() + .clear() + .type('{"query":{"match":{"category":"Men\'s Clothing"}}}', { parseSpecialCharSequences: false }); + + // set slices to auto + cy.get('[data-test-subj="sliceEnabled"]').click({ force: true }); + + // input pipeline + cy.get(`div[data-test-subj="pipelineCombobox"]`).find(`input[data-test-subj="comboBoxSearchInput"]`).type("bumpOrderId{enter}"); + + // click to perform reindex + cy.get('[data-test-subj="reindexConfirmButton"]').click(); + cy.wait(10); + cy.contains(/Successfully started reindexing/); + + cy.wait(10000); + // Type in REINDEX_DEST in search input + cy.get(`input[type="search"]`).focus().type(REINDEX_DEST); + + // Confirm we only see REINDEX_DEST in table + cy.get("tbody > tr").should(($tr) => { + expect($tr, "1 row").to.have.length(1); + expect($tr, "item").to.contain(REINDEX_DEST); + }); + }); + }); + + describe("Reindex successfully for newly created index", () => { + before(() => { + cy.deleteAllIndices(); + // Load logs data + cy.request({ + method: "POST", + url: `${Cypress.env("opensearch_dashboards")}/api/sample_data/logs`, + headers: { + "osd-xsrf": true, + }, + }).then((response) => { + expect(response.status).equal(200); + }); + }); + + it("successfully", () => { + // search + cy.get(`input[type="search"]`).focus().type("opensearch_dashboards_sample_data_logs"); + + cy.wait(1000); + + // Confirm we have our initial index + cy.contains("opensearch_dashboards_sample_data_logs"); + + // select logs index + cy.get("#_selection_column_opensearch_dashboards_sample_data_logs-checkbox").click(); + + // Click actions button + cy.get('[data-test-subj="moreAction"]').click(); + // Reindex should show as activate + cy.get('[data-test-subj="Reindex Action"]').click(); + + // open advance option + cy.get('[data-test-subj="advanceOptionToggle"]').click(); + + // enable subset query + cy.get('[data-test-subj="subsetOption"] #subset').click({ force: true }); + + // input query to reindex subset + cy.get('[data-test-subj="queryJsonEditor"] textarea') + .focus() + .clear() + .type('{"query":{"match":{"ip":"135.201.60.64"}}}', { parseSpecialCharSequences: false }); + + // create destination + cy.get('[data-test-subj="createIndexButton"]').click(); + cy.contains("Create Index"); + + cy.get('[placeholder="Specify a name for the new index."]').type(REINDEX_NEW_CREATED).blur(); + cy.wait(1000); + + // import setting and mapping + cy.get('[data-test-subj="importSettingMappingBtn"]').click(); + cy.get('[data-test-subj="import-settings-opensearch_dashboards_sample_data_logs"]').click(); + + cy.wait(10); + cy.contains(/have been import successfully/); + + cy.get('[data-test-subj="flyout-footer-action-button"]').click({ force: true }); + + // click to perform reindex + cy.get('[data-test-subj="reindexConfirmButton"]').click(); + cy.wait(10); + cy.contains(/Successfully started reindexing/); + + cy.wait(10000); + // Type in REINDEX_DEST in search input + cy.get(`input[type="search"]`).focus().type(REINDEX_NEW_CREATED); + + // Confirm we only see REINDEX_DEST in table + cy.get("tbody > tr").should(($tr) => { + expect($tr, "1 row").to.have.length(1); + expect($tr, "item").to.contain(REINDEX_NEW_CREATED); + // subset data number + expect($tr, "item").to.contain(13); + }); + }); + }); +}); diff --git a/cypress/integration/split_index.js b/cypress/integration/split_index.js new file mode 100644 index 000000000..39485be92 --- /dev/null +++ b/cypress/integration/split_index.js @@ -0,0 +1,184 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { PLUGIN_NAME } from "../support/constants"; + +const sampleIndex = "index-split"; +const sampleAlias = "alias-split"; + +describe("Split Index", () => { + before(() => { + // Set welcome screen tracking to false + localStorage.setItem("home:welcome:show", "false"); + cy.deleteAllIndices(); + }); + + describe("can be created and updated", () => { + beforeEach(() => { + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/indices`); + cy.contains("Rows per page", { timeout: 60000 }); + }); + + let splitNumber = 2; + let replicaNumber = 1; + it("Create an index successfully", () => { + // enter create page + cy.get('[data-test-subj="Create IndexButton"]').click(); + cy.contains("Create index"); + + // type field name + cy.get('[placeholder="Specify a name for the new index."]').type(sampleIndex).end(); + + cy.get('[data-test-subj="comboBoxSearchInput"]').focus().type(`${sampleAlias}`).end(); + + // click create + cy.get('[data-test-subj="createIndexCreateButton"]').click({ force: true }).end(); + + // The index should exist + cy.get(`#_selection_column_${sampleIndex}-checkbox`).should("have.exist").end(); + + cy.get(`[data-test-subj="viewIndexDetailButton-${sampleIndex}"]`).click().end(); + cy.get("#indexDetailModalSettings").click().end(); + + cy.get('[data-test-subj="form-name-index.number_of_shards"] .euiText').then(($shardNumber) => { + splitNumber = $shardNumber.attr("title") * 2; + }); + + cy.get("#indexDetailModalAlias").click().end(); + cy.get(`[title="${sampleAlias}"]`).should("exist").end(); + + // Update Index status to blocks write otherwise we can't apply split operation on it + cy.updateIndexSettings(sampleIndex, { "index.blocks.write": "true" }).end(); + }); // create index + + it("Split successfully", () => { + const targetIndex = `${sampleIndex}` + "-target"; + cy.get(`[data-test-subj="checkboxSelectRow-${sampleIndex}"]`) + .click() + .end() + .get('[data-test-subj="moreAction"]') + .click() + .end() + .get('[data-test-subj="Split Action"]') + .click() + .end() + // Target Index Name is required + .get('[data-test-subj="targetIndexNameInput"]') + .type(`${targetIndex}`) + .end() + // Number of shards after split is required + .get('[data-test-subj="numberOfShardsInput"]') + .type(`${splitNumber}{downArrow}{enter}`) + .end() + .get('[data-test-subj="numberOfReplicasInput"]') + .clear() + .type(`${replicaNumber}`) + .end() + .get('[data-test-subj="splitButton"]') + .click() + .end(); + + cy.get(`[data-test-subj="viewIndexDetailButton-${targetIndex}"]`).click().end(); + cy.get("#indexDetailModalSettings").click().end(); + cy.get('[data-test-subj="form-name-index.number_of_shards"] .euiText').should("have.text", `${splitNumber}`).end(); + cy.get('[data-test-subj="form-name-index.number_of_replicas"] input').should("have.value", `${replicaNumber}`).end(); + }); // Split + + it("Split successfully with advanced setting", () => { + const targetIndex = `${sampleIndex}` + "-setting"; + cy.get(`[data-test-subj="checkboxSelectRow-${sampleIndex}"]`) + .click() + .end() + .get('[data-test-subj="moreAction"]') + .click() + .end() + .get('[data-test-subj="Split Action"]') + .click() + .end() + .get("[data-test-subj=targetIndexNameInput]") + .type(`${targetIndex}`) + .end() + // Instead of input shard number at shard field, another option is to populate it in advanced setting + .get('[aria-controls="accordionForCreateIndexSettings"]') + .click() + .end() + .get('[data-test-subj="codeEditorContainer"] textarea') + .focus() + // Need to remove the default {} in advanced setting + .clear() + .type(`{"index.number_of_shards": "${splitNumber}", "index.number_of_replicas": "${replicaNumber}"}`, { + parseSpecialCharSequences: false, + }) + .end() + .get('[data-test-subj="splitButton"]') + .click() + .end(); + + cy.get(`[data-test-subj="viewIndexDetailButton-${targetIndex}"]`).click().end(); + cy.get("#indexDetailModalSettings").click().end(); + cy.get('[data-test-subj="form-name-index.number_of_shards"] .euiText').should("have.text", `${splitNumber}`).end(); + cy.get('[data-test-subj="form-name-index.number_of_replicas"] input').should("have.value", `${replicaNumber}`).end(); + }); // advanced setting + + it("Split successfully with alias", () => { + const targetIndex = `${sampleIndex}` + "-alias"; + const newAlias = "alias-new"; + cy.get(`[data-test-subj="checkboxSelectRow-${sampleIndex}"]`) + .click() + .end() + .get('[data-test-subj="moreAction"]') + .click() + .end() + .get('[data-test-subj="Split Action"]') + .click() + .end() + .get("[data-test-subj=targetIndexNameInput]") + .type(`${targetIndex}`) + .end() + .get('[data-test-subj="numberOfShardsInput"]') + .type(`${splitNumber}{downArrow}{enter}`) + .end() + // Assign to an existing alias and a new alias + .get('[data-test-subj="form-name-aliases"] [data-test-subj="comboBoxSearchInput"]') + .type(`${sampleAlias}{enter}${newAlias}{enter}`) + .end() + .get('[data-test-subj="splitButton"]') + .click() + .end(); + + cy.get(`[data-test-subj="viewIndexDetailButton-${targetIndex}"]`).click().end(); + // Verify alias associated with the new index + cy.get("#indexDetailModalAlias").click().end(); + cy.get(`[title="${newAlias}"]`).should("exist").end(); + cy.get(`[title="${sampleAlias}"]`).should("exist").end(); + }); // Create with alias + + it("Update blocks write to true", () => { + // Set index to not blocks write + cy.updateIndexSettings(sampleIndex, { "index.blocks.write": "false" }).end(); + cy.get(`[data-test-subj="checkboxSelectRow-${sampleIndex}"]`) + .click() + .end() + .get('[data-test-subj="moreAction"]') + .click() + .end() + .get('[data-test-subj="Split Action"]') + .click() + .end() + // Index can't be split if it's blocks write status is not true + .get('[data-test-subj="splitButton"]') + .should("have.class", "euiButton-isDisabled") + .end() + .wait(1000) + // Set index to blocks write + .get('[data-test-subj="set-indexsetting-button"]') + .click() + .end() + .get('[data-test-subj="splitButton"]') + .click() + .end(); + }); // Blocks write + }); +}); diff --git a/cypress/integration/templates.js b/cypress/integration/templates.js new file mode 100644 index 000000000..c0743a8cf --- /dev/null +++ b/cypress/integration/templates.js @@ -0,0 +1,91 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { PLUGIN_NAME } from "../support/constants"; + +const SAMPLE_TEMPLATE_PREFIX = "index-for-alias-test"; +const MAX_TEMPLATE_NUMBER = 30; + +describe("Templates", () => { + before(() => { + // Set welcome screen tracking to false + localStorage.setItem("home:welcome:show", "false"); + cy.deleteTemplate(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER}`); + for (let i = 0; i < MAX_TEMPLATE_NUMBER; i++) { + cy.deleteTemplate(`${SAMPLE_TEMPLATE_PREFIX}-${i}`); + cy.createIndexTemplate(`${SAMPLE_TEMPLATE_PREFIX}-${i}`, { + index_patterns: ["template-test-*"], + priority: i, + template: { + aliases: {}, + settings: { + number_of_shards: 2, + number_of_replicas: 1, + }, + }, + }); + } + }); + + beforeEach(() => { + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/templates`); + + // Common text to wait for to confirm page loaded, give up to 60 seconds for initial load + cy.contains("Rows per page", { timeout: 60000 }); + }); + + describe("can be searched / sorted / paginated", () => { + it("successfully", () => { + cy.get('[data-test-subj="pagination-button-1"]').should("exist"); + cy.get('[placeholder="Search..."]').type(`${SAMPLE_TEMPLATE_PREFIX}-0`); + cy.contains(`${SAMPLE_TEMPLATE_PREFIX}-0`); + cy.get(".euiTableRow").should("have.length", 1); + }); + }); + + describe("can create a template", () => { + it("successfully", () => { + cy.get('[data-test-subj="Create templateButton"]').click(); + cy.contains("Define template"); + + cy.get('[data-test-subj="form-row-name"] input').type(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER}`); + cy.get('[data-test-subj="form-row-index_patterns"] [data-test-subj="comboBoxSearchInput"]').type("test{enter}"); + cy.get('[data-test-subj="CreateIndexTemplateCreateButton"]').click(); + + cy.contains(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER} has been successfully created.`); + + cy.get('[placeholder="Search..."]').type(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER}`); + cy.contains(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER}`); + cy.get(".euiTableRow").should("have.length", 1); + }); + }); + + describe("can delete a template", () => { + it("successfully", () => { + cy.get('[placeholder="Search..."]').type(`${SAMPLE_TEMPLATE_PREFIX}-0`); + cy.contains(`${SAMPLE_TEMPLATE_PREFIX}-0`); + cy.get(`#_selection_column_${SAMPLE_TEMPLATE_PREFIX}-0-checkbox`).click(); + + cy.get('[data-test-subj="moreAction"] button').click().get('[data-test-subj="deleteAction"]').click(); + // The confirm button should be disabled + cy.get('[data-test-subj="deleteConfirmButton"]').should("be.disabled"); + // type delete + cy.wait(500).get('[data-test-subj="deleteInput"]').type("delete"); + cy.get('[data-test-subj="deleteConfirmButton"]').should("not.be.disabled"); + // click to delete + cy.get('[data-test-subj="deleteConfirmButton"]').click(); + // the alias should not exist + cy.wait(500); + cy.get(`#_selection_column_${SAMPLE_TEMPLATE_PREFIX}-0-checkbox`).should("not.exist"); + }); + }); + + after(() => { + cy.deleteTemplate(`${SAMPLE_TEMPLATE_PREFIX}-${MAX_TEMPLATE_NUMBER}`); + for (let i = 0; i < MAX_TEMPLATE_NUMBER; i++) { + cy.deleteTemplate(`${SAMPLE_TEMPLATE_PREFIX}-${i}`); + } + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 0850da201..5e8cf088f 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -4,7 +4,7 @@ */ const { API, INDEX, ADMIN_AUTH } = require("./constants"); -const { NODE_API } = require("../../server/utils/constants") +const { NODE_API } = require("../../server/utils/constants"); // *********************************************** // This example commands.js shows you how to @@ -123,8 +123,8 @@ Cypress.Commands.add("createIndex", (index, policyID = null, settings = {}) => { }); Cypress.Commands.add("deleteSnapshot", (repository, snapshot) => { - cy.request("DELETE", `${Cypress.env("opensearch")}${NODE_API._SNAPSHOTS}/${repository}/${snapshot}`) -}) + cy.request("DELETE", `${Cypress.env("opensearch")}${NODE_API._SNAPSHOTS}/${repository}/${snapshot}`); +}); Cypress.Commands.add("createRollup", (rollupId, rollupJSON) => { cy.request("PUT", `${Cypress.env("opensearch")}${API.ROLLUP_JOBS_BASE}/${rollupId}`, rollupJSON); @@ -134,6 +134,14 @@ Cypress.Commands.add("createIndexTemplate", (name, template) => { cy.request("PUT", `${Cypress.env("opensearch")}${API.INDEX_TEMPLATE_BASE}/${name}`, template); }); +Cypress.Commands.add("deleteTemplate", (name) => { + cy.request({ + url: `${Cypress.env("opensearch")}${API.INDEX_TEMPLATE_BASE}/${name}`, + failOnStatusCode: false, + method: "DELETE", + }); +}); + Cypress.Commands.add("createDataStream", (name) => { cy.request("PUT", `${Cypress.env("opensearch")}${API.DATA_STREAM_BASE}/${name}`); }); @@ -150,6 +158,10 @@ Cypress.Commands.add("createTransform", (transformId, transformJSON) => { cy.request("PUT", `${Cypress.env("opensearch")}${API.TRANSFORM_JOBS_BASE}/${transformId}`, transformJSON); }); +Cypress.Commands.add("createPipeline", (pipelineId, pipelineJSON) => { + cy.request("PUT", `${Cypress.env("opensearch")}/_ingest/pipeline/${pipelineId}`, pipelineJSON); +}); + Cypress.Commands.add("disableJitter", () => { // Sets the jitter to 0 in the ISM plugin cluster settings const jitterJson = { @@ -163,3 +175,39 @@ Cypress.Commands.add("disableJitter", () => { }; cy.request("PUT", `${Cypress.env("opensearch")}/_cluster/settings`, jitterJson); }); + +Cypress.Commands.add("addAlias", (alias, index) => { + cy.request({ + url: `${Cypress.env("opensearch")}/_aliases`, + method: "POST", + body: { + actions: [ + { + add: { + index, + alias, + }, + }, + ], + }, + failOnStatusCode: false, + }); +}); + +Cypress.Commands.add("removeAlias", (alias) => { + cy.request({ + url: `${Cypress.env("opensearch")}/_aliases`, + method: "POST", + body: { + actions: [ + { + remove: { + index: "*", + alias, + }, + }, + ], + }, + failOnStatusCode: false, + }); +}); diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index b662c58ae..0054853f6 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -104,5 +104,21 @@ declare namespace Cypress { * cy.disableJitter() */ disableJitter(): Chainable; + + /** + * Delete template + * @example + * cy.deleteTemplate("some_template") + */ + deleteTemplate(name: string); + + /** + * Create a ingest pipeline + * @example + * cy.createPipeline("pipelineId", {"description": "sample description", "processors": []}) + */ + createPipeline(pipelineId: string, pipeline: object); + addAlias(alias: string, index: string); + removeAlias(alias: string); } } diff --git a/models/interfaces.ts b/models/interfaces.ts index 136a900a2..5ab513986 100644 --- a/models/interfaces.ts +++ b/models/interfaces.ts @@ -23,12 +23,70 @@ export interface ManagedIndexMetaData { info?: object; } +export type MappingsPropertiesObject = Record< + string, + { + type: string; + properties?: MappingsPropertiesObject; + } +>; + +export type MappingsProperties = { + fieldName: string; + type: string; + path?: string; + analyzer?: string; + properties?: MappingsProperties; +}[]; + +export interface IndexItem { + index: string; + indexUuid?: string; + data_stream?: string | null; + settings?: { + index?: { + number_of_shards?: number; + number_of_replicas?: number; + creation_date?: string; + [key: string]: any; + }; + "index.number_of_shards"?: number; + "index.number_of_replicas"?: number; + "index.refresh_interval"?: string; + [key: string]: any; + }; + aliases?: Record; + mappings?: { + properties?: MappingsProperties; + [key: string]: any; + }; +} + +export interface IndexItemRemote extends Omit { + mappings?: { + properties?: MappingsPropertiesObject; + }; +} + +interface ITemplateExtras { + name: string; + data_stream?: {}; + version: number; + priority: number; + index_patterns: string[]; +} + +export interface TemplateItem extends ITemplateExtras { + template: Pick; +} +export interface TemplateItemRemote extends ITemplateExtras { + template: Pick; +} + /** * ManagedIndex item shown in the Managed Indices table */ -export interface ManagedIndexItem { - index: string; - indexUuid: string; +export interface ManagedIndexItem extends IndexItem { dataStream: string | null; policyId: string; policySeqNo: number; @@ -38,10 +96,6 @@ export interface ManagedIndexItem { managedIndexMetaData: ManagedIndexMetaData | null; } -export interface IndexItem { - index: string; -} - /** * Interface what the Policy Opensearch Document */ @@ -168,7 +222,7 @@ export interface SMDeleteCondition { export interface ErrorNotification { destination?: Destination; channel?: Channel; - message_template: MessageTemplate; + message_template?: MessageTemplate; } export interface Notification { @@ -564,3 +618,25 @@ export enum TRANSFORM_AGG_TYPE { histogram = "histogram", date_histogram = "date_histogram", } +export interface IAPICaller { + endpoint: string; + method?: string; + data?: any; +} + +export interface IRecoveryItem { + index: string; + stage: "done" | "translog"; +} + +export interface ITaskItem { + action: string; + description: string; +} + +export interface IReindexItem extends ITaskItem { + fromIndex: string; + toIndex: string; +} + +export type IAliasAction = Record; diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 4df0e3c89..e5cd8bf6d 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "2.5.0", "opensearchDashboardsVersion": "2.5.0", "configPath": ["opensearch_index_management"], - "requiredPlugins": ["navigation"], + "requiredPlugins": ["navigation", "opensearchDashboardsReact"], "server": true, "ui": true } diff --git a/package.json b/package.json index 241a963fc..69c0dfc37 100644 --- a/package.json +++ b/package.json @@ -52,14 +52,18 @@ "@elastic/elastic-eslint-config-kibana": "link:../../packages/opensearch-eslint-config-opensearch-dashboards", "@elastic/eslint-import-resolver-kibana": "link:../../packages/osd-eslint-import-resolver-opensearch-dashboards", "@testing-library/dom": "^8.11.3", + "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^13.1.9", + "@types/diff": "^5.0.2", + "@types/flat": "^5.0.2", "@types/react-dom": "^16.9.8", "@types/react-router-dom": "^5.3.2", "cypress": "^6.0.0", + "diff": "^4.0.1", "eslint-plugin-no-unsanitized": "^3.0.2", "eslint-plugin-prefer-object-spread": "^1.2.1", "husky": "^3.0.0", - "lint-staged": "^9.2.0", + "lint-staged": "^10.2.0", "ts-loader": "^6.2.1" }, "engines": { diff --git a/public/JobHandler/callbacks/reindex.tsx b/public/JobHandler/callbacks/reindex.tsx new file mode 100644 index 000000000..09bd4ba78 --- /dev/null +++ b/public/JobHandler/callbacks/reindex.tsx @@ -0,0 +1,122 @@ +import React, { ReactChild } from "react"; +import { CallbackType } from "../interface"; +import { ReindexJobMetaData } from "../../models/interfaces"; +import { CommonService } from "../../services"; +import { triggerEvent, EVENT_MAP } from "../utils"; +import { DetailLink } from "../components/DetailLink"; + +type TaskResult = { + found: boolean; + _source: { + completed: boolean; + response: { + failures: { + cause?: { + reason: string; + }; + }[]; + }; + error?: { + type: string; + reason: string; + }; + }; +}; + +export const callbackForReindex: CallbackType = async (job: ReindexJobMetaData, { core }) => { + const extras = job.extras; + const commonService = new CommonService(core.http); + const tasksResult = await commonService.apiCaller({ + endpoint: "transport.request", + data: { + path: `.tasks/_doc/${extras.taskId}`, + method: "GET", + }, + }); + if (tasksResult.ok) { + const { _source, found } = tasksResult.response; + const { completed, response, error } = (_source || {}) as TaskResult["_source"]; + const { failures } = response; + if (completed && found) { + if (!failures.length && !error?.reason) { + if (extras.toastId) { + core.notifications.toasts.remove(extras.toastId); + } + triggerEvent(EVENT_MAP.REINDEX_COMPLETE, job); + core.notifications.toasts.addSuccess( + { + title: (( + <> + Source {extras.sourceIndex} has been successfully reindexed as{" "} + + + ) as unknown) as string, + }, + { + toastLifeTimeMs: 1000 * 60 * 60 * 24 * 5, + } + ); + } else { + let errors: ReactChild[] = []; + if (failures.length) { + errors.push( +
    + {Array.from(new Set(failures.map((item) => item.cause?.reason).filter((item) => item))).map((item) => ( +
  • {item}
  • + ))} +
+ ); + } + + if (error?.reason) { + errors.push( +
    +
  • {error.reason}
  • +
+ ); + } + + if (extras.toastId) { + core.notifications.toasts.remove(extras.toastId); + } + core.notifications.toasts.addDanger( + { + title: (( + <> + Reindex from {extras.sourceIndex} to {extras.destIndex} has some errors, please check the errors below: + + ) as unknown) as string, + text: ((
{errors}
) as unknown) as string, + }, + { + toastLifeTimeMs: 1000 * 60 * 60 * 24 * 5, + } + ); + } + return true; + } + } + + return false; +}; + +export const callbackForReindexTimeout: CallbackType = (job: ReindexJobMetaData, { core }) => { + const extras = job.extras; + if (extras.toastId) { + core.notifications.toasts.remove(extras.toastId); + } + core.notifications.toasts.addDanger( + { + title: (( + <> + Reindex from {extras.sourceIndex} to {extras.destIndex} does not finish in reasonable time, please check the task {extras.taskId}{" "} + manually + + ) as unknown) as string, + }, + { + toastLifeTimeMs: 1000 * 60 * 60 * 24 * 5, + } + ); + return Promise.resolve(true); +}; diff --git a/public/JobHandler/callbacks/shrink.tsx b/public/JobHandler/callbacks/shrink.tsx new file mode 100644 index 000000000..41e5e94ce --- /dev/null +++ b/public/JobHandler/callbacks/shrink.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { CallbackType } from "../interface"; +import { RecoveryJobMetaData } from "../../models/interfaces"; +import { IndexService } from "../../services"; +import { triggerEvent, EVENT_MAP } from "../utils"; +import { DetailLink } from "../components/DetailLink"; + +export const callbackForShrink: CallbackType = async (job: RecoveryJobMetaData, { core }) => { + const extras = job.extras; + const indexService = new IndexService(core.http); + const indexResult = await indexService.getIndices({ + from: 0, + size: 10, + search: extras.destIndex, + terms: extras.destIndex, + sortField: "index", + sortDirection: "desc", + showDataStreams: false, + }); + if (indexResult.ok) { + const [firstItem] = indexResult.response.indices || []; + if (firstItem && firstItem.health !== "red") { + if (extras.toastId) { + core.notifications.toasts.remove(extras.toastId); + } + triggerEvent(EVENT_MAP.SHRINK_COMPLETE, job); + core.notifications.toasts.addSuccess( + { + title: (( + <> + Source index has been successfully shrunken as{" "} + . + + ) as unknown) as string, + }, + { + toastLifeTimeMs: 1000 * 60 * 60 * 24 * 5, + } + ); + return true; + } + } + + return false; +}; + +export const callbackForShrinkTimeout: CallbackType = (job: RecoveryJobMetaData, { core }) => { + const extras = job.extras; + if (extras.toastId) { + core.notifications.toasts.remove(extras.toastId); + } + core.notifications.toasts.addDanger( + { + title: (( + <> + Shrink to {extras.destIndex} does not finish in reasonable time, please check the index + manually. + + ) as unknown) as string, + }, + { + toastLifeTimeMs: 1000 * 60 * 60 * 24 * 5, + } + ); + return Promise.resolve(true); +}; diff --git a/public/JobHandler/callbacks/split.tsx b/public/JobHandler/callbacks/split.tsx new file mode 100644 index 000000000..5b24e68f6 --- /dev/null +++ b/public/JobHandler/callbacks/split.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { CallbackType } from "../interface"; +import { RecoveryJobMetaData } from "../../models/interfaces"; +import { IndexService } from "../../services"; +import { triggerEvent, EVENT_MAP } from "../utils"; +import { DetailLink } from "../components/DetailLink"; + +export const callbackForSplit: CallbackType = async (job: RecoveryJobMetaData, { core }) => { + const extras = job.extras; + const indexService = new IndexService(core.http); + const indexResult = await indexService.getIndices({ + from: 0, + size: 10, + search: extras.destIndex, + terms: extras.destIndex, + sortField: "index", + sortDirection: "desc", + showDataStreams: false, + }); + if (indexResult.ok) { + const [firstItem] = indexResult.response.indices || []; + if (firstItem && firstItem.health !== "red") { + if (extras.toastId) { + core.notifications.toasts.remove(extras.toastId); + } + triggerEvent(EVENT_MAP.SPLIT_COMPLETE, job); + core.notifications.toasts.addSuccess( + { + title: (( + <> + Source index has been successfully split as . + + ) as unknown) as string, + }, + { + toastLifeTimeMs: 1000 * 60 * 60 * 24 * 5, + } + ); + return true; + } + } + + return false; +}; + +export const callbackForSplitTimeout: CallbackType = (job: RecoveryJobMetaData, { core }) => { + const extras = job.extras; + if (extras.toastId) { + core.notifications.toasts.remove(extras.toastId); + } + core.notifications.toasts.addDanger( + { + title: (( + <> + Split to {extras.destIndex} does not finish in reasonable time, please check the index + manually + + ) as unknown) as string, + }, + { + toastLifeTimeMs: 1000 * 60 * 60 * 24 * 5, + } + ); + return Promise.resolve(true); +}; diff --git a/public/JobHandler/components/DetailLink.tsx b/public/JobHandler/components/DetailLink.tsx new file mode 100644 index 000000000..68d740c42 --- /dev/null +++ b/public/JobHandler/components/DetailLink.tsx @@ -0,0 +1,7 @@ +import React from "react"; +import { EuiLink } from "@elastic/eui"; +import { ROUTES } from "../../utils/constants"; + +export const DetailLink = (props: { index: string; writingIndex?: string }) => { + return {props.index}; +}; diff --git a/public/JobHandler/index.ts b/public/JobHandler/index.ts new file mode 100644 index 000000000..ce73d9781 --- /dev/null +++ b/public/JobHandler/index.ts @@ -0,0 +1,27 @@ +import { jobSchedulerInstance } from "../context/JobSchedulerContext"; +import { CoreSetup } from "../../../../src/core/public"; +import { callbackForReindex, callbackForReindexTimeout } from "./callbacks/reindex"; +import { callbackForSplit, callbackForSplitTimeout } from "./callbacks/split"; +import { callbackForShrink, callbackForShrinkTimeout } from "./callbacks/shrink"; +export { listenEvent, destroyListener, EVENT_MAP } from "./utils"; + +export function JobHandlerRegister(core: CoreSetup) { + jobSchedulerInstance.addCallback({ + callbackName: "callbackForReindex", + callback: (job) => callbackForReindex(job, { core }), + timeoutCallback: (job) => callbackForReindexTimeout(job, { core }), + listenType: "reindex", + }); + jobSchedulerInstance.addCallback({ + callbackName: "callbackForSplit", + callback: (job) => callbackForSplit(job, { core }), + timeoutCallback: (job) => callbackForSplitTimeout(job, { core }), + listenType: "split", + }); + jobSchedulerInstance.addCallback({ + callbackName: "callbackForShrink", + callback: (job) => callbackForShrink(job, { core }), + timeoutCallback: (job) => callbackForShrinkTimeout(job, { core }), + listenType: "shrink", + }); +} diff --git a/public/JobHandler/interface.ts b/public/JobHandler/interface.ts new file mode 100644 index 000000000..aa6ec6bed --- /dev/null +++ b/public/JobHandler/interface.ts @@ -0,0 +1,9 @@ +import { IJobItemMetadata } from "../lib/JobScheduler/interface"; +import { CoreSetup } from "../../../../src/core/public"; + +export type CallbackType = ( + jobData: IJobItemMetadata, + params: { + core: CoreSetup; + } +) => Promise; diff --git a/public/JobHandler/utils.ts b/public/JobHandler/utils.ts new file mode 100644 index 000000000..50a227f10 --- /dev/null +++ b/public/JobHandler/utils.ts @@ -0,0 +1,20 @@ +export const EVENT_MAP = { + REINDEX_COMPLETE: "REINDEX_COMPLETE", + SPLIT_COMPLETE: "SPLIT_COMPLETE", + SHRINK_COMPLETE: "SHRINK_COMPLETE", +}; + +export const triggerEvent = (eventName: string, data?: unknown) => { + const event = new CustomEvent(eventName, { + detail: data, + }); + window.dispatchEvent(event); +}; + +export const listenEvent = (eventName: string, callback: () => void) => { + window.addEventListener(eventName, callback); +}; + +export const destroyListener = (eventName: string, callback: () => void) => { + window.removeEventListener(eventName, callback); +}; diff --git a/public/components/AdvancedSettings/AdvancedSettings.test.tsx b/public/components/AdvancedSettings/AdvancedSettings.test.tsx new file mode 100644 index 000000000..037111627 --- /dev/null +++ b/public/components/AdvancedSettings/AdvancedSettings.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { fireEvent, render } from "@testing-library/react"; +import AdvancedSettings from "./index"; +import userEvent from "@testing-library/user-event"; + +describe(" spec", () => { + it("render the component", () => { + render(); + expect(document.body.children).toMatchSnapshot(); + }); + + it("do some actions with render props", async () => { + const onChangeMock = jest.fn(); + render( + ( + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+ +
+
+
+ The original value +
+
+
+
+
+
+
+ +
+
+
+ The value you modified +
+
+
+
+
+
+
, +] +`; diff --git a/public/components/SwitchableEditor/index.tsx b/public/components/SwitchableEditor/index.tsx new file mode 100644 index 000000000..8b0e14658 --- /dev/null +++ b/public/components/SwitchableEditor/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import SwitchableEditor from "./SwitchableEditor"; + +export default SwitchableEditor; +export * from "./SwitchableEditor"; diff --git a/public/components/Toast/Toast.test.tsx b/public/components/Toast/Toast.test.tsx new file mode 100644 index 000000000..543fe391f --- /dev/null +++ b/public/components/Toast/Toast.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { waitFor } from "@testing-library/react"; +import { act } from "react-dom/test-utils"; +import { SimpleEuiToast } from "./index"; + +describe("SimpleEuiToast show", () => { + it("render the component", async () => { + await act(async () => { + SimpleEuiToast.addSuccess("Success information"); + }); + expect(document.body).toMatchSnapshot(); + expect(document.querySelector('[data-test-subj="toast_Success information"]')).not.toBeNull(); + await act(async () => { + SimpleEuiToast.addDanger("Error information"); + }); + expect(document.querySelector('[data-test-subj="toast_Error information"]')).not.toBeNull(); + await act(async () => { + SimpleEuiToast.show({ + toastLifeTimeMs: 10, + title: "Test quick destroy", + }); + }); + await waitFor(() => { + expect(document.querySelector('[data-test-subj="toast_Test quick destroy"]')).toBeNull(); + }); + }); +}); diff --git a/public/components/Toast/__snapshots__/Toast.test.tsx.snap b/public/components/Toast/__snapshots__/Toast.test.tsx.snap new file mode 100644 index 000000000..9678911ce --- /dev/null +++ b/public/components/Toast/__snapshots__/Toast.test.tsx.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SimpleEuiToast show render the component 1`] = ` + +
+
+
+

+ A new notification appears +

+
+ + Success information + +
+ +
+
+
+ +`; diff --git a/public/components/Toast/index.tsx b/public/components/Toast/index.tsx new file mode 100644 index 000000000..fc0679f6e --- /dev/null +++ b/public/components/Toast/index.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from "react"; +import { render } from "react-dom"; +import { EuiGlobalToastList, EuiGlobalToastListProps } from "@elastic/eui"; + +export type SimpleEuiToastProps = EuiGlobalToastListProps["toasts"][number]; + +const TOAST_MOUNT_ID = "EUI_SIMPLE_TOAST_MOUNT_ID"; + +let addToastHandler: (params: SimpleEuiToastProps) => void; +let removeAllToastsHandler: () => void; +let id = 0; + +const SimpleToast = () => { + const [toasts, setToasts] = useState([]); + + addToastHandler = (toast) => { + setToasts(toasts.concat(toast)); + }; + + const removeToast: (params: SimpleEuiToastProps & { id: string }) => void = (removedToast) => { + setToasts(toasts.filter((toast) => toast.id !== removedToast.id)); + }; + + removeAllToastsHandler = () => { + setToasts([]); + }; + + useEffect(() => { + return () => { + removeAllToastsHandler(); + }; + }, []); + + return ; +}; + +export const SimpleEuiToast = { + show: (props: Partial & { title: SimpleEuiToastProps["title"] }) => { + let dom; + if (!document.getElementById(TOAST_MOUNT_ID)) { + dom = document.createElement("div"); + dom.id = TOAST_MOUNT_ID; + dom.setAttribute("data-role", "SimpleEuiToast"); + document.body.appendChild(dom); + render(, dom); + } else { + dom = document.getElementById(TOAST_MOUNT_ID); + } + addToastHandler({ + ...props, + "data-test-subj": `toast_${props.title}`, + id: `toast_${id++}`, + }); + }, + addSuccess: (message: SimpleEuiToastProps["text"]) => + SimpleEuiToast.show({ + title: message, + color: "success", + }), + addDanger: (message: SimpleEuiToastProps["text"]) => + SimpleEuiToast.show({ + title: message, + color: "danger", + }), +}; diff --git a/public/components/ToolTipWithoutWarning/index.tsx b/public/components/ToolTipWithoutWarning/index.tsx new file mode 100644 index 000000000..8626293bd --- /dev/null +++ b/public/components/ToolTipWithoutWarning/index.tsx @@ -0,0 +1,24 @@ +import { EuiToolTip } from "@elastic/eui"; + +/** + * The EuiToolTip has an issue when calling showToolTip / clearAnimationTimeout. + * It does not check if the component is still mounted. + * And it will give a warning in browser console and terminal when running unittest. + */ +export default class ToolTipWithoutWarning extends EuiToolTip { + protected newTimeoutId?: ReturnType; + showToolTip = () => { + if (!this.newTimeoutId) { + this.newTimeoutId = setTimeout(() => { + if (this._isMounted) { + this.setState({ visible: true }); + } + }, 0); + } + }; + clearAnimationTimeout = () => { + if (this.newTimeoutId) { + this.newTimeoutId = clearTimeout(this.newTimeoutId) as undefined; + } + }; +} diff --git a/public/components/UnsavedChangesBottomBar/UnsavedChangesBottomBar.test.tsx b/public/components/UnsavedChangesBottomBar/UnsavedChangesBottomBar.test.tsx new file mode 100644 index 000000000..cd47064be --- /dev/null +++ b/public/components/UnsavedChangesBottomBar/UnsavedChangesBottomBar.test.tsx @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { render } from "@testing-library/react"; +import UnsavedChangesBottomBar from "./index"; + +describe(" spec", () => { + it("render the component", () => { + render( {}} onClickSubmit={async () => {}} />); + expect(document.body.children).toMatchSnapshot(); + }); +}); diff --git a/public/components/UnsavedChangesBottomBar/UnsavedChangesBottomBar.tsx b/public/components/UnsavedChangesBottomBar/UnsavedChangesBottomBar.tsx new file mode 100644 index 000000000..3dab4ca98 --- /dev/null +++ b/public/components/UnsavedChangesBottomBar/UnsavedChangesBottomBar.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useRef, useState } from "react"; +import { EuiButton, EuiBottomBar, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from "@elastic/eui"; + +export type CustomFormRowProps = { + unsavedCount: number; + onClickCancel: () => Promise; + onClickSubmit: () => Promise; + submitButtonDataTestSubj?: string; +}; + +export default function CustomFormRow(props: CustomFormRowProps) { + const { unsavedCount, onClickCancel, onClickSubmit, submitButtonDataTestSubj } = props; + const [loading, setLoading] = useState(false); + const bottomBarRef = useRef(null); + const destroyRef = useRef(false); + const onClick = async () => { + setLoading(true); + try { + await onClickSubmit(); + } catch (e) { + } finally { + if (destroyRef.current) { + return; + } + setLoading(false); + } + }; + + useEffect(() => { + const bodyDom = document.querySelector("#opensearch-dashboards-body"); + let originalBodyPaddingBottom = ""; + if (bodyDom) { + originalBodyPaddingBottom = bodyDom.style.paddingBottom; + bodyDom.style.paddingBottom = "64px"; + } + + return () => { + destroyRef.current = true; + if (bodyDom) { + bodyDom.style.paddingBottom = originalBodyPaddingBottom; + } + }; + }, []); + + return ( + + + {unsavedCount} unsaved changes. + + + Cancel changes + + + + + Save changes + + + + + ); +} diff --git a/public/components/UnsavedChangesBottomBar/__snapshots__/UnsavedChangesBottomBar.test.tsx.snap b/public/components/UnsavedChangesBottomBar/__snapshots__/UnsavedChangesBottomBar.test.tsx.snap new file mode 100644 index 000000000..4fed4115e --- /dev/null +++ b/public/components/UnsavedChangesBottomBar/__snapshots__/UnsavedChangesBottomBar.test.tsx.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec render the component 1`] = ` +HTMLCollection [ +
, +
+
+

+ Page level controls +

+
+
+ 0 + unsaved changes. +
+
+ +
+
+ +
+
+
+

+ There is a new region landmark with page level controls at the end of the document. +

+
, +] +`; diff --git a/public/components/UnsavedChangesBottomBar/index.ts b/public/components/UnsavedChangesBottomBar/index.ts new file mode 100644 index 000000000..e9107336f --- /dev/null +++ b/public/components/UnsavedChangesBottomBar/index.ts @@ -0,0 +1,3 @@ +import UnsavedChangesBottomBar from "./UnsavedChangesBottomBar"; + +export default UnsavedChangesBottomBar; diff --git a/public/containers/ErrorNotification/ErrorNotification.test.tsx b/public/containers/ErrorNotification/ErrorNotification.test.tsx new file mode 100644 index 000000000..8d8ae4b0a --- /dev/null +++ b/public/containers/ErrorNotification/ErrorNotification.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render } from "@testing-library/react"; +import ErrorNotificationContainer, { ErrorNotificationProps } from "./ErrorNotification"; +import { ServicesContext } from "../../services"; +import { browserServicesMock, coreServicesMock } from "../../../test/mocks"; +import { ErrorNotification as IErrorNotification } from "../../../models/interfaces"; +import { CoreServicesContext } from "../../components/core_services"; + +const ErrorNotification = (props: Pick) => { + const [value, onChange] = useState(props.value); + return ( + + + + ); +}; + +function renderErrorNotification(errorNotification: IErrorNotification) { + return { + ...render( + + + + ), + }; +} + +describe(" spec", () => { + it("renders the component", () => { + const { container } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it("renders the channel ui editor for channels", () => { + const errorNotification = { channel: { id: "some_id" }, message_template: { source: "some source message" } }; + const { queryByTestId, queryByText } = renderErrorNotification(errorNotification); + + expect(queryByTestId("channel-notification-refresh")).not.toBeNull(); + expect(queryByText("Switch to using Channel ID")).toBeNull(); + }); + + it("renders the json legacy editor for destinations", () => { + const errorNotification = { destination: { slack: { url: "https://slack.com" } }, message_template: { source: "some source message" } }; + const { queryByTestId, queryByText } = renderErrorNotification(errorNotification); + + expect(queryByTestId("channel-notification-refresh")).toBeNull(); + expect(queryByText("Switch to using Channel ID")).not.toBeNull(); + }); +}); diff --git a/public/containers/ErrorNotification/ErrorNotification.tsx b/public/containers/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 000000000..a02b66475 --- /dev/null +++ b/public/containers/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,161 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { ChangeEvent, Component, useContext } from "react"; +import { EuiLink, EuiIcon, EuiFlexGroup, EuiFlexItem, EuiText } from "@elastic/eui"; +import { ContentPanel } from "../../components/ContentPanel"; +import "brace/theme/github"; +import "brace/mode/json"; +import { FeatureChannelList } from "../../../server/models/interfaces"; +import { BrowserServices } from "../../models/interfaces"; +import { ErrorNotification as IErrorNotification } from "../../../models/interfaces"; +import { ServicesContext } from "../../services"; +import { getErrorMessage } from "../../utils/helpers"; +import { CoreServicesContext } from "../../components/core_services"; +import ChannelNotification from "../../components/ChannelNotification"; +import LegacyNotification from "../../components/LegacyNotification"; +import { ERROR_NOTIFICATION_DOCUMENTATION_URL } from "../../utils/constants"; + +export interface ErrorNotificationProps { + value?: IErrorNotification; + onChange: (val: Required["value"]) => void; + onChangeChannelId?: (value: string) => void; + onChangeMessage?: (value: string) => void; + browserServices: BrowserServices; +} + +interface ErrorNotificationState { + channels: FeatureChannelList[]; + loadingChannels: boolean; +} + +class ErrorNotification extends Component { + static contextType = CoreServicesContext; + constructor(props: ErrorNotificationProps) { + super(props); + + this.state = { + channels: [], + loadingChannels: true, + }; + } + + componentDidMount = async (): Promise => { + await this.getChannels(); + }; + + getChannels = async (): Promise => { + this.setState({ loadingChannels: true }); + try { + const { notificationService } = this.props.browserServices; + const response = await notificationService.getChannels(); + if (response.ok) { + this.setState({ channels: response.response.channel_list }); + } else { + this.context.notifications.toasts.addDanger(`Could not load notification channels: ${response.error}`); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "Could not load the notification channels")); + } + this.setState({ loadingChannels: false }); + }; + + onChangeChannelId = (e: ChangeEvent) => { + const { onChange, value, onChangeChannelId } = this.props; + const id = e.target.value; + onChangeChannelId && onChangeChannelId(id); + onChange({ + ...value, + channel: { + id, + }, + }); + }; + + onChangeMessage = (e: ChangeEvent) => { + const { onChange, value, onChangeMessage } = this.props; + const message = e.target.value; + onChangeMessage && onChangeMessage(message); + onChange({ + ...value, + message_template: { + source: message, + }, + }); + }; + + onSwitchToChannels = () => { + const { onChange } = this.props; + onChange({ + channel: { + id: "", + }, + message_template: { + source: "", + }, + }); + }; + + render() { + const { value: errorNotification, onChange } = this.props; + const { channels, loadingChannels } = this.state; + const hasDestination = !!errorNotification?.destination; + + let content = ( + + ); + + // If we have a destination in the error notification then it's either an older policy or they created through the API + if (hasDestination) { + content = ; + } + + return ( + + + +

Error notification

+
+
+ + + - optional + + + + } + titleSize="s" + subTitleText={ + +

+ You can set up an error notification for when a policy execution fails.{" "} + + Learn more + +

+
+ } + > +
{content}
+
+ ); + } +} + +export default function ErrorNotificationContainer(props: Omit) { + const browserServices = useContext(ServicesContext) as BrowserServices; + return ; +} diff --git a/public/containers/ErrorNotification/__snapshots__/ErrorNotification.test.tsx.snap b/public/containers/ErrorNotification/__snapshots__/ErrorNotification.test.tsx.snap new file mode 100644 index 000000000..f24ff5faa --- /dev/null +++ b/public/containers/ErrorNotification/__snapshots__/ErrorNotification.test.tsx.snap @@ -0,0 +1,218 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+
+
+
+

+ Error notification +

+
+
+
+
+
+ + - optional + +
+
+
+
+
+
+

+ You can set up an error notification for when a policy execution fails. + + + Learn more + EuiIconMock + EuiIconMock + + (opens in a new tab or window) + + +

+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+ + EuiIconMock + +
+
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+ Embed variables in your message using Mustache template. +
+
+
+
+
+
+`; diff --git a/public/containers/ErrorNotification/index.ts b/public/containers/ErrorNotification/index.ts new file mode 100644 index 000000000..5e1409536 --- /dev/null +++ b/public/containers/ErrorNotification/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import ErrorNotification from "./ErrorNotification"; + +export default ErrorNotification; diff --git a/public/containers/IndexDetail/IndexDetail.test.tsx b/public/containers/IndexDetail/IndexDetail.test.tsx new file mode 100644 index 000000000..bc87da9a7 --- /dev/null +++ b/public/containers/IndexDetail/IndexDetail.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { render, waitFor } from "@testing-library/react"; +import IndexDetail, { IIndexDetailProps } from "./index"; +import { browserServicesMock, coreServicesMock } from "../../../test/mocks"; +import { ServicesContext } from "../../services"; +import { CoreServicesContext } from "../../components/core_services"; +import { CatIndex } from "../../../server/models/interfaces"; + +browserServicesMock.commonService.apiCaller = jest.fn( + async (payload): Promise => { + if (payload.data?.index?.includes("error_index")) { + return { + ok: false, + error: "error index", + }; + } + + return { + ok: true, + response: (payload.data.index || []).map( + (index: string): CatIndex => { + return { + index, + "docs.count": "0", + "docs.deleted": "1", + "pri.store.size": "1", + data_stream: "no", + "store.size": "1mb", + rep: "2", + uuid: "1", + health: "green", + pri: "4", + status: "open", + }; + } + ), + }; + } +); + +function renderWithServiceAndCore(props: IIndexDetailProps) { + return { + ...render( + + + + + + ), + }; +} + +describe(" spec", () => { + it("render the component", async () => { + const { container, queryByText } = renderWithServiceAndCore({ + indices: ["test"], + children: <>content underneath the table, + }); + + expect(queryByText("children content here")).toBeNull(); + await waitFor(() => { + expect(container).toMatchSnapshot(); + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(1); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "cat.indices", + data: { + index: ["test"], + format: "json", + }, + }); + }); + expect(queryByText("content underneath the table")).not.toBeNull(); + }); + + it("render with error", async () => { + const onGetIndicesDetailMock = jest.fn(); + renderWithServiceAndCore({ + indices: ["error_index"], + children: <>content underneath the table, + onGetIndicesDetail: onGetIndicesDetailMock, + }); + + await waitFor(() => { + expect(coreServicesMock.notifications.toasts.addDanger).toBeCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addDanger).toBeCalledWith("error index"); + expect(onGetIndicesDetailMock).toBeCalledTimes(1); + expect(onGetIndicesDetailMock).toBeCalledWith([]); + }); + }); +}); diff --git a/public/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap b/public/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap new file mode 100644 index 000000000..a38a36806 --- /dev/null +++ b/public/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec render the component 1`] = ` +
+
+
+
+

+ Source index details +

+
+
+
+
+
+
+
+
+
+`; diff --git a/public/containers/IndexDetail/index.tsx b/public/containers/IndexDetail/index.tsx new file mode 100644 index 000000000..b353d8573 --- /dev/null +++ b/public/containers/IndexDetail/index.tsx @@ -0,0 +1,70 @@ +import { EuiSpacer } from "@elastic/eui"; +import React, { useContext, useEffect, useState } from "react"; +import { CatIndex } from "../../../server/models/interfaces"; +import { ContentPanel } from "../../components/ContentPanel"; +import { CoreServicesContext } from "../../components/core_services"; +import { ServicesContext } from "../../services"; +import DescriptionListHoz from "../../components/DescriptionListHoz"; + +export interface IIndexDetailProps { + indices: string[]; + onGetIndicesDetail?: (args: CatIndex[]) => void; + children?: React.ReactChild; +} + +export default function IndexDetail(props: IIndexDetailProps) { + const [loading, setLoading] = useState(false); + const [items, setItems] = useState([] as CatIndex[]); + const services = useContext(ServicesContext); + const coreServices = useContext(CoreServicesContext); + useEffect(() => { + (async () => { + setLoading(true); + const result = await services?.commonService.apiCaller({ + endpoint: "cat.indices", + data: { + index: props.indices, + format: "json", + }, + }); + let finalResponse: CatIndex[] = []; + if (result?.ok) { + finalResponse = result.response; + } else { + coreServices?.notifications.toasts.addDanger(result?.error || ""); + } + setItems(finalResponse); + props.onGetIndicesDetail && props.onGetIndicesDetail(finalResponse); + setLoading(false); + })(); + }, [props.indices.join(","), setLoading, setItems, coreServices]); + return ( + + + {items && items.length ? ( + + ) : null} + + {loading ? null : props.children} + + ); +} diff --git a/public/context/JobSchedulerContext.tsx b/public/context/JobSchedulerContext.tsx new file mode 100644 index 000000000..73353dc78 --- /dev/null +++ b/public/context/JobSchedulerContext.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { JobScheduler } from "../lib/JobScheduler"; + +const jobSchedulerInstance = new JobScheduler({ + callbacks: [], +}); + +jobSchedulerInstance.init(); + +export { jobSchedulerInstance }; + +export const JobSchedulerContext = React.createContext(jobSchedulerInstance); diff --git a/public/index_management_app.tsx b/public/index_management_app.tsx index a7f684c43..f668540d2 100644 --- a/public/index_management_app.tsx +++ b/public/index_management_app.tsx @@ -16,6 +16,7 @@ import { NotificationService, ServicesContext, SnapshotManagementService, + CommonService, } from "./services"; import { DarkModeContext } from "./components/DarkMode"; import Main from "./pages/Main"; @@ -32,6 +33,7 @@ export function renderApp(coreStart: CoreStart, params: AppMountParameters, land const transformService = new TransformService(http); const notificationService = new NotificationService(http); const snapshotManagementService = new SnapshotManagementService(http); + const commonService = new CommonService(http); const services = { indexService, managedIndexService, @@ -40,6 +42,7 @@ export function renderApp(coreStart: CoreStart, params: AppMountParameters, land transformService, notificationService, snapshotManagementService, + commonService, }; const isDarkMode = coreStart.uiSettings.get("theme:darkMode") || false; diff --git a/public/lib/JobScheduler/JobScheduler.test.ts b/public/lib/JobScheduler/JobScheduler.test.ts new file mode 100644 index 000000000..7f76efcdd --- /dev/null +++ b/public/lib/JobScheduler/JobScheduler.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { waitFor } from "@testing-library/dom"; +import { JobScheduler } from "./JobScheduler"; + +describe("JobScheduler spec", () => { + it("basic usage", async () => { + const callback = jest.fn(async () => { + return false; + }); + const timeoutCallback = jest.fn(); + // setup job scheduler + const jobScheduler = new JobScheduler({ + callbacks: [ + { + callback, + callbackName: "test", + timeoutCallback, + }, + ], + }); + jobScheduler.init(); + + // add a job + const addedJob = await jobScheduler.addJob({ + interval: 1000, + timeout: 2500, + extras: {}, + type: "reindex", + }); + await jobScheduler.addJob(addedJob); + // if the same job was added, ignore that. + expect(jobScheduler.getAllJobs()).resolves.toHaveLength(1); + + // excute every second + await waitFor( + () => + new Promise(async (resolve, reject) => { + const result = await jobScheduler.getAllJobs(); + try { + expect(result).toHaveLength(0); + resolve(true); + } catch (e) { + reject(e); + } + }), + { + timeout: 10000, + } + ); + expect(callback).toBeCalledTimes(3); + + // setup a long timeout job + const testJob = await jobScheduler.addJob({ + interval: 1000, + type: "reindex", + extras: {}, + id: "test", + }); + expect(testJob.id).toEqual("test"); + await jobScheduler.changeJob(testJob.id, { + timeout: 2000, + }); + await new Promise((resolve) => setTimeout(resolve, 3000)); + // wait for 3s, and the job should be gone + expect(jobScheduler.getAllJobs()).resolves.toHaveLength(0); + + // add a callback + jobScheduler.addCallback({ + callbackName: "test1", + callback: async () => false, + timeoutCallback, + }); + + // delete the callback + jobScheduler.deleteCallback("test1"); + expect(jobScheduler.getAllCallbacks()).toHaveLength(1); + + // add a job + const testDeleteJob = await jobScheduler.addJob({ + interval: 1000, + type: "reindex", + extras: {}, + id: "testDeleteJob", + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + jobScheduler.deleteJob(testDeleteJob.id); + }, 30000); + + it("jobs when resume", async () => { + const callback = jest.fn(() => Promise.reject(false)); + const timeoutCallback = jest.fn(); + // setup job scheduler + const jobScheduler = new JobScheduler({ + callbacks: [ + { + callback, + callbackName: "test", + timeoutCallback, + }, + ], + }); + jobScheduler.addJob({ + createTime: Date.now() - 20 * 1000, + timeout: 2000, + interval: 1000, + type: "reindex", + extras: {}, + }); + jobScheduler.init(); + await new Promise((resolve) => setTimeout(resolve, 3000)); + expect(jobScheduler.getAllJobs()).resolves.toHaveLength(0); + expect(callback).toBeCalledTimes(1); + expect(timeoutCallback).toBeCalledTimes(1); + const result = await jobScheduler.changeJob("1", {}); + expect(result).toBe(false); + }); +}); diff --git a/public/lib/JobScheduler/JobScheduler.ts b/public/lib/JobScheduler/JobScheduler.ts new file mode 100644 index 000000000..4711afb68 --- /dev/null +++ b/public/lib/JobScheduler/JobScheduler.ts @@ -0,0 +1,157 @@ +import { IJobSchedulerOptions, IJobItemMetadata, IStorage, JobItemMetadata, TimeoutId } from "./interface"; +import { StoreLocalStorage } from "./store-localstorage"; + +export class JobScheduler { + private options: IJobSchedulerOptions; + // key: jobId value: timerid + private runningJobMap: Record = {}; + private storage: IStorage; + constructor(options: IJobSchedulerOptions) { + this.options = options; + this.storage = options.storage || new StoreLocalStorage(); + } + async init(): Promise { + this.loopJob(); + return true; + } + private getId() { + return `${Date.now()}_${Math.floor(Math.random() * 10)}`; + } + private formatJob(job: IJobItemMetadata): JobItemMetadata { + const formattedJob = { ...job }; + if (!formattedJob.id) { + formattedJob.id = this.getId(); + } + + if (!formattedJob.createTime) { + formattedJob.createTime = Date.now(); + } + + if (!formattedJob.timeout) { + formattedJob.timeout = 1000 * 60 * 60 * 24; + } + + return formattedJob as JobItemMetadata; + } + private isStaledJob(job: JobItemMetadata) { + if (!job.latestRunTime) { + // haven't run once, return false + return false; + } + + return job.timeout + job.createTime < job.latestRunTime; + } + private async loopJob() { + const jobs = await this.storage.getAll(); + // loop all the jobs to see if any job do not exist in runningJobMap + jobs.forEach(async (job) => { + // if a job is staled, remove that + if (this.isStaledJob(job)) { + await this.runStaledJob(job.id); + await this.deleteJob(job.id); + return; + } + + if (!this.runningJobMap[job.id]) { + const timeoutCallback = setTimeout(async () => { + if (!this.isStaledJob(job)) { + this.runJob(job.id); + } else { + await this.runStaledJob(job.id); + await this.deleteJob(job.id); + } + }, job.interval); + this.runningJobMap[job.id] = timeoutCallback; + } + }); + } + private async runStaledJob(jobId: JobItemMetadata["id"]): Promise { + const job = await this.getJob(jobId); + if (!job) { + return undefined; + } + const filteredCallbacks = this.options.callbacks.filter( + (callbackItem) => callbackItem.listenType === job.type || !callbackItem.listenType + ); + await Promise.all( + filteredCallbacks.map(async (callbackItem) => { + try { + return callbackItem.timeoutCallback(job); + } catch (e) { + return false; + } + }) + ); + } + private async runJob(jobId: JobItemMetadata["id"]): Promise { + const job = await this.getJob(jobId); + if (!job) { + return undefined; + } + const filteredCallbacks = this.options.callbacks.filter( + (callbackItem) => callbackItem.listenType === job.type || !callbackItem.listenType + ); + job.latestRunTime = Date.now(); + const result = await Promise.all( + filteredCallbacks.map(async (callbackItem) => { + try { + return await callbackItem.callback(job); + } catch (e) { + return false; + } + }) + ); + const hasFinish = result.some((res) => res === true); + await this.deleteJob(job.id); + if (!hasFinish) { + await this.addJob(job); + } + } + addCallback(callback: IJobSchedulerOptions["callbacks"][number]) { + this.options.callbacks.push(callback); + } + deleteCallback(callbackName: string) { + const findIndex = this.options.callbacks.findIndex((item) => item.callbackName === callbackName); + if (findIndex > -1) { + this.options.callbacks.splice(findIndex, 1); + } + } + getAllCallbacks() { + return this.options.callbacks; + } + async addJob(job: IJobItemMetadata): Promise { + const formattedJob = this.formatJob(job); + if (this.runningJobMap[formattedJob.id]) { + return formattedJob; + } + + await this.storage.set(formattedJob.id, formattedJob); + this.loopJob(); + return formattedJob; + } + async deleteJob(jobId: JobItemMetadata["id"]): Promise { + clearTimeout(this.runningJobMap[jobId]); + delete this.runningJobMap[jobId]; + const storageResult = await this.storage.delete(jobId); + return storageResult || true; + } + getJob(jobId: JobItemMetadata["id"]): Promise { + return this.storage.get(jobId); + } + getAllJobs(): Promise { + return this.storage.getAll(); + } + async changeJob(jobId: JobItemMetadata["id"], jobMeta: Partial>): Promise { + const nowJob = await this.getJob(jobId); + + if (!nowJob) { + return false; + } + + return this.storage.set(jobId, { + ...nowJob, + ...jobMeta, + id: jobId, + }); + } +} diff --git a/public/lib/JobScheduler/index.ts b/public/lib/JobScheduler/index.ts new file mode 100644 index 000000000..86f934b50 --- /dev/null +++ b/public/lib/JobScheduler/index.ts @@ -0,0 +1,2 @@ +export * from "./interface"; +export { JobScheduler } from "./JobScheduler"; diff --git a/public/lib/JobScheduler/interface.ts b/public/lib/JobScheduler/interface.ts new file mode 100644 index 000000000..9e907fee2 --- /dev/null +++ b/public/lib/JobScheduler/interface.ts @@ -0,0 +1,33 @@ +export interface IJobItemMetadata { + interval: number; + extras: any; // extra fields to store job-related info + type: "reindex" | "split" | "shrink"; // enum for job type + id?: string; // a number to indicate the job + createTime?: number; // the time when this job is created + latestRunTime?: number; // the time when the job latest run, will be used to check if the job is staled + // the timeout for job to do, once the time goes beyond the timeout + // a timeout error toast will show. + timeout?: number; +} + +export type JobItemMetadata = IJobItemMetadata & Required>; + +export interface IJobSchedulerOptions { + callbacks: { + listenType?: IJobItemMetadata["type"]; + callback: (params: IJobItemMetadata) => Promise; + timeoutCallback: (params: IJobItemMetadata) => void; + callbackName: string; + }[]; + storage?: IStorage; +} + +export interface IStorage { + setup(): Promise; + getAll(): Promise; + set(key: string, value: JobItemMetadata): Promise; + get(key: string): Promise; + delete(key: string): Promise; +} + +export type TimeoutId = ReturnType; diff --git a/public/lib/JobScheduler/store-localstorage.ts b/public/lib/JobScheduler/store-localstorage.ts new file mode 100644 index 000000000..cdb69a4b7 --- /dev/null +++ b/public/lib/JobScheduler/store-localstorage.ts @@ -0,0 +1,44 @@ +import { IStorage, JobItemMetadata } from "./interface"; + +const JOB_STORAGE_KEY = "ISM_JOBS"; + +export class StoreLocalStorage implements IStorage { + async setup(): Promise { + // do nothing + return true; + } + async getAll(): Promise { + return JSON.parse(localStorage.getItem(JOB_STORAGE_KEY) || "[]"); + } + async set(key: string, value: JobItemMetadata): Promise { + try { + const result = await this.getAll(); + const findIndex = result.findIndex((item) => item.id === key); + if (findIndex > -1) { + result[findIndex] = value; + } else { + result.push(value); + } + this.saveToDisk(result); + return true; + } catch (e) { + return false; + } + } + async get(key: string): Promise { + const all = await this.getAll(); + return all.find((item) => item.id === key); + } + async delete(key: string): Promise { + const result = await this.getAll(); + return this.saveToDisk(result.filter((item) => item.id !== key)); + } + private saveToDisk(payload: JobItemMetadata[]) { + try { + localStorage.setItem(JOB_STORAGE_KEY, JSON.stringify(payload)); + return true; + } catch (e) { + return false; + } + } +} diff --git a/public/lib/field/index.tsx b/public/lib/field/index.tsx new file mode 100644 index 000000000..6e0384e43 --- /dev/null +++ b/public/lib/field/index.tsx @@ -0,0 +1,186 @@ +import React, { useEffect, useRef, useState } from "react"; +import { set, get, unset } from "lodash"; +import { Rule, FieldOption, FieldInstance, InitOption, InitResult, ValidateFunction, FieldName } from "./interfaces"; +import buildInRules from "./rules"; +import { getOrderedJson } from "../../../utils/helper"; +import { diffJson } from "../../utils/helpers"; + +export function transformNameToString(name: FieldName) { + if (Array.isArray(name)) { + return name.join("."); + } else { + return name; + } +} + +export default function useField(options?: FieldOption): FieldInstance { + const [, setValuesState] = useState((options?.values || {}) as Record); + const [, setErrorsState] = useState({} as Record); + const destroyRef = useRef(false); + const values = useRef>(options?.values || {}); + const errors = useRef>({}); + const originalValuesRef = useRef>(options?.originalValues || {}); + const fieldsMapRef = useRef>({}); + const setValues = (obj: Record) => { + if (destroyRef.current) { + return; + } + values.current = { + ...values.current, + ...obj, + }; + setValuesState(values.current); + }; + const resetValues = (obj: Record) => { + if (destroyRef.current) { + return; + } + values.current = obj; + setValuesState(values.current); + }; + const setValue: FieldInstance["setValue"] = (name: FieldName, value) => { + const payload = { ...values.current }; + if (!Array.isArray(name)) { + name = [name]; + } + set(payload, name, value); + setValues(payload); + }; + const setErrors: FieldInstance["setErrors"] = (errs) => { + if (destroyRef.current) { + return; + } + errors.current = errs; + setErrorsState(errors.current); + }; + const setError: FieldInstance["setError"] = (name, error) => { + setErrors({ + ...errors.current, + [transformNameToString(name)]: error, + }); + }; + const validateField = async (name: FieldName) => { + const fieldOptions = fieldsMapRef.current[transformNameToString(name)]; + const rules: Rule[] = fieldOptions.rules || []; + const result = await Promise.all( + rules.map(async (item) => { + let validateFunction: ValidateFunction = () => undefined; + if (item.validator) { + validateFunction = item.validator; + } else if (item.required) { + validateFunction = buildInRules.required; + } else if (item.format) { + validateFunction = buildInRules.format; + } else if (typeof item.min === "number" || typeof item.max === "number") { + validateFunction = buildInRules.size; + } else if (item.pattern) { + validateFunction = buildInRules.pattern; + } + + let errorInfo = null; + try { + const result = validateFunction( + { + ...item, + field: transformNameToString(name), + }, + get(values.current, name) + ); + if (result && (result as Promise).then) { + await result; + } else { + errorInfo = result; + } + } catch (e) { + errorInfo = e || item.message; + } + + return errorInfo; + }) + ); + const fieldErrors = result.filter((item) => item) as string[]; + + return fieldErrors; + }; + useEffect(() => { + return () => { + destroyRef.current = true; + }; + }, []); + const refCallbacks = useRef>>({}); + return { + registerField: (initOptions: InitOption): InitResult => { + const fieldName = transformNameToString(initOptions.name); + fieldsMapRef.current[fieldName] = initOptions; + const payload: InitResult = { + ...initOptions.props, + value: get(values.current, initOptions.name), + onChange: async (val) => { + setValue(initOptions.name, val); + options?.onChange && options?.onChange(initOptions.name, val); + const validateErros = await validateField(initOptions.name); + setError(initOptions.name, validateErros.length ? validateErros : null); + }, + }; + if (options?.unmountComponent) { + if (!refCallbacks.current[fieldName]) { + refCallbacks.current[fieldName] = (ref: any) => { + if (!ref) { + delete fieldsMapRef.current[fieldName]; + delete refCallbacks.current[fieldName]; + } + }; + } + payload.ref = refCallbacks.current[fieldName] as React.RefCallback; + } + return payload; + }, + setValue, + setValues, + getValue: (name) => get(values.current, name), + getValues: () => values.current, + getError: (name) => errors.current[transformNameToString(name)], + getErrors: () => errors.current, + validatePromise: async () => { + const result = await Promise.all( + Object.values(fieldsMapRef.current).map(({ name }) => { + return validateField(name).then((res) => { + if (res.length) { + return { + [transformNameToString(name)]: res, + }; + } + + return null; + }); + }) + ); + const resultArray = result.filter((item) => item) as Record[]; + const resultPayload = resultArray.reduce((total, current) => ({ ...total, ...current }), {} as Record); + setErrors(resultPayload); + return { + errors: resultArray.length ? resultPayload : null, + values: values.current, + }; + }, + setError, + setErrors, + resetValues, + deleteValue: (key) => { + const newValues = { ...values.current }; + unset(newValues, key); + resetValues(newValues); + }, + setOriginalValues: (obj) => { + originalValuesRef.current = obj; + }, + getOriginalValues: () => originalValuesRef.current, + computeDifference: () => { + const originalValues = getOrderedJson(originalValuesRef.current); + const currentValues = getOrderedJson(values.current); + return diffJson(originalValues, currentValues); + }, + }; +} + +export * from "./interfaces"; diff --git a/public/lib/field/interfaces.ts b/public/lib/field/interfaces.ts new file mode 100644 index 000000000..244de3a06 --- /dev/null +++ b/public/lib/field/interfaces.ts @@ -0,0 +1,202 @@ +import React from "react"; + +// if it's a string[], the value will become nested. +// registerField({ name: ['a', 'b', 'c.d'] }) => { a: { b: { c,d: '' } } } +export type FieldName = string | string[]; + +export type FieldOption = { + /** + * All component changes will arrive here [set value will not trigger this function] + */ + onChange?: (name: FieldName, value?: any) => void; + + /** + * Initialization data + */ + values?: {}; + + /** + * OriginalValues + */ + originalValues?: {}; + + unmountComponent?: boolean; +}; + +export type ValidateResults = { + errors: Record | null; + values: any; +}; + +export type InitResult = { + value?: T; + onChange(value: T): void; + ref?: React.RefCallback; +}; + +export type Rule = { + /** + * cannot be empty (cannot be used with pattern) + * @default true + */ + required?: boolean; + + /** + * error message + */ + message?: string; + + /** + * Check Regular Expression + */ + pattern?: RegExp; + /** + * Minimum string length /minimum number of arrays + */ + minLength?: number; + /** + * Maximum string length /maximum number of arrays + */ + maxLength?: number; + + /** + * String exact length /array exact number + */ + length?: number; + + /** + * minimum + */ + min?: number; + + /** + * maximum value + */ + max?: number; + /** + * Summary of common patterns + */ + format?: "url" | "email" | "tel" | "number"; + + /** + * Custom verification, (don't forget to execute callback() when the verification is successful, otherwise the verification will not return) + */ + validator?: (rule: Rule, value: string | number | object | boolean | Date | null | any) => string | Promise; + + /** + * The name of the event that triggered the validation + */ + trigger?: "onChange" | "onBlur" | string; +}; + +export type InitOption = { + /** + * The name of the field + */ + name: FieldName; + + /** + * The name of the event that triggered the data change + * @default 'onChange' + */ + trigger?: string | "onChange" | "onBlur"; + + /** + * Check rules + */ + rules?: Rule[]; + + /** + * Component custom events can be written here, others will be transparently transmitted (small package version ^0.3.0 support, large package ^0.7.0 support) + */ + props?: any; +}; + +export type FieldInstance = { + /** + * Initialize each component + */ + registerField(option?: InitOption): InitResult; + + /** + * check + * @param name + */ + validatePromise(name?: FieldName): Promise; + + /** + * Get the value of a single input control + * @param field name + */ + getValue(name: FieldName): any; + + /** + * Get the values ​​of a set of input controls, if no parameters are passed in, get the values ​​of all components + * @param names + */ + getValues(): any; + + /** + * Get the values ​​of a set of input controls, if no parameters are passed in, get the values ​​of all components + * @param names + */ + getOriginalValues(): any; + + /** + * Set the value of a single input control (will trigger render, please follow the timing of react) + */ + setValue(name: FieldName, value: any): void; + + /** + * Set the value of a set of input controls (will trigger render, please follow the timing of react) + */ + setValues(obj: any): void; + + /** + * + */ + setOriginalValues(obj: any): void; + + /** + * Reset values + */ + resetValues(obj: any): void; + + /** + * Delete value + */ + deleteValue(key: FieldName): void; + + /** + * Get the Error of a single input control + */ + getError(name: FieldName): null | string[]; + + /** + * Get the Error for a set of input controls + * @param names field name + */ + getErrors(): any; + + /** + * Sets the Error for a single input control + * @param name + * @param errors + */ + setError(name: FieldName, errors: null | string[]): void; + + /** + * Sets the Error for a set of input controls + */ + setErrors(obj: any): void; + + /** + * Get difference between originalValues & currentValues + */ + computeDifference(): number; +}; + +export type ValidateFunction = ( + rule: Rule & { field: string; aliasName?: string }, + value: string | number | object | boolean | Date +) => string | Promise | undefined; diff --git a/public/lib/field/messages.ts b/public/lib/field/messages.ts new file mode 100644 index 000000000..df8e34dc4 --- /dev/null +++ b/public/lib/field/messages.ts @@ -0,0 +1,30 @@ +export default { + default: "%s verification failed", + required: "%s is a required field", + format: { + number: "%s is not a legal number", + email: "%s is not a valid email address", + url: "%s is not a valid URL address", + tel: "%s is not a valid phone number", + }, + number: { + length: "%s length must be %s", + min: "%s field value must not be less than %s", + max: "%s field value must not be greater than %s", + minLength: "%s field character length must be at least %s", + maxLength: "%s field character length cannot exceed %s", + }, + string: { + length: "%s length must be %s", + min: "%s field value must not be less than %s", + max: "%s field value must not be greater than %s", + minLength: "%s field character length must be at least %s", + maxLength: "%s field character length cannot exceed %s", + }, + array: { + length: "%s length must be %s", + minLength: "%s must not be less than %s", + maxLength: "%s must not exceed %s", + }, + pattern: "%s field value %s does not match the regular %s", +}; diff --git a/public/lib/field/rules/index.ts b/public/lib/field/rules/index.ts new file mode 100644 index 000000000..9ca515d6d --- /dev/null +++ b/public/lib/field/rules/index.ts @@ -0,0 +1,90 @@ +import { ValidateFunction } from "../interfaces"; +import messages from "../messages"; +import { format as messageFormat } from "../util"; + +const pattern = { + email: /[\w\u4E00-\u9FA5]+([-+.][\w\u4E00-\u9FA5]+)*@[\w\u4E00-\u9FA5]+([-.][\w\u4E00-\u9FA5]+)*\.[\w\u4E00-\u9FA5]+([-.][\w\u4E00-\u9FA5]+)*/, + url: /^(?:(?:http|https|ftp):\/\/|\/\/)(?:(?:(?:[-\w\u00a1-\uffff]+)(?:\.[-\w\u00a1-\uffff]+)+|localhost)(?::\d{2,5})?(?:(?:\/|#)[^\s]*)?)$/, + number: /\d*/, + tel: /^(1\d{10})$|(((400)-(\d{3})-(\d{4}))|^((\d{7,8})|(\d{3,4})-(\d{7,8})|(\d{7,8})-(\d{1,4}))$)$|^([ ]?)$/, +}; + +const types = { + number(value: any) { + if (isNaN(value)) { + return false; + } + return typeof value === "number" || (typeof value === "string" && !!value.match(pattern.number)); + }, + email(value: any) { + return typeof value === "string" && !!value.match(pattern.email) && value.length < 255; + }, + url(value: any) { + return typeof value === "string" && !!value.match(pattern.url); + }, + tel(value: any) { + return typeof value === "string" && !!value.match(pattern.tel); + }, +}; + +const rules = { + required: (rule, value) => { + if (value === undefined || value === null || value === "" || value.length === 0) { + return messageFormat(rule.message || messages.required, rule.aliasName || rule.field); + } + }, + format: (rule, value) => { + const custom = ["email", "number", "url", "tel"]; + const ruleType = rule.format; + if (ruleType && custom.indexOf(ruleType) > -1 && !types[ruleType](value)) { + return messageFormat(rule.message || messages.format[ruleType], rule.aliasName || rule.field, ruleType); + } + }, + size: (rule, value) => { + let key: "number" | "string" | null = null; + const isNum = typeof value === "number"; + const isStr = typeof value === "string"; + + if (isNum) { + key = "number"; + } else if (isStr) { + key = "string"; + } + + if (!key) { + return false; + } + + if (typeof rule.min === "number" || typeof rule.max === "number") { + let val = value; + const max = Number(rule.max); + const min = Number(rule.min); + + if (isStr) { + val = Number(val); + } + + if (val < min) { + return messageFormat(rule.message || messages[key].min, rule.aliasName || rule.field, "" + rule.min); + } else if (val > max) { + return messageFormat(rule.message || messages[key].max, rule.aliasName || rule.field, "" + rule.max); + } + } + }, + pattern: (rule, value: string) => { + if (rule.pattern) { + if (rule.pattern instanceof RegExp) { + if (!rule.pattern.test(value || "")) { + return messageFormat(rule.message || messages.pattern, rule.aliasName || rule.field, value, rule.pattern.toString()); + } + } else if (typeof rule.pattern === "string") { + const _pattern = new RegExp(rule.pattern); + if (!_pattern.test(value || "")) { + return messageFormat(rule.message || messages.pattern, rule.aliasName || rule.field, value, rule.pattern); + } + } + } + }, +} as Record; + +export default rules as Record; diff --git a/public/lib/field/util.ts b/public/lib/field/util.ts new file mode 100644 index 000000000..6511e2160 --- /dev/null +++ b/public/lib/field/util.ts @@ -0,0 +1,34 @@ +const formatRegExp = /%[sdj%]/g; + +export function format(...args: string[]) { + let i = 1; + const f = args[0]; + const len = args.length; + if (typeof f === "string") { + const str = String(f).replace(formatRegExp, (x) => { + if (x === "%%") { + return "%"; + } + if (i >= len) { + return x; + } + switch (x) { + case "%s": + return String(args[i++]); + case "%d": + return `${Number(args[i++])}`; + case "%j": + try { + return JSON.stringify(args[i++]); + } catch (_) { + return "[Circular]"; + } + default: + return x; + } + }); + + return str; + } + return f; +} diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts index 7a8fb9784..443a0dfff 100644 --- a/public/models/interfaces.ts +++ b/public/models/interfaces.ts @@ -5,6 +5,7 @@ import { Direction, Query } from "@elastic/eui"; import { SMPolicy } from "../../models/interfaces"; +import { IJobItemMetadata } from "../lib/JobScheduler"; import { IndexService, ManagedIndexService, @@ -13,6 +14,7 @@ import { TransformService, NotificationService, SnapshotManagementService, + CommonService, } from "../services"; export interface BrowserServices { @@ -23,6 +25,7 @@ export interface BrowserServices { transformService: TransformService; notificationService: NotificationService; snapshotManagementService: SnapshotManagementService; + commonService: CommonService; } export interface SMPoliciesQueryParams { @@ -60,7 +63,6 @@ export interface Column { sortable: boolean; } - export interface RestoreError { reason?: string; type?: string; @@ -108,3 +110,21 @@ export interface IndexItem { index: string; restore_status?: string; } +export interface ReindexJobMetaData extends IJobItemMetadata { + extras: { + toastId: string; + sourceIndex: string; + destIndex: string; + writingIndex: string; + taskId: string; + destType: "index" | "other"; + }; +} + +export interface RecoveryJobMetaData extends IJobItemMetadata { + extras: { + toastId: string; + sourceIndex: string; + destIndex: string; + }; +} diff --git a/public/pages/Aliases/components/IndexControls/IndexControls.test.tsx b/public/pages/Aliases/components/IndexControls/IndexControls.test.tsx new file mode 100644 index 000000000..9ed775959 --- /dev/null +++ b/public/pages/Aliases/components/IndexControls/IndexControls.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, waitFor } from "@testing-library/react"; +// @ts-ignore +import userEvent from "@testing-library/user-event"; +import IndexControls from "./IndexControls"; + +describe(" spec", () => { + it("renders the component", async () => { + const { container } = render( {}} />); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it("onChange with right data", async () => { + const onSearchChangeMock = jest.fn(); + const { getByTestId, getByPlaceholderText } = render( + + ); + + userEvent.type(getByTestId("comboBoxSearchInput"), "closed{enter}"); + expect(onSearchChangeMock).toBeCalledTimes(1); + expect(onSearchChangeMock).toBeCalledWith({ + search: "", + status: "closed", + }); + userEvent.type(getByPlaceholderText("Search..."), "test"); + await waitFor(() => { + expect(onSearchChangeMock).toBeCalledTimes(5); + expect(onSearchChangeMock).toBeCalledWith({ + search: "test", + status: "closed", + }); + }); + }); +}); diff --git a/public/pages/Aliases/components/IndexControls/IndexControls.tsx b/public/pages/Aliases/components/IndexControls/IndexControls.tsx new file mode 100644 index 000000000..4487b0700 --- /dev/null +++ b/public/pages/Aliases/components/IndexControls/IndexControls.tsx @@ -0,0 +1,49 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from "react"; +import { EuiComboBox, EuiFieldSearch, EuiFlexGroup, EuiFlexItem } from "@elastic/eui"; +import { ALIAS_STATUS_OPTIONS } from "../../../../utils/constants"; + +export interface SearchControlsProps { + value: { + search: string; + status: string; + }; + onSearchChange: (args: SearchControlsProps["value"]) => void; +} + +export default function SearchControls(props: SearchControlsProps) { + const [state, setState] = useState(props.value); + const onChange = (field: T, value: SearchControlsProps["value"][T]) => { + const payload = { + ...state, + [field]: value, + }; + setState(payload); + props.onSearchChange(payload); + }; + useEffect(() => { + setState(props.value); + }, [props.value]); + return ( + + + onChange("search", e.target.value)} /> + + + ({ label })) : []} + onChange={(val) => onChange("status", (val || []).map((item) => item.label).join(","))} + /> + + + ); +} diff --git a/public/pages/Aliases/components/IndexControls/__snapshots__/IndexControls.test.tsx.snap b/public/pages/Aliases/components/IndexControls/__snapshots__/IndexControls.test.tsx.snap new file mode 100644 index 000000000..e2b013133 --- /dev/null +++ b/public/pages/Aliases/components/IndexControls/__snapshots__/IndexControls.test.tsx.snap @@ -0,0 +1,130 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+
+ +
+ + EuiIconMock + +
+
+ +
+
+
+
+
+ +
+`; diff --git a/public/pages/Aliases/components/IndexControls/index.ts b/public/pages/Aliases/components/IndexControls/index.ts new file mode 100644 index 000000000..993ddb52b --- /dev/null +++ b/public/pages/Aliases/components/IndexControls/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import IndexControls from "./IndexControls"; + +export * from "./IndexControls"; +export default IndexControls; diff --git a/public/pages/Aliases/containers/AliasActions/AliasActions.test.tsx b/public/pages/Aliases/containers/AliasActions/AliasActions.test.tsx new file mode 100644 index 000000000..8739999b1 --- /dev/null +++ b/public/pages/Aliases/containers/AliasActions/AliasActions.test.tsx @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, waitFor } from "@testing-library/react"; +// @ts-ignore +import userEvent from "@testing-library/user-event"; +import { browserServicesMock, coreServicesMock } from "../../../../../test/mocks"; +import AliasesActions, { AliasesActionsProps } from "./index"; +import { ModalProvider } from "../../../../components/Modal"; +import { ServicesContext } from "../../../../services"; +import { CoreServicesContext } from "../../../../components/core_services"; + +function renderWithRouter(props: AliasesActionsProps) { + return { + ...render( + + + + + + + + ), + }; +} + +describe(" spec", () => { + it("renders the component and all the actions should be disabled when no items selected", async () => { + const { container, getByTestId } = renderWithRouter({ + selectedItems: [], + onUpdateAlias: () => null, + onDelete: () => null, + }); + + await waitFor(() => { + expect(container.firstChild).toMatchSnapshot(); + }); + + userEvent.click(document.querySelector('[data-test-subj="moreAction"] button') as Element); + await waitFor(() => { + expect(getByTestId("deleteAction")).toBeDisabled(); + }); + }); + + it("delete alias by calling commonService", async () => { + const onDelete = jest.fn(); + let times = 0; + browserServicesMock.commonService.apiCaller = jest.fn( + async (payload): Promise => { + if (payload.endpoint === "indices.deleteAlias") { + if (times >= 1) { + return { + ok: true, + response: {}, + }; + } else { + times++; + return { + ok: false, + error: "test error", + }; + } + } + return { ok: true, response: {} }; + } + ); + const { container, getByTestId, getByPlaceholderText } = renderWithRouter({ + selectedItems: [ + { + index: "test_index", + alias: "1", + filter: "1", + "routing.index": "1", + "routing.search": "1", + is_write_index: "1", + indexArray: ["test_index"], + }, + ], + onUpdateAlias: () => null, + onDelete, + }); + + await waitFor(() => { + expect(container.firstChild).toMatchSnapshot(); + }); + + userEvent.click(document.querySelector('[data-test-subj="moreAction"] button') as Element); + userEvent.click(getByTestId("deleteAction")); + userEvent.type(getByPlaceholderText("delete"), "delete"); + userEvent.click(getByTestId("deleteConfirmButton")); + + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledTimes(1); + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledWith({ + endpoint: "indices.deleteAlias", + data: { + index: "_all", + name: ["1"], + }, + }); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledWith("test error"); + expect(onDelete).toHaveBeenCalledTimes(0); + }); + + userEvent.click(getByTestId("deleteConfirmButton")); + + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledTimes(2); + expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addSuccess).toHaveBeenCalledWith("Delete [1] successfully"); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/public/pages/Aliases/containers/AliasActions/__snapshots__/AliasActions.test.tsx.snap b/public/pages/Aliases/containers/AliasActions/__snapshots__/AliasActions.test.tsx.snap new file mode 100644 index 000000000..4f0d2ca06 --- /dev/null +++ b/public/pages/Aliases/containers/AliasActions/__snapshots__/AliasActions.test.tsx.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec delete alias by calling commonService 1`] = ` +
+
+ +
+
+`; + +exports[` spec renders the component and all the actions should be disabled when no items selected 1`] = ` +
+
+ +
+
+`; diff --git a/public/pages/Aliases/containers/AliasActions/index.tsx b/public/pages/Aliases/containers/AliasActions/index.tsx new file mode 100644 index 000000000..5de68f6c0 --- /dev/null +++ b/public/pages/Aliases/containers/AliasActions/index.tsx @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React, { useMemo, useState } from "react"; +import { EuiButton, EuiContextMenu } from "@elastic/eui"; + +import SimplePopover from "../../../../components/SimplePopover"; +import DeleteIndexModal from "../DeleteAliasModal"; +import { IAlias } from "../../interface"; + +export interface AliasesActionsProps { + selectedItems: IAlias[]; + onDelete: () => void; + onUpdateAlias: () => void; +} + +export default function AliasesActions(props: AliasesActionsProps) { + const { selectedItems, onDelete, onUpdateAlias } = props; + const [deleteIndexModalVisible, setDeleteIndexModalVisible] = useState(false); + + const onDeleteIndexModalClose = () => { + setDeleteIndexModalVisible(false); + }; + + const renderKey = useMemo(() => Date.now(), [selectedItems]); + + return ( + <> + + Actions + + } + > + setDeleteIndexModalVisible(true), + }, + ], + }, + ]} + /> + + item.alias)} + visible={deleteIndexModalVisible} + onClose={onDeleteIndexModalClose} + onDelete={() => { + onDeleteIndexModalClose(); + onDelete(); + }} + /> + + ); +} diff --git a/public/pages/Aliases/containers/Aliases/Aliases.test.tsx b/public/pages/Aliases/containers/Aliases/Aliases.test.tsx new file mode 100644 index 000000000..f381107bc --- /dev/null +++ b/public/pages/Aliases/containers/Aliases/Aliases.test.tsx @@ -0,0 +1,239 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, waitFor } from "@testing-library/react"; +import { Redirect, Route, Switch } from "react-router-dom"; +import { HashRouter as Router } from "react-router-dom"; +import { browserServicesMock, coreServicesMock } from "../../../../../test/mocks"; +import Aliases from "./Aliases"; +import { ServicesContext } from "../../../../services"; +import { ROUTES } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; +import userEvent from "@testing-library/user-event"; +import { IAlias } from "../../interface"; + +function renderWithRouter() { + return { + ...render( + + + ( + + + + + + )} + /> + + + + ), + }; +} + +const testAliasId = "test"; +const multiIndexAliasId = "test2"; + +describe(" spec", () => { + beforeEach(() => { + browserServicesMock.commonService.apiCaller = jest.fn(async (payload) => { + if (payload.endpoint === "cat.aliases") { + return { + ok: true, + response: [ + { + alias: testAliasId, + index: "1", + }, + { + alias: multiIndexAliasId, + index: "1", + }, + { + alias: multiIndexAliasId, + index: "2", + }, + { + alias: multiIndexAliasId, + index: "3", + }, + { + alias: multiIndexAliasId, + index: "4", + }, + ] as IAlias[], + }; + } else if (payload.endpoint === "cat.indices") { + return { + ok: true, + response: [ + { + health: "green", + status: "open", + index: "1", + pri: "1", + rep: "0", + "docs.count": "1", + "docs.deleted": "0", + "store.size": "5.2kb", + "pri.store.size": "5.2kb", + }, + ], + }; + } else if (payload?.data?.name === multiIndexAliasId) { + return { + ok: false, + error: "alias exist", + }; + } else if (payload.endpoint === "transport.request" && payload.data?.path === "/_data_stream") { + return { + ok: true, + response: { + data_streams: [ + { + name: "test_data_stream", + indices: [], + }, + ], + }, + }; + } + + return { + ok: true, + response: {}, + }; + }) as any; + window.location.hash = "/"; + }); + it("renders the component", async () => { + const { container, getByTestId, queryByText } = renderWithRouter(); + + expect(container.firstChild).toMatchSnapshot(); + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(1); + }); + userEvent.click(getByTestId("tableHeaderCell_alias_0").querySelector("button") as Element); + await waitFor(() => { + expect(queryByText("1 more")).not.toBeNull(); + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(2); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + data: { format: "json", name: `**`, s: "alias:asc" }, + endpoint: "cat.aliases", + }); + }); + }); + + it("with some actions", async () => { + const { + findByTitle, + findByTestId, + getByTestId, + getByPlaceholderText, + getByTitle, + findByPlaceholderText, + getByText, + } = renderWithRouter(); + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(1); + userEvent.type(getByPlaceholderText("Search..."), `${testAliasId}{enter}`); + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(2); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + data: { format: "json", name: `*${testAliasId}*`, s: "alias:desc" }, + endpoint: "cat.aliases", + }); + }); + userEvent.click(document.getElementById(`_selection_column_${testAliasId}-checkbox`) as Element); + await waitFor(() => {}); + userEvent.click(document.querySelector('[data-test-subj="moreAction"] button') as Element); + userEvent.click(document.querySelector('[data-test-subj="editAction"]') as Element); + userEvent.click(getByTestId("cancelCreateAliasButton")); + userEvent.click(document.querySelector('[data-test-subj="moreAction"] button') as Element); + userEvent.click(document.querySelector('[data-test-subj="editAction"]') as Element); + await findByPlaceholderText("Specify alias name"); + expect(getByPlaceholderText("Specify alias name")).toBeDisabled(); + expect((getByPlaceholderText("Specify alias name") as HTMLInputElement).value).toEqual(testAliasId); + expect(getByTitle("1")).toBeInTheDocument(); + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(6); + userEvent.type(getByTestId("form-name-indexArray").querySelector('[data-test-subj="comboBoxSearchInput"]') as Element, "2{enter}"); + userEvent.click(document.querySelector('[title="1"] button') as Element); + userEvent.click(getByText("Save changes")); + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(8); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + data: { + body: { + actions: [ + { + remove: { + alias: testAliasId, + index: "1", + }, + }, + { + add: { + alias: testAliasId, + index: "2", + }, + }, + ], + }, + }, + endpoint: "indices.updateAliases", + }); + }); + + userEvent.click(getByTestId("Create AliasButton")); + await findByTestId("createAliasButton"); + userEvent.click(getByTestId("cancelCreateAliasButton")); + userEvent.click(getByTestId("Create AliasButton")); + await findByTestId("createAliasButton"); + userEvent.click(getByTestId("createAliasButton")); + await waitFor(() => { + expect(getByText("Invalid alias name.")).not.toBeNull(); + }); + userEvent.type(getByPlaceholderText("Specify alias name"), multiIndexAliasId); + userEvent.type(getByTestId("form-name-indexArray").querySelector('[data-test-subj="comboBoxSearchInput"]') as Element, "1{enter}"); + await waitFor(() => {}); + userEvent.click(getByTestId("createAliasButton")); + await waitFor(() => { + expect(coreServicesMock.notifications.toasts.addDanger).toBeCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addDanger).toBeCalledWith("alias exist"); + }); + userEvent.clear(getByPlaceholderText("Specify alias name")); + userEvent.type(getByPlaceholderText("Specify alias name"), testAliasId); + userEvent.click(getByTestId("createAliasButton")); + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledTimes(17); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + data: { + index: ["1"], + name: testAliasId, + }, + endpoint: "indices.putAlias", + }); + }); + + userEvent.click(getByText("1 more")); + await findByTitle(`Indices in ${multiIndexAliasId} (4)`); + userEvent.click(getByText("Rows per page: 10")); + userEvent.click(getByTestId("tablePagination-25-rows")); + userEvent.click(getByTestId("euiFlyoutCloseButton")); + }, 70000); + + it("shows detail", async () => { + const { getByTestId, findByTestId, getByText } = renderWithRouter(); + await findByTestId(`aliasDetail-${testAliasId}`); + userEvent.click(getByTestId(`aliasDetail-${testAliasId}`)); + await waitFor(() => expect(getByText("Save changes")).toBeInTheDocument(), { + timeout: 3000, + }); + }); +}); diff --git a/public/pages/Aliases/containers/Aliases/Aliases.tsx b/public/pages/Aliases/containers/Aliases/Aliases.tsx new file mode 100644 index 000000000..6e50c16fd --- /dev/null +++ b/public/pages/Aliases/containers/Aliases/Aliases.tsx @@ -0,0 +1,451 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component, useContext, useState } from "react"; +import _, { isEqual } from "lodash"; +import { RouteComponentProps } from "react-router-dom"; +import queryString from "query-string"; +import { + EuiHorizontalRule, + EuiBasicTable, + Criteria, + EuiTableSortingType, + Direction, + Pagination, + EuiTableSelectionType, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiText, + EuiLink, + EuiTitle, + EuiFormRow, + EuiEmptyPrompt, + EuiButton, +} from "@elastic/eui"; +import { ContentPanel, ContentPanelActions } from "../../../../components/ContentPanel"; +import { DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_QUERY_PARAMS } from "../../utils/constants"; +import CommonService from "../../../../services/CommonService"; +import { IAlias } from "../../interface"; +import { BREADCRUMBS } from "../../../../utils/constants"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { ServicesContext } from "../../../../services"; +import IndexControls, { SearchControlsProps } from "../../components/IndexControls"; +import CreateAlias from "../CreateAlias"; +import AliasesActions from "../AliasActions"; +import { CoreStart } from "opensearch-dashboards/public"; + +interface AliasesProps extends RouteComponentProps { + commonService: CommonService; +} + +interface AliasesState { + totalAliases: number; + from: string; + size: string; + search: string; + status: string; + sortField: keyof IAlias; + sortDirection: Direction; + selectedItems: IAlias[]; + editingItem: IAlias | null; + aliases: IAlias[]; + loading: boolean; + aliasCreateFlyoutVisible: boolean; + aliasEditFlyoutVisible: boolean; +} + +function IndexNameDisplay(props: { indices: string[]; alias: string }) { + const [hide, setHide] = useState(true); + const [tableParams, setTableParams] = useState>({}); + const { index, size } = tableParams.page || { + index: 0, + size: 10, + }; + + return ( + <> + {props.indices.slice(0, 3).join(", ")} + {props.indices.length <= 3 ? null : ( + setHide(!hide)}> + {props.indices.length - 3} more + + )} + {hide ? null : ( + setHide(!hide)}> + + +

+ Indices in {props.alias} ({props.indices.length}) +

+
+
+ + ({ index }))} + onChange={setTableParams} + pagination={{ + pageIndex: index, + pageSize: size, + totalItemCount: props.indices.length, + }} + /> + +
+ )} + + ); +} + +const defaultFilter = { + search: DEFAULT_QUERY_PARAMS.search, + status: DEFAULT_QUERY_PARAMS.status, +}; + +class Aliases extends Component { + static contextType = CoreServicesContext; + constructor(props: AliasesProps) { + super(props); + const { + from = DEFAULT_QUERY_PARAMS.from, + size = DEFAULT_QUERY_PARAMS.size, + search = DEFAULT_QUERY_PARAMS.search, + sortField = DEFAULT_QUERY_PARAMS.sortField, + sortDirection = DEFAULT_QUERY_PARAMS.sortDirection, + status = DEFAULT_QUERY_PARAMS.status, + } = queryString.parse(props.history.location.search) as { + from: string; + size: string; + search: string; + sortField: keyof IAlias; + sortDirection: Direction; + status: string; + }; + this.state = { + ...defaultFilter, + totalAliases: 0, + from, + size, + status, + search, + sortField, + sortDirection, + selectedItems: [], + aliases: [], + loading: false, + aliasCreateFlyoutVisible: false, + aliasEditFlyoutVisible: false, + editingItem: null, + }; + + this.getAliases = _.debounce(this.getAliases, 500, { leading: true }); + } + + componentDidMount() { + this.context.chrome.setBreadcrumbs([BREADCRUMBS.INDEX_MANAGEMENT, BREADCRUMBS.ALIASES]); + this.getAliases(); + } + + getQueryState = (state: AliasesState) => { + return Object.keys(DEFAULT_QUERY_PARAMS).reduce((total, key) => { + return { + ...total, + [key]: state[key as keyof typeof DEFAULT_QUERY_PARAMS], + }; + }, {} as AliasesState); + }; + + groupResponse = (array: IAlias[]) => { + const groupedMap: Record = {}; + array.forEach((item, index) => { + groupedMap[item.alias] = groupedMap[item.alias] || { + ...item, + order: index, + indexArray: [], + }; + groupedMap[item.alias].indexArray.push(item.index); + }); + const result = Object.values(groupedMap); + result.sort((a, b) => a.order - b.order); + return Object.values(groupedMap).sort(); + }; + + getAliases = async (): Promise => { + this.setState({ loading: true }); + const { from, size, status } = this.state; + const fromNumber = Number(from); + const sizeNumber = Number(size); + const { history, commonService } = this.props; + const queryObject = this.getQueryState(this.state); + const queryParamsString = queryString.stringify(queryObject); + history.replace({ ...this.props.location, search: queryParamsString }); + + const payload: any = { + format: "json", + name: `*${queryObject.search}*`, + s: `${queryObject.sortField}:${queryObject.sortDirection}`, + expand_wildcards: status, + }; + if (!status) { + delete payload.expand_wildcards; + } + + const getAliasesResponse = await commonService.apiCaller({ + endpoint: "cat.aliases", + data: payload, + }); + + if (getAliasesResponse.ok) { + // group by alias name + const responseGroupByAliasName: IAlias[] = this.groupResponse(getAliasesResponse.response); + const totalAliases = responseGroupByAliasName.length; + const payload = { + aliases: responseGroupByAliasName.slice(fromNumber * sizeNumber, (fromNumber + 1) * sizeNumber), + totalAliases, + selectedItems: this.state.selectedItems + .map((item) => responseGroupByAliasName.find((remoteItem) => remoteItem.alias === item.alias)) + .filter((item) => item), + } as AliasesState; + this.setState(payload); + } else { + this.context.notifications.toasts.addDanger(getAliasesResponse.error); + } + + // Avoiding flicker by showing/hiding the "Data stream" column only after the results are loaded. + this.setState({ loading: false }); + }; + + onTableChange = ({ page: tablePage, sort }: Criteria): void => { + const { index: page, size } = tablePage || {}; + const { field: sortField, direction: sortDirection } = sort || {}; + this.setState( + { + from: "" + page, + size: "" + size, + sortField: sortField || DEFAULT_QUERY_PARAMS.sortField, + sortDirection: sortDirection as Direction, + }, + () => this.getAliases() + ); + }; + + onSelectionChange = (selectedItems: IAlias[]): void => { + this.setState({ selectedItems }); + }; + + onSearchChange = (params: Parameters[0]): void => { + this.setState({ from: "0", ...params }, () => this.getAliases()); + }; + + render() { + const { totalAliases, from, size, sortField, sortDirection, aliases } = this.state; + + const pagination: Pagination = { + pageIndex: Number(from), + pageSize: Number(size), + pageSizeOptions: DEFAULT_PAGE_SIZE_OPTIONS, + totalItemCount: Number(totalAliases), + }; + + const sorting: EuiTableSortingType = { + sort: { + direction: sortDirection, + field: sortField, + }, + }; + + const selection: EuiTableSelectionType = { + onSelectionChange: this.onSelectionChange, + }; + return ( + { + this.setState({ aliasEditFlyoutVisible: true }); + }} + selectedItems={this.state.selectedItems} + onDelete={this.getAliases} + /> + ), + }, + { + text: "Create Alias", + buttonProps: { + fill: true, + onClick: () => { + this.setState({ + aliasCreateFlyoutVisible: true, + }); + }, + }, + }, + ]} + /> + } + bodyStyles={{ padding: "initial" }} + title={ + <> + + Aliases + + + An alias is a virtual index name that can point to one or more indexes. If your data is spread across multiple indexes, + rather than keeping track of which indexes to query, you can create an alias and query it instead.{" "} + + Learn more. + +
+ } + > + <> + + + } + > + + + + { + return ( + + this.setState({ + editingItem: record, + aliasEditFlyoutVisible: true, + }) + } + > + {value} + + ); + }, + }, + { + field: "indexArray", + name: "Index name", + render: (value: string[], record) => { + return ; + }, + }, + ]} + isSelectable={true} + itemId="alias" + items={aliases} + onChange={this.onTableChange} + pagination={pagination} + selection={selection} + sorting={sorting} + noItemsMessage={ + isEqual( + { + search: this.state.search, + status: this.state.status, + }, + defaultFilter + ) ? ( + +

You have no aliases.

+ + } + actions={[ + { + this.setState({ + aliasCreateFlyoutVisible: true, + }); + }} + > + Create alias + , + ]} + /> + ) : ( + +

There are no aliases matching your applied filters. Reset your filters to view your aliases.

+ + } + actions={[ + { + this.setState(defaultFilter, () => { + this.getAliases(); + }); + }} + > + Reset filters + , + ]} + /> + ) + } + /> + { + this.getAliases(); + this.setState({ aliasCreateFlyoutVisible: false }); + }} + onClose={() => this.setState({ aliasCreateFlyoutVisible: false })} + /> + { + this.getAliases(); + this.setState({ editingItem: null, aliasEditFlyoutVisible: false }); + }} + onClose={() => + this.setState({ + editingItem: null, + aliasEditFlyoutVisible: false, + }) + } + alias={this.state.editingItem || this.state.selectedItems[0]} + /> + + ); + } +} + +export default function AliasContainer(props: Omit) { + const context = useContext(ServicesContext); + return ; +} diff --git a/public/pages/Aliases/containers/Aliases/__snapshots__/Aliases.test.tsx.snap b/public/pages/Aliases/containers/Aliases/__snapshots__/Aliases.test.tsx.snap new file mode 100644 index 000000000..3f4f721b0 --- /dev/null +++ b/public/pages/Aliases/containers/Aliases/__snapshots__/Aliases.test.tsx.snap @@ -0,0 +1,433 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+ + Aliases + +
+
+
+
+ An alias is a virtual index name that can point to one or more indexes. If your data is spread across multiple indexes, rather than keeping track of which indexes to query, you can create an alias and query it instead. + + + Learn more. + EuiIconMock + + (opens in a new tab or window) + + +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + EuiIconMock + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ +
+
+
+
+ + + + + Index name + + +
+
+ +
+ +
+
+

+ You have no aliases. +

+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+`; diff --git a/public/pages/Aliases/containers/Aliases/index.ts b/public/pages/Aliases/containers/Aliases/index.ts new file mode 100644 index 000000000..1498b3c3e --- /dev/null +++ b/public/pages/Aliases/containers/Aliases/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import Aliases from "./Aliases"; + +export default Aliases; diff --git a/public/pages/Aliases/containers/CreateAlias/CreateAlias.test.tsx b/public/pages/Aliases/containers/CreateAlias/CreateAlias.test.tsx new file mode 100644 index 000000000..cadecca57 --- /dev/null +++ b/public/pages/Aliases/containers/CreateAlias/CreateAlias.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, waitFor } from "@testing-library/react"; +import CreateAlias from "./index"; +import { browserServicesMock } from "../../../../../test/mocks"; +import { ServicesContext } from "../../../../services"; + +describe(" spec", () => { + // the main unit test case is in Aliases.test.tsx + it("renders the component", async () => { + browserServicesMock.commonService.apiCaller = jest.fn(async () => ({ + ok: true, + response: [], + })) as typeof browserServicesMock.commonService.apiCaller; + render( + + {}} onSuccess={() => {}} /> + + ); + await waitFor(() => { + expect(document.body.children).toMatchSnapshot(); + }); + }); +}); diff --git a/public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap b/public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap new file mode 100644 index 000000000..8ae51a911 --- /dev/null +++ b/public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +HTMLCollection [ +