diff --git a/cypress/integration/split_index.js b/cypress/integration/split_index.js new file mode 100644 index 000000000..1a07aca54 --- /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"] .euiText').should("have.text", `${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"] .euiText').should("have.text", `${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/public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap b/public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap index fd9ea12f6..94b677b48 100644 --- a/public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap +++ b/public/pages/Aliases/containers/CreateAlias/__snapshots__/CreateAlias.test.tsx.snap @@ -7,7 +7,7 @@ HTMLCollection [ data-aria-hidden="true" />,
diff --git a/public/pages/CreateIndex/components/IndexDetail/IndexDetail.tsx b/public/pages/CreateIndex/components/IndexDetail/IndexDetail.tsx index 121ebf3aa..920eaac03 100644 --- a/public/pages/CreateIndex/components/IndexDetail/IndexDetail.tsx +++ b/public/pages/CreateIndex/components/IndexDetail/IndexDetail.tsx @@ -29,6 +29,7 @@ import { INDEX_NAMING_MESSAGE, REPLICA_NUMBER_MESSAGE, INDEX_SETTINGS_URL, + INDEX_NAMING_PATTERN, } from "../../../../utils/constants"; import { Modal } from "../../../../components/Modal"; import FormGenerator, { IField, IFormGeneratorRef } from "../../../../components/FormGenerator"; @@ -336,7 +337,7 @@ const IndexDetail = ( }, rules: [ { - pattern: /^[^\s:,A-Z-_"*+/\\|?#<>][^\s:,A-Z"*+/\\|?#<>]*$/, + pattern: INDEX_NAMING_PATTERN, message: "Invalid index name.", }, ], diff --git a/public/pages/IndexDetail/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap b/public/pages/IndexDetail/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap index bf8b6b363..6ae9ebd49 100644 --- a/public/pages/IndexDetail/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap +++ b/public/pages/IndexDetail/containers/IndexDetail/__snapshots__/IndexDetail.test.tsx.snap @@ -156,23 +156,6 @@ exports[`container spec render the component 2`] = ` > Health -
- - green - -
-
-
-
- Status -
@@ -196,6 +179,25 @@ exports[`container spec render the component 2`] = `
+
+
+ Status +
+
+ + open + +
+
{
)} /> + ( +
+ +
+ )} + /> diff --git a/public/pages/SplitIndex/components/SplitIndexForm/SplitIndexForm.tsx b/public/pages/SplitIndex/components/SplitIndexForm/SplitIndexForm.tsx new file mode 100644 index 000000000..acb493a00 --- /dev/null +++ b/public/pages/SplitIndex/components/SplitIndexForm/SplitIndexForm.tsx @@ -0,0 +1,225 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React, { Component } from "react"; +import { EuiSpacer, EuiLink, EuiFlexItem, EuiFlexGroup, EuiButton, EuiButtonEmpty } from "@elastic/eui"; +import FormGenerator, { IField, IFormGeneratorRef } from "../../../../components/FormGenerator"; +import { IndexItem } from "../../../../../models/interfaces"; +import IndexDetail from "../../../../containers/IndexDetail"; +import ContentPanel from "../../../../components/ContentPanel/ContentPanel"; +import { IFieldComponentProps } from "../../../../components/FormGenerator"; +import AliasSelect, { AliasSelectProps } from "../../../CreateIndex/components/AliasSelect"; +import EuiToolTipWrapper from "../../../../components/EuiToolTipWrapper"; +import { INDEX_NAMING_PATTERN, INDEX_NAMING_MESSAGE, INDEX_SETTINGS_URL, REPLICA_NUMBER_MESSAGE } from "../../../../utils/constants"; + +const WrappedAliasSelect = EuiToolTipWrapper(AliasSelect, { + disabledKey: "isDisabled", +}); + +interface SplitIndexComponentProps { + sourceIndex: string; + reasons: React.ReactChild[]; + sourceShards: string; + shardsSelectOptions: { label: string }[]; + onSplitIndex: (targetIndex: string, settingsPayload: Required["settings"]) => Promise; + onCancel: () => void; + getAlias: AliasSelectProps["refreshOptions"]; +} + +export default class SplitIndexForm extends Component { + state = { + settings: {} as Required["settings"], + sourceIndexSettings: {} as IndexItem, + }; + + formRef: IFormGeneratorRef | null = null; + onSubmit = async () => { + // trigger the validation manually here + const validateResult = await this.formRef?.validatePromise(); + const { targetIndex, ...others } = this.state.settings; + const { errors } = validateResult || {}; + if (errors) { + return; + } + try { + await this.props.onSplitIndex(targetIndex, others); + } catch (err) { + // no need to log anything since getIndexSettings will log the error + } + this.props.onCancel(); + }; + + render() { + const { sourceIndex, sourceShards, reasons, getAlias } = this.props; + const blockNameList = ["targetIndex"]; + + let shardMessage = "The number must be 2x times of the primary shard count of the source index."; + if (sourceShards === "1") { + shardMessage = "The number must be an integer greater than 1 but fewer or equal to 1024."; + } + + const formFields: IField[] = [ + { + rowProps: { + label: "Target Index Name", + helpText: INDEX_NAMING_MESSAGE, + position: "bottom", + }, + name: "targetIndex", + type: "Input", + options: { + rules: [ + { + trigger: "onBlur", + validator: (rule, value) => { + if (!value || value === "") { + // do not pass the validation + // return a rejected promise with error message + return Promise.reject("Target index name is required"); + } else if (!INDEX_NAMING_PATTERN.test(value)) { + return Promise.reject(`Target index name ${value} is invalid`); + } + // pass the validation, return a resolved promise + return Promise.resolve(); + }, + }, + ], + props: { + "data-test-subj": "targetIndexNameInput", + placeholder: "Specify a name for the new split index", + }, + }, + }, + { + rowProps: { + label: "Number of primary shards", + helpText: ( + <> +

Specify the number of primary shards for the new split index.

+

{shardMessage}

+ + ), + }, + name: "index.number_of_shards", + type: "ComboBoxSingle", + options: { + rules: [ + { + trigger: "onBlur", + validator: (rule, value) => { + if (!value || value === "") { + // do not pass the validation + // return a rejected promise with error message + return Promise.reject("Number of shards is required"); + } else if (!this.props.shardsSelectOptions.find((item) => "" + item.label === "" + value)) { + return Promise.reject(`Number of shards ${value} is invalid`); + } + // pass the validation, return a resolved promise + return Promise.resolve(); + }, + }, + ], + props: { + "data-test-subj": "numberOfShardsInput", + options: this.props.shardsSelectOptions, + placeholder: "Specify primary shard count", + onCreateOption: undefined, + }, + }, + }, + { + rowProps: { + label: "Number of replicas", + helpText: REPLICA_NUMBER_MESSAGE, + }, + name: "index.number_of_replicas", + type: "Number", + options: { + props: { + "data-test-subj": "numberOfReplicasInput", + placeholder: "Specify number of replica", + min: 0, + value: 1, + }, + }, + }, + { + name: "aliases", + rowProps: { + label: "Index alias - optional", + helpText: "Allow this index to be referenced by existing aliases or specify a new alias.", + }, + options: { + props: { + refreshOptions: getAlias, + }, + }, + component: WrappedAliasSelect as React.ComponentType, + }, + ]; + + const readyForSplit = reasons.length === 0; + return ( +
+ +
    + {reasons.map((reason, reasonIndex) => ( +
  • {reason}
  • + ))} +
+
+ + + {readyForSplit && ( + + + this.setState({ + settings: totalValue, + }) + } + formFields={formFields} + ref={(ref) => (this.formRef = ref)} + hasAdvancedSettings={true} + advancedSettingsProps={{ + accordionProps: { + initialIsOpen: false, + id: "accordionForCreateIndexSettings", + buttonContent:

Advanced settings

, + }, + blockedNameList: blockNameList, + rowProps: { + label: "Specify advanced index settings", + helpText: ( + <> + Specify a comma-delimited list of settings. + + View index settings + + + ), + }, + }} + /> +
+ )} + + + + + + Cancel + + + + + Split + + + +
+ ); + } +} diff --git a/public/pages/SplitIndex/components/SplitIndexForm/index.ts b/public/pages/SplitIndex/components/SplitIndexForm/index.ts new file mode 100644 index 000000000..7af4b5b3b --- /dev/null +++ b/public/pages/SplitIndex/components/SplitIndexForm/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import SplitIndexForm from "./SplitIndexForm"; + +export default SplitIndexForm; diff --git a/public/pages/SplitIndex/container/SplitIndex/SplitIndex.test.tsx b/public/pages/SplitIndex/container/SplitIndex/SplitIndex.test.tsx new file mode 100644 index 000000000..7290bb7f9 --- /dev/null +++ b/public/pages/SplitIndex/container/SplitIndex/SplitIndex.test.tsx @@ -0,0 +1,534 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { render, waitFor } from "@testing-library/react"; +import { SplitIndex } from "./SplitIndex"; +import userEvent from "@testing-library/user-event"; +import { browserServicesMock, coreServicesMock } from "../../../../../test/mocks"; +import { Route, RouteComponentProps, Switch } from "react-router-dom"; +import { MemoryRouter as Router } from "react-router-dom"; +import { BREADCRUMBS, ROUTES } from "../../../../utils/constants"; +import { CoreServicesConsumer, CoreServicesContext } from "../../../../components/core_services"; +import { ServicesConsumer, ServicesContext } from "../../../../services"; +import { IAlias } from "../../../Aliases/interface"; +import { BrowserServices } from "../../../../models/interfaces"; +import { ModalProvider, ModalRoot } from "../../../../components/Modal"; +import { CoreStart } from "opensearch-dashboards/public"; + +function renderWithRouter(initialEntries = [ROUTES.SPLIT_INDEX] as string[]) { + return { + ...render( + + + + + {(services: BrowserServices | null) => + services && ( + + {(core: CoreStart | null) => + core && ( + + + + ( + + )} + /> +

location is: {ROUTES.INDICES}

} /> +
+
+ ) + } +
+ ) + } +
+
+
+
+ ), + }; +} + +const sourceIndexName = "source-index"; + +describe(" spec", () => { + it("renders the component", async () => { + browserServicesMock.commonService.apiCaller = jest.fn(async (payload) => { + if (payload.endpoint === "cat.aliases") { + return { + ok: true, + response: [ + { + alias: "testAlias", + index: "1", + }, + ] as IAlias[], + }; + } else if (payload.endpoint === "cat.indices") { + return { + ok: true, + response: [ + { + health: "green", + status: "open", + index: sourceIndexName, + pri: "1", + rep: "0", + }, + ], + }; + } + + return { + ok: true, + }; + }); + + renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=index-source`]); + + await waitFor(() => { + expect(document.body.children).toMatchSnapshot(); + }); + }); + + it("set breadcrumbs when mounting", async () => { + renderWithRouter(); + + // wait for one tick + await waitFor(() => {}); + + expect(coreServicesMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); + expect(coreServicesMock.chrome.setBreadcrumbs).toHaveBeenCalledWith([ + BREADCRUMBS.INDEX_MANAGEMENT, + BREADCRUMBS.INDICES, + BREADCRUMBS.SPLIT_INDEX, + ]); + }); + + it("Successful split an index whose shards number is greater than 1", async () => { + browserServicesMock.commonService.apiCaller = jest.fn(async (payload) => { + if (payload.endpoint === "cat.aliases") { + return { + ok: true, + response: [ + { + alias: "testAlias", + index: "1", + }, + ] as IAlias[], + }; + } else if (payload.endpoint === "cat.indices") { + return { + ok: true, + response: [ + { + health: "green", + status: "open", + index: sourceIndexName, + pri: "2", + rep: "0", + }, + ], + }; + } + + return { + ok: true, + }; + }); + + const { getByTestId, getByText } = renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=$(sourceIndexName)`]); + + await waitFor(() => { + expect(getByTestId("splitButton")).not.toBeDisabled(); + }); + + expect(getByText("The number must be 2x times of the primary shard count of the source index.")).not.toBeNull(); + + userEvent.type(getByTestId("targetIndexNameInput"), "split_test_index-split"); + userEvent.type( + getByTestId("numberOfShardsInput").querySelector('[data-test-subj="comboBoxSearchInput"]') as Element, + "4{arrowdown}{enter}" + ); + userEvent.click(getByTestId("splitButton")); + + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.split", + method: "PUT", + data: { + index: "source-index", + target: "split_test_index-split", + body: { + settings: { + "index.number_of_shards": "4", + "index.number_of_replicas": "1", + }, + }, + }, + }); + }); + }); + + it("Successful split an index whose shards number is 1", async () => { + browserServicesMock.commonService.apiCaller = jest.fn(async (payload) => { + if (payload.endpoint === "cat.aliases") { + return { + ok: true, + response: [ + { + alias: "testAlias", + index: "1", + }, + ] as IAlias[], + }; + } else if (payload.endpoint === "cat.indices") { + return { + ok: true, + response: [ + { + health: "green", + status: "open", + index: sourceIndexName, + pri: "1", + rep: "0", + }, + ], + }; + } + + return { + ok: true, + }; + }); + + const { getByTestId, getByText } = renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=$(sourceIndexName)`]); + + await waitFor(() => { + expect(getByTestId("splitButton")).not.toBeDisabled(); + }); + + expect(getByText("The number must be an integer greater than 1 but fewer or equal to 1024.")).not.toBeNull(); + userEvent.type(getByTestId("targetIndexNameInput"), "split_test_index-split"); + userEvent.type( + getByTestId("numberOfShardsInput").querySelector('[data-test-subj="comboBoxSearchInput"]') as Element, + "5{arrowdown}{enter}" + ); + userEvent.clear(getByTestId("numberOfReplicasInput")); + userEvent.type(getByTestId("numberOfReplicasInput"), "1"); + userEvent.click(getByTestId("splitButton")); + + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.split", + method: "PUT", + data: { + index: "source-index", + target: "split_test_index-split", + body: { + settings: { + "index.number_of_shards": "5", + "index.number_of_replicas": "1", + }, + }, + }, + }); + }); + }); + + it("Error message if number of shards is invalid", async () => { + browserServicesMock.commonService.apiCaller = jest.fn(async (payload) => { + if (payload.endpoint === "cat.aliases") { + return { + ok: true, + response: [ + { + alias: "testAlias", + index: "1", + }, + ] as IAlias[], + }; + } else if (payload.endpoint === "cat.indices") { + return { + ok: true, + response: [ + { + health: "green", + status: "open", + index: sourceIndexName, + pri: "3", + rep: "0", + }, + ], + }; + } + + return { + ok: true, + }; + }); + + const { getByTestId, getByText } = renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=$(sourceIndexName)`]); + + await waitFor(() => { + expect(getByTestId("splitButton")).not.toBeDisabled(); + }); + + userEvent.type( + getByTestId("numberOfShardsInput").querySelector('[data-test-subj="comboBoxSearchInput"]') as Element, + "5{arrowdown}{enter}" + ); + userEvent.click(getByTestId("splitButton")); + + await waitFor(() => { + expect(getByText("Number of shards is required")).not.toBeNull(); + }); + }); + + it("Error message if index name or number of shards is not specified", async () => { + const { getByTestId, getByText } = renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=$(sourceIndexName)`]); + + await waitFor(() => { + expect(getByTestId("splitButton")).not.toBeDisabled(); + }); + + userEvent.click(getByTestId("splitButton")); + + await waitFor(() => { + expect(getByText("Target index name is required")).not.toBeNull(); + expect(getByText("Number of shards is required")).not.toBeNull(); + }); + }); + + it("Error message if index name is invalid", async () => { + const { getByTestId, getByText } = renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=$(sourceIndexName)`]); + + await waitFor(() => { + expect(getByTestId("splitButton")).not.toBeDisabled(); + }); + + userEvent.type(getByTestId("targetIndexNameInput"), "s*lit"); + userEvent.click(getByTestId("splitButton")); + + await waitFor(() => { + expect(getByText("Target index name s*lit is invalid")).not.toBeNull(); + }); + }); + + it("Red Index is not ready for split", async () => { + browserServicesMock.commonService.apiCaller = jest.fn(async (payload) => { + if (payload.endpoint === "cat.indices") { + return { + ok: true, + response: [ + { + health: "red", + status: "open", + index: sourceIndexName, + pri: "1", + rep: "0", + "docs.count": "1", + "docs.deleted": "0", + "store.size": "5.2kb", + "pri.store.size": "5.2kb", + }, + ], + }; + } + + return { + ok: true, + }; + }); + + const { getByTestId, queryByText } = renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=$(sourceIndexName)`]); + + await waitFor(() => { + expect(getByTestId("splitButton")).toBeDisabled(); + expect(queryByText("The source index must not have a Red health status.")).not.toBeNull(); + }); + }); + + it("Closed Index is not ready for split", async () => { + browserServicesMock.commonService.apiCaller = jest.fn(async (payload) => { + if (payload.endpoint === "cat.indices") { + return { + ok: true, + response: [ + { + health: "green", + status: "close", + index: sourceIndexName, + pri: "2", + rep: "0", + "docs.count": "1", + "docs.deleted": "0", + "store.size": "5.2kb", + "pri.store.size": "5.2kb", + }, + ], + }; + } else if (payload.endpoint === "indices.open") { + return { + ok: true, + response: [{}], + }; + } + + return { + ok: true, + }; + }); + + const { getByTestId, queryByText } = renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=$(sourceIndexName)`]); + + await waitFor(() => { + expect(getByTestId("splitButton")).toBeDisabled(); + expect(queryByText("The source index must be open.")).not.toBeNull(); + }); + userEvent.click(getByTestId("open-index-button")); + await waitFor(() => {}); + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.open", + data: { index: ["$(sourceIndexName)"] }, + }); + }); + + it("blocks.write is not set to true, Index is not ready for split", async () => { + browserServicesMock.commonService.apiCaller = jest.fn(async (payload) => { + if (payload.endpoint === "cat.indices") { + return { + ok: true, + response: [ + { + health: "green", + status: "open", + index: sourceIndexName, + pri: "1", + rep: "0", + "docs.count": "1", + "docs.deleted": "0", + "store.size": "5.2kb", + "pri.store.size": "5.2kb", + }, + ], + }; + } else if (payload.endpoint === "indices.getSettings") { + return { + ok: true, + response: { + [sourceIndexName]: { + settings: { + "index.blocks.write": "false", + }, + }, + }, + }; + } else if (payload.endpoint === "indices.putSettings") { + return { + ok: true, + response: [{}], + }; + } + }); + + const { getByTestId, queryByText } = renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=$(sourceIndexName)`]); + + await waitFor(() => { + expect(getByTestId("splitButton")).toBeDisabled(); + expect(queryByText("The source index must block write operations before splitting.")).not.toBeNull(); + }); + + userEvent.click(getByTestId("set-indexsetting-button")); + await waitFor(() => + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.putSettings", + method: "PUT", + data: { + index: "source-index", + flat_settings: true, + body: { + settings: { + "index.blocks.write": "true", + }, + }, + }, + }) + ); + }); + + it("blocks.write is not set, Index is not ready for split", async () => { + browserServicesMock.commonService.apiCaller = jest.fn(async (payload) => { + if (payload.endpoint === "cat.indices") { + return { + ok: true, + response: [ + { + health: "green", + status: "open", + index: sourceIndexName, + pri: "1", + rep: "0", + "docs.count": "1", + "docs.deleted": "0", + "store.size": "5.2kb", + "pri.store.size": "5.2kb", + }, + ], + }; + } else if (payload.endpoint === "indices.getSettings") { + return { + ok: true, + response: { + [sourceIndexName]: { + settings: {}, + }, + }, + }; + } else if (payload.endpoint === "indices.putSettings") { + return { + ok: true, + response: [{}], + }; + } + }); + + const { getByTestId, queryByText } = renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=$(sourceIndexName)`]); + + await waitFor(() => { + expect(getByTestId("splitButton")).toBeDisabled(); + expect(queryByText("The source index must block write operations before splitting.")).not.toBeNull(); + }); + + userEvent.click(getByTestId("set-indexsetting-button")); + await waitFor(() => + expect(browserServicesMock.commonService.apiCaller).toBeCalledWith({ + endpoint: "indices.putSettings", + method: "PUT", + data: { + index: "source-index", + flat_settings: true, + body: { + settings: { + "index.blocks.write": "true", + }, + }, + }, + }) + ); + }); + + it("Cancel works", async () => { + const { getByTestId, getByText } = renderWithRouter([`${ROUTES.SPLIT_INDEX}?source=index-source`]); + await waitFor(() => {}); + userEvent.click(getByTestId("splitCancelButton")); + + expect(getByText(`location is: ${ROUTES.INDICES}`)).toBeInTheDocument(); + }); +}); diff --git a/public/pages/SplitIndex/container/SplitIndex/SplitIndex.tsx b/public/pages/SplitIndex/container/SplitIndex/SplitIndex.tsx new file mode 100644 index 000000000..146f0b32b --- /dev/null +++ b/public/pages/SplitIndex/container/SplitIndex/SplitIndex.tsx @@ -0,0 +1,244 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React, { Component } from "react"; +import { EuiCallOut, EuiSpacer, EuiTitle, EuiButton, EuiLink, EuiText } from "@elastic/eui"; +import { get } from "lodash"; + +import { CatIndex } from "../../../../../server/models/interfaces"; +import { BrowserServices } from "../../../../models/interfaces"; +import SplitIndexForm from "../../components/SplitIndexForm"; +import { IndexItem } from "../../../../../models/interfaces"; +import { RouteComponentProps } from "react-router-dom"; +import queryString from "query-string"; +import { + openIndices, + getIndexSettings, + setIndexSettings, + getSplitShardOptions, + splitIndex, + getSingleIndice, + getAlias, +} from "../../../Indices/utils/helpers"; + +import { CommonService, ServicesContext } from "../../../../services"; +import { CoreStart } from "opensearch-dashboards/public"; +import { useContext } from "react"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { BREADCRUMBS, ROUTES } from "../../../../utils/constants"; + +interface SplitIndexProps extends RouteComponentProps { + commonService: CommonService; + coreService: CoreStart; +} + +export class SplitIndex extends Component { + static contextType = CoreServicesContext; + state = { + reasons: [] as React.ReactChild[], + shardsSelectOptions: [] as { label: string }[], + sourceIndex: {} as CatIndex, + splitIndexFlyoutVisible: false, + }; + + async componentDidMount() { + this.context.chrome.setBreadcrumbs([ + BREADCRUMBS.INDEX_MANAGEMENT, + BREADCRUMBS.INDICES, + { ...BREADCRUMBS.SPLIT_INDEX, href: `#${ROUTES.SPLIT_INDEX}${this.props.location.search}` }, + ]); + await this.isSourceIndexReady(); + this.calculateShardsOption(); + this.setState({ + splitIndexFlyoutVisible: true, + }); + } + + isSourceIndexReady = async () => { + const source = queryString.parse(this.props.location.search) as { source: string }; + let sourceIndex; + try { + sourceIndex = await getSingleIndice({ + indexName: source.source as string, + commonService: this.props.commonService, + coreServices: this.props.coreService, + }); + } catch (err) { + // no need to log anything since getIndexSettings will log the error + this.onCancel(); + } + + if (!sourceIndex) { + this.onCancel(); + } + this.setState({ + sourceIndex, + }); + + let sourceIndexSettings; + try { + sourceIndexSettings = await getIndexSettings({ + indexName: sourceIndex.index, + flat: true, + commonService: this.props.commonService, + coreServices: this.props.coreService, + }); + } catch (err) { + // no need to log anything since getIndexSettings will log the error + this.onCancel(); + } + const reasons = []; + const sourceSettings = get(sourceIndexSettings, [sourceIndex.index, "settings"]); + const blocksWriteValue = get(sourceSettings, ["index.blocks.write"]); + + if (sourceIndex.health === "red") { + reasons.push( + <> + + + + ); + } + + if (sourceIndex.status === "close") { + reasons.push( + <> + +

+ You must first open the index before splitting it. Depending on the size of the source index, this may take additional time to + complete. The index will be in the Red state while the index is opening. +

+

+ { + try { + await openIndices({ + commonService: this.props.commonService, + indices: [source.source], + coreServices: this.props.coreService, + }); + await this.isSourceIndexReady(); + } catch (err) { + // no need to log anything since openIndices will log the error + } + }} + data-test-subj={"open-index-button"} + > + Open index + +

+
+ + + ); + } + + if (sourceSettings && (!blocksWriteValue || (blocksWriteValue !== "true" && blocksWriteValue !== true))) { + const flat = true; + const blocksWriteSetting = { "index.blocks.write": "true" }; + reasons.push( + <> + +

In order to split an existing index, you must first set the index to block write operations.

+ { + try { + await setIndexSettings({ + indexName: sourceIndex.index, + flat, + settings: blocksWriteSetting, + commonService: this.props.commonService, + coreServices: this.props.coreService, + }); + await this.isSourceIndexReady(); + } catch (err) { + // no need to log anything since getIndexSettings will log the error + } + }} + data-test-subj={"set-indexsetting-button"} + > + Block write operations + +
+ + + ); + } + + this.setState({ + reasons, + }); + }; + + calculateShardsOption = () => { + const { sourceIndex } = this.state; + const sourceShards = Number(sourceIndex.pri); + const shardsSelectOptions = getSplitShardOptions(sourceShards); + this.setState({ + shardsSelectOptions, + }); + }; + + onSplitIndex = async (targetIndex: string, settingsPayload: Required["settings"]): Promise => { + const { sourceIndex } = this.state; + await splitIndex({ + sourceIndex: sourceIndex.index, + targetIndex, + settingsPayload, + commonService: this.props.commonService, + coreServices: this.props.coreService, + }); + }; + + onCancel = () => { + this.props.history.push(ROUTES.INDICES); + }; + + render() { + const { sourceIndex, splitIndexFlyoutVisible, reasons, shardsSelectOptions } = this.state; + return ( +
+ +

Split index

+
+ + +

+ Split an existing read-only index into a new index with more primary shards .    + + Learn more. + +

+
+ + + + {splitIndexFlyoutVisible && ( + + getAlias({ + aliasName, + commonService: this.props.commonService, + }) + } + /> + )} +
+ ); + } +} + +export default function SplitIndexWrapper(props: Omit) { + const services = useContext(ServicesContext) as BrowserServices; + const coreService = useContext(CoreServicesContext) as CoreStart; + return ; +} diff --git a/public/pages/SplitIndex/container/SplitIndex/__snapshots__/SplitIndex.test.tsx.snap b/public/pages/SplitIndex/container/SplitIndex/__snapshots__/SplitIndex.test.tsx.snap new file mode 100644 index 000000000..bbffc907b --- /dev/null +++ b/public/pages/SplitIndex/container/SplitIndex/__snapshots__/SplitIndex.test.tsx.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +HTMLCollection [ +
+
+

+ Split index +

+
+
+

+ Split an existing read-only index into a new index with more primary shards .    + + Learn more. + EuiIconMock + + (opens in a new tab or window) + + +

+
+
+
+
+
, +] +`; diff --git a/public/pages/SplitIndex/container/SplitIndex/index.ts b/public/pages/SplitIndex/container/SplitIndex/index.ts new file mode 100644 index 000000000..e1baea382 --- /dev/null +++ b/public/pages/SplitIndex/container/SplitIndex/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import SplitIndex from "./SplitIndex"; + +export default SplitIndex; diff --git a/public/pages/SplitIndex/index.ts b/public/pages/SplitIndex/index.ts new file mode 100644 index 000000000..7ee8618cf --- /dev/null +++ b/public/pages/SplitIndex/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import SplitIndex from "./container/SplitIndex"; + +export default SplitIndex; diff --git a/public/utils/constants.ts b/public/utils/constants.ts index 07250614c..fa10f48fb 100644 --- a/public/utils/constants.ts +++ b/public/utils/constants.ts @@ -337,3 +337,5 @@ export const TEMPLATE_TYPE = { INDEX_TEMPLATE: "Indexes", DATA_STREAM: "Data streams", }; + +export const INDEX_NAMING_PATTERN = /^[^A-Z-_"*+/\\|?#<>][^A-Z"*+/\\|?#<>]*$/;