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 [
+ ,
+]
+`;
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"*+/\\|?#<>]*$/;