diff --git a/cypress/integration/aliases.js b/cypress/integration/aliases.js index 7d77a0f06..f2a9b235c 100644 --- a/cypress/integration/aliases.js +++ b/cypress/integration/aliases.js @@ -45,7 +45,7 @@ describe("Aliases", () => { }); }); - describe("shows more flyout", () => { + describe("shows more modal", () => { it("successfully", () => { cy.get('[placeholder="Search..."]').type("alias-for-test-0{enter}"); cy.contains("alias-for-test-0"); @@ -64,7 +64,7 @@ describe("Aliases", () => { cy.get('[data-test-subj="form-name-indexArray"] [data-test-subj="comboBoxSearchInput"]').type( `${EDIT_INDEX}{enter}${SAMPLE_INDEX_PREFIX}-*{enter}` ); - cy.get(".euiFlyoutFooter .euiButton--fill").click().get('[data-test-subj="9 more"]').should("exist"); + cy.get(".euiModalFooter .euiButton--fill").click().get('[data-test-subj="9 more"]').should("exist"); }); }); @@ -89,7 +89,7 @@ describe("Aliases", () => { .click() .get(`[title="${SAMPLE_INDEX_PREFIX}-1"] button`) .click() - .get(".euiFlyoutFooter .euiButton--fill") + .get(".euiModalFooter .euiButton--fill") .click() .end(); diff --git a/cypress/integration/indices_spec.js b/cypress/integration/indices_spec.js index 7962b853b..08ba14862 100644 --- a/cypress/integration/indices_spec.js +++ b/cypress/integration/indices_spec.js @@ -330,6 +330,47 @@ describe("Indices", () => { }); }); + 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(); 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/JobHandler/index.tsx b/public/JobHandler/index.tsx index 2035a8342..3e974d3bc 100644 --- a/public/JobHandler/index.tsx +++ b/public/JobHandler/index.tsx @@ -77,8 +77,7 @@ export function JobHandlerRegister(core: CoreSetup) { { title: (( <> - Source index has been successfully reindexed as{" "} - + Source {extras.sourceIndex} has been successfully reindexed as ) as unknown) as string, }, @@ -113,8 +112,7 @@ export function JobHandlerRegister(core: CoreSetup) { { title: (( <> - Reindex from to {extras.destIndex} has some errors, please check the errors - below: + 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, @@ -139,8 +137,8 @@ export function JobHandlerRegister(core: CoreSetup) { { title: (( <> - Reindex from to {extras.destIndex} does not finish in reasonable time, please check - the task {extras.taskId} manually + Reindex from {extras.sourceIndex} to {extras.destIndex} does not finish in reasonable time, please check the task{" "} + {extras.taskId} manually ) as unknown) as string, }, diff --git a/public/components/FormGenerator/built_in_components/index.tsx b/public/components/FormGenerator/built_in_components/index.tsx index 20a18b879..43cd71da5 100644 --- a/public/components/FormGenerator/built_in_components/index.tsx +++ b/public/components/FormGenerator/built_in_components/index.tsx @@ -42,14 +42,14 @@ const componentMap: Record) => { return ( { const findItem = options.find((item: { label: string }) => item.label === searchValue); if (findItem) { onChange(searchValue); } }} + {...others} + options={options} singleSelection={{ asPlainText: true }} ref={ref} onChange={(selectedOptions) => { @@ -59,7 +59,7 @@ const componentMap: Record item !== undefined).map((label) => ({ label }))} + selectedOptions={[value].filter((item) => item !== undefined).map((label) => ({ label: `${label}` }))} /> ); }) diff --git a/public/components/FormGenerator/index.tsx b/public/components/FormGenerator/index.tsx index 6b2a9284a..8557424e7 100644 --- a/public/components/FormGenerator/index.tsx +++ b/public/components/FormGenerator/index.tsx @@ -74,10 +74,12 @@ function FormGenerator(props: IFormGeneratorProps, ref: React.Ref { - if (propsRef.current.resetValuesWhenPropsValueChange) { - field.resetValues(props.value); - } else { - field.setValues(props.value); + if (!isEqual(field.getValues(), props.value)) { + if (propsRef.current.resetValuesWhenPropsValueChange) { + field.resetValues(props.value); + } else { + field.setValues(props.value); + } } }, [props.value]); const formattedFormFields = useMemo(() => { diff --git a/public/components/JSONDiffEditor/JSONDiffEditor.tsx b/public/components/JSONDiffEditor/JSONDiffEditor.tsx index ba4ecbd3d..b831b5ca4 100644 --- a/public/components/JSONDiffEditor/JSONDiffEditor.tsx +++ b/public/components/JSONDiffEditor/JSONDiffEditor.tsx @@ -66,7 +66,7 @@ const JSONDiffEditor = forwardRef(({ value, onChange, ...others }: JSONDiffEdito editorRef.current?.getModifiedEditor().getDomNode()?.setAttribute("data-test-subj", "codeEditorContainer"); return () => { document.body.removeEventListener("click", onClickOutsideHandler.current); - editorRef.current?.getDomNode().addEventListener("click", onClickContainer.current); + editorRef.current?.getDomNode().removeEventListener("click", onClickContainer.current); }; }, [isReady]); 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/Aliases/containers/CreateAlias/index.tsx b/public/pages/Aliases/containers/CreateAlias/index.tsx index fb5a8cb60..cc8f2eaeb 100644 --- a/public/pages/Aliases/containers/CreateAlias/index.tsx +++ b/public/pages/Aliases/containers/CreateAlias/index.tsx @@ -1,5 +1,5 @@ import React, { useContext, useEffect, useRef } from "react"; -import { EuiButton, EuiCallOut, EuiFlyout, EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, EuiSpacer } from "@elastic/eui"; +import { EuiButton, EuiCallOut, EuiModal, EuiModalHeader, EuiModalBody, EuiModalFooter, EuiSpacer } from "@elastic/eui"; import FormGenerator, { IFormGeneratorRef } from "../../../../components/FormGenerator"; import RemoteSelect from "../../../../components/RemoteSelect"; import { ServicesContext } from "../../../../services"; @@ -104,9 +104,9 @@ export default function CreateAlias(props: ICreateAliasProps) { } return ( - {}}> - {isEdit ? "Update" : "Create"} alias - + {}}> + {isEdit ? "Update" : "Create"} alias + {isEdit && filterByMinimatch(props.alias?.alias || "", SYSTEM_ALIAS) ? ( <> You are editing a system-like alias, please be careful before you do any change to it. @@ -162,9 +162,10 @@ export default function CreateAlias(props: ICreateAliasProps) { }, ]} /> - - -
+ + + +
Cancel @@ -214,7 +215,7 @@ export default function CreateAlias(props: ICreateAliasProps) { {isEdit ? "Save changes" : "Create alias"}
- - +
+ ); } 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/CreateIndex/containers/IndexForm/index.tsx b/public/pages/CreateIndex/containers/IndexForm/index.tsx index d4ee9ee09..9ac491d32 100644 --- a/public/pages/CreateIndex/containers/IndexForm/index.tsx +++ b/public/pages/CreateIndex/containers/IndexForm/index.tsx @@ -3,10 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { diffArrays } from "diff"; import React, { Component, forwardRef, useContext } from "react"; import { EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiLoadingSpinner } from "@elastic/eui"; import { get, set, differenceWith, isEqual, merge } from "lodash"; +import { diffArrays } from "diff"; import flattern from "flat"; import IndexDetail, { IndexDetailProps, IIndexDetailRef, defaultIndexSettings } from "../../components/IndexDetail"; import { IAliasAction, IndexItem, IndexItemRemote, MappingsProperties } from "../../../../../models/interfaces"; @@ -382,8 +382,8 @@ export class IndexForm extends Component spec", () => { userEvent.clear(shardsInput); userEvent.type(shardsInput, "1.5"); - await waitFor(() => expect(getByText("Number of primary shards must be an integer")).toBeInTheDocument(), { timeout: 3000 }); + await waitFor(() => expect(getByText("Number of primary shards must be an integer.")).toBeInTheDocument(), { timeout: 3000 }); userEvent.clear(shardsInput); userEvent.type(shardsInput, "1"); diff --git a/public/pages/CreateIndexTemplate/containers/TemplateDetail/TemplateDetail.tsx b/public/pages/CreateIndexTemplate/containers/TemplateDetail/TemplateDetail.tsx index 86f20259f..64581a774 100644 --- a/public/pages/CreateIndexTemplate/containers/TemplateDetail/TemplateDetail.tsx +++ b/public/pages/CreateIndexTemplate/containers/TemplateDetail/TemplateDetail.tsx @@ -7,6 +7,7 @@ import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHan import { EuiButton, EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -37,6 +38,7 @@ import { RouteComponentProps } from "react-router-dom"; import { INDEX_SETTINGS_URL, ROUTES } from "../../../../utils/constants"; import DeleteTemplateModal from "../../../Templates/containers/DeleteTemplatesModal"; import TemplateType, { TemplateConvert } from "../../components/TemplateType"; +import { filterByMinimatch } from "../../../../../utils/helper"; export interface TemplateDetailProps { templateName?: string; @@ -126,6 +128,7 @@ const TemplateDetail = ({ templateName, onCancel, onSubmitSuccess, readonly, his }, []); const values: TemplateItem = field.getValues(); const Component = isEdit ? AllBuiltInComponents.Text : AllBuiltInComponents.Input; + const matchSystemIndex = filterByMinimatch(".kibana", values.index_patterns || []); return ( <> @@ -284,6 +287,17 @@ const TemplateDetail = ({ templateName, onCancel, onSubmitSuccess, readonly, his /> + {matchSystemIndex ? ( + <> + + + This template may apply to new system indexes and may affect your ability to access OpenSearch. We recommend narrowing + your index patterns. + + + + + ) : null} { const health = detail.health; const color = health ? HEALTH_TO_COLOR[health] : "subdued"; @@ -65,6 +61,12 @@ const OVERVIEW_DISPLAY_INFO: { ); }, }, + { + label: "Status", + value: ({ detail }) => { + return {detail.status}; + }, + }, { label: "Creation date", value: ({ detail }) => ( 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/Indices/components/CloseIndexModal/__snapshots__/CloseIndexModal.test.tsx.snap b/public/pages/Indices/components/CloseIndexModal/__snapshots__/CloseIndexModal.test.tsx.snap index c3a8f86fd..77d591b0d 100644 --- a/public/pages/Indices/components/CloseIndexModal/__snapshots__/CloseIndexModal.test.tsx.snap +++ b/public/pages/Indices/components/CloseIndexModal/__snapshots__/CloseIndexModal.test.tsx.snap @@ -58,7 +58,7 @@ HTMLCollection [
- You are closing system-like index, please be careful before you do any change to it. + You are closing system like index, please be careful before you do any change to it.
diff --git a/public/pages/Indices/containers/Indices/index.scss b/public/pages/Indices/containers/Indices/index.scss index 001b17611..04c41d04c 100644 --- a/public/pages/Indices/containers/Indices/index.scss +++ b/public/pages/Indices/containers/Indices/index.scss @@ -2,4 +2,8 @@ .euiFlexItem--flexGrowZero { text-transform: capitalize; } +} + +.camel-first-letter { + text-transform: capitalize; } \ No newline at end of file diff --git a/public/pages/Indices/utils/constants.tsx b/public/pages/Indices/utils/constants.tsx index 85bd624db..1adf929ff 100644 --- a/public/pages/Indices/utils/constants.tsx +++ b/public/pages/Indices/utils/constants.tsx @@ -92,7 +92,7 @@ const getColumns = (props: IColumnOptions): EuiTableFieldDataColumnType { - return item.extraStatus || status; + return {item.extraStatus || status}; }, }, { diff --git a/public/pages/Indices/utils/helpers.ts b/public/pages/Indices/utils/helpers.ts index feab9fe77..4021ca50f 100644 --- a/public/pages/Indices/utils/helpers.ts +++ b/public/pages/Indices/utils/helpers.ts @@ -125,8 +125,8 @@ export async function getAlias(props: { aliasName?: string; commonService: Commo method: "GET", data: { format: "json", - name: `${props.aliasName || ""}*`, - expand_wildcards: "open", + name: `*${props.aliasName || ""}*`, + s: "alias:desc", }, }); } diff --git a/public/pages/Main/Main.tsx b/public/pages/Main/Main.tsx index 4d7351f76..5ad4b9b17 100644 --- a/public/pages/Main/Main.tsx +++ b/public/pages/Main/Main.tsx @@ -33,12 +33,14 @@ import Repositories from "../Repositories"; import SnapshotPolicies from "../SnapshotPolicies"; import SnapshotPolicyDetails from "../SnapshotPolicyDetails"; import Snapshots from "../Snapshots"; +import CreateIndex from "../CreateIndex"; +import Reindex from "../Reindex/container/Reindex"; import Aliases from "../Aliases"; import Templates from "../Templates"; import CreateIndexTemplate from "../CreateIndexTemplate"; -import CreateIndex from "../CreateIndex"; +import SplitIndex from "../SplitIndex"; import IndexDetail from "../IndexDetail"; -import Reindex from "../Reindex/container/Reindex"; +import ShrinkIndex from "../ShrinkIndex/container/ShrinkIndex"; enum Navigation { IndexManagement = "Index Management", @@ -401,23 +403,23 @@ export default class Main extends Component { )} /> ( + path={`${ROUTES.CREATE_INDEX}/:index/:mode`} + render={(props: RouteComponentProps) => (
- +
)} /> ( + path={`${ROUTES.CREATE_INDEX}/:index`} + render={(props: RouteComponentProps) => (
- +
)} /> (
@@ -425,23 +427,39 @@ export default class Main extends Component { )} /> ( + path={ROUTES.REINDEX} + render={(props: RouteComponentProps) => (
- +
)} /> (
- +
)} /> ( +
+ +
+ )} + /> + ( +
+ +
+ )} + /> + (
@@ -449,10 +467,10 @@ export default class Main extends Component { )} /> ( + path={`${ROUTES.CREATE_TEMPLATE}/:template`} + render={(props) => (
- +
)} /> @@ -473,10 +491,10 @@ export default class Main extends Component { )} /> ( + path={ROUTES.SHRINK_INDEX} + render={(props) => (
- +
)} /> diff --git a/public/pages/ShrinkIndex/container/ShrinkIndex/ShrinkIndex.test.tsx b/public/pages/ShrinkIndex/container/ShrinkIndex/ShrinkIndex.test.tsx new file mode 100644 index 000000000..2411db098 --- /dev/null +++ b/public/pages/ShrinkIndex/container/ShrinkIndex/ShrinkIndex.test.tsx @@ -0,0 +1,615 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, fireEvent, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Route, RouteComponentProps, Switch } from "react-router-dom"; +import { MemoryRouter as Router } from "react-router-dom"; +import { CoreStart } from "opensearch-dashboards/public"; +import { browserServicesMock, coreServicesMock } from "../../../../../test/mocks"; +import { ServicesConsumer, ServicesContext } from "../../../../services"; +import { BREADCRUMBS, ROUTES } from "../../../../utils/constants"; +import { CoreServicesConsumer, CoreServicesContext } from "../../../../components/core_services"; +import ShrinkIndex from "./ShrinkIndex"; +import { ModalProvider, ModalRoot } from "../../../../components/Modal"; +import { BrowserServices } from "../../../../models/interfaces"; + +function renderWithRouter(initialEntries = [ROUTES.SHRINK_INDEX] as string[]) { + return { + ...render( + + + + + {(services: BrowserServices | null) => + services && ( + + {(core: CoreStart | null) => + core && ( + + + + } + /> +

location is: {ROUTES.INDICES}

} /> +
+
+ ) + } +
+ ) + } +
+
+
+
+ ), + }; +} + +const indices = [ + { + "docs.count": 5, + "docs.deleted": 2, + health: "green", + index: "test1", + pri: "10", + "pri.store.size": "100KB", + rep: "0", + status: "open", + "store.size": "100KB", + uuid: "some_uuid", + }, + { + "docs.count": 5, + "docs.deleted": 2, + health: "green", + index: "test2", + pri: "3", + "pri.store.size": "100KB", + rep: "0", + status: "open", + "store.size": "100KB", + uuid: "some_uuid", + }, + { + "docs.count": 5, + "docs.deleted": 2, + health: "green", + index: "test3", + pri: "5", + "pri.store.size": "100KB", + rep: "0", + status: "open", + "store.size": "100KB", + uuid: "some_uuid", + }, + { + "docs.count": 5, + "docs.deleted": 2, + health: "red", + index: "test4", + pri: "1", + "pri.store.size": "100KB", + rep: "0", + status: "open", + "store.size": "100KB", + uuid: "some_uuid", + }, + { + "docs.count": 5, + "docs.deleted": 2, + health: "green", + index: "test5", + pri: "3", + "pri.store.size": "100KB", + rep: "0", + status: "close", + "store.size": "100KB", + uuid: "some_uuid", + }, + { + "docs.count": 5, + "docs.deleted": 2, + health: "yellow", + index: "test6", + pri: "3", + "pri.store.size": "100KB", + rep: "0", + status: "open", + "store.size": "100KB", + uuid: "some_uuid", + }, + { + "docs.count": 5, + "docs.deleted": 2, + health: "yellow", + index: "test7", + pri: "3", + "pri.store.size": "100KB", + rep: "0", + status: "close", + "store.size": "100KB", + uuid: "some_uuid", + }, +]; + +const mockApi = () => { + browserServicesMock.indexService.getIndices = jest.fn().mockImplementation((args) => ({ + ok: true, + response: { indices: args.search.length > 0 ? indices.filter((index) => index.index.startsWith(args.search)) : indices }, + })); + + browserServicesMock.commonService.apiCaller = jest.fn( + async (payload): Promise => { + switch (payload.endpoint) { + case "cat.indices": + return { + ok: true, + response: indices.filter((indexItem) => indexItem.index === payload.data.index[0]), + }; + case "indices.getSettings": + if (payload.data.index === "test2") { + return { + ok: true, + response: { + test2: { + settings: { + "index.blocks.write": false, + }, + }, + }, + }; + } else if (payload.data.index === "test3") { + return { + ok: true, + response: { + test3: { + settings: { + "index.blocks.write": true, + "index.routing.allocation.require._name": "node1", + }, + }, + }, + }; + } else if (payload.data.index === "test6") { + return { + ok: true, + response: { + test6: { + settings: { + "index.blocks.write": true, + "index.blocks.read_only": true, + }, + }, + }, + }; + } else if (payload.data.index === "test7") { + return { + ok: true, + response: { + test6: { + settings: { + "index.blocks.read_only": true, + }, + }, + }, + }; + } else { + return { + ok: true, + response: {}, + }; + } + case "indices.putSettings": + if (payload.data.index === "test7") { + return { + ok: false, + error: "[cluster_block_exception] index [test7] blocked by: [FORBIDDEN/5/index read-only (api)];", + }; + } else { + return { + ok: true, + response: {}, + }; + } + case "indices.open": + if (payload.data.index === "test7") { + return { + ok: false, + error: "[cluster_block_exception] index [test7] blocked by: [FORBIDDEN/5/index read-only (api)];", + }; + } else { + return { + ok: true, + response: {}, + }; + } + case "cat.aliases": + return { + ok: true, + response: [ + { + alias: "a1", + index: "acvxcvxc", + filter: "-", + "routing.index": "-", + "routing.search": "-", + is_write_index: "-", + }, + ], + }; + case "indices.shrink": + return { + ok: true, + response: {}, + }; + } + return { + ok: true, + response: {}, + }; + } + ); +}; + +describe(" spec", () => { + it("renders the component", async () => { + mockApi(); + const { container, getByText } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test3`]); + // wait for one tick + await waitFor(() => { + getByText("Configure target index"); + }); + expect(container).toMatchSnapshot(); + }); + + it("set breadcrumbs when mounting", async () => { + mockApi(); + renderWithRouter(); + + // wait for one tick + await waitFor(() => {}); + + expect(coreServicesMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); + expect(coreServicesMock.chrome.setBreadcrumbs).toHaveBeenCalledWith([ + BREADCRUMBS.INDEX_MANAGEMENT, + BREADCRUMBS.INDICES, + BREADCRUMBS.SHRINK_INDEX, + ]); + }); + + it("cancel back to indices page", async () => { + mockApi(); + const { getByText } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test1`]); + await waitFor(() => {}); + userEvent.click(getByText("Cancel")); + + expect(getByText(`location is: ${ROUTES.INDICES}`)).toBeInTheDocument(); + }); + + it("shows error when source index's setting index.blocks.write is null", async () => { + mockApi(); + const { getByText, queryByText, getByTestId } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test1`]); + await waitFor(() => { + getByText("Source index details"); + }); + + expect(queryByText("The source index must block write operations before shrinking.")).not.toBeNull(); + fireEvent.click(getByTestId("onSetIndexWriteBlockButton")); + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledWith({ + endpoint: "indices.putSettings", + method: "PUT", + data: { + index: "test1", + body: { + settings: { + "index.blocks.write": true, + }, + }, + }, + }); + }); + + it("shows error when source index's setting index.blocks.write is false", async () => { + mockApi(); + const { getByText, queryByText, getByTestId } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test2`]); + await waitFor(() => { + getByText("Source index details"); + }); + + expect(queryByText("The source index must block write operations before shrinking.")).not.toBeNull(); + fireEvent.click(getByTestId("onSetIndexWriteBlockButton")); + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledWith({ + endpoint: "indices.putSettings", + method: "PUT", + data: { + index: "test2", + body: { + settings: { + "index.blocks.write": true, + }, + }, + }, + }); + }); + + it("shows error when target index name is not set", async () => { + mockApi(); + const { getByText, queryByText, getByTestId } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test3`]); + await waitFor(async () => { + getByText("Configure target index"); + expect(queryByText("Target index name required.")).toBeNull(); + }); + + await act(async () => { + userEvent.type(getByTestId("targetIndexNameInput"), "test_index_shrunken"); + }); + await waitFor(async () => { + expect(queryByText("Target index name required.")).toBeNull(); + }); + + await act(async () => { + userEvent.clear(getByTestId("targetIndexNameInput")); + }); + await act(async () => { + fireEvent.click(getByTestId("shrinkIndexConfirmButton")); + }); + await waitFor(() => { + expect(queryByText("Target index name required.")).not.toBeNull(); + }); + }); + + it("shows error when target index name is not valid", async () => { + mockApi(); + const { getByText, queryByText, getByTestId } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test3`]); + await waitFor(async () => { + getByText("Configure target index"); + expect(queryByText("Invalid target index name.")).toBeNull(); + }); + + await act(async () => { + userEvent.type(getByTestId("targetIndexNameInput"), "$44@&**^*^*"); + }); + await waitFor(async () => { + expect(queryByText("Invalid target index name.")).not.toBeNull(); + }); + await act(async () => { + fireEvent.click(getByTestId("shrinkIndexConfirmButton")); + }); + await waitFor(() => { + expect(queryByText("Invalid target index name.")).not.toBeNull(); + }); + }); + + it("shows error when number of replicas is not valid", async () => { + mockApi(); + const { getByText, queryByText, getByTestId } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test3`]); + await waitFor(async () => { + getByText("Configure target index"); + expect(queryByText("Number of replicas must be greater than or equal to 0.")).toBeNull(); + }); + + await act(async () => { + userEvent.clear(getByTestId("numberOfReplicasInput")); + }); + + await waitFor(async () => { + expect(queryByText("Number of replicas must be greater than or equal to 0.")).not.toBeNull(); + }); + await act(async () => { + userEvent.type(getByTestId("numberOfReplicasInput"), "-1"); + }); + + await waitFor(async () => { + expect(queryByText("Number of replicas must be greater than or equal to 0.")).not.toBeNull(); + }); + }); + + it("shows danger when source index is red", async () => { + mockApi(); + const { queryByText, getByTestId } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test4`]); + + await waitFor(() => { + expect(queryByText("The source index's health status is Red.")).not.toBeNull(); + expect(getByTestId("shrinkIndexConfirmButton")).toHaveAttribute("disabled"); + }); + }); + + it("shows danger when source index has only one primary shard", async () => { + mockApi(); + const { queryByText, getByTestId } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test4`]); + + await waitFor(() => { + expect(queryByText("The source index has only one primary shard, you cannot shrink it anymore.")).not.toBeNull(); + expect(getByTestId("shrinkIndexConfirmButton")).toHaveAttribute("disabled"); + }); + }); + + it("shows block write operations button when source index has no write block", async () => { + mockApi(); + const { getByText, queryByText, getByTestId } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test5`]); + + await waitFor(() => { + getByText("Source index details"); + }); + + expect(queryByText("The source index must block write operations before shrinking.")).not.toBeNull(); + expect(getByTestId("shrinkIndexConfirmButton")).toHaveAttribute("disabled"); + expect(getByTestId("onSetIndexWriteBlockButton")).not.toBeNull(); + + userEvent.click(getByTestId("onSetIndexWriteBlockButton")); + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledWith({ + endpoint: "indices.putSettings", + method: "PUT", + data: { + index: "test5", + body: { + settings: { + "index.blocks.write": true, + }, + }, + }, + }); + }); + + it("shows open index button when source index is close", async () => { + mockApi(); + const { getByText, queryByText, getByTestId } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test5`]); + + await waitFor(() => { + getByText("Source index details"); + }); + + expect(queryByText("The source index must be open.")).not.toBeNull(); + expect(getByTestId("shrinkIndexConfirmButton")).toHaveAttribute("disabled"); + expect(getByTestId("onOpenIndexButton")).not.toBeNull(); + + userEvent.click(getByTestId("onOpenIndexButton")); + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledWith({ + endpoint: "indices.open", + data: { + index: "test5", + }, + }); + }); + + it("shows warning when source index is yellow", async () => { + mockApi(); + const { getByText, queryByText } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test6`]); + + await waitFor(() => { + getByText("Source index details"); + }); + + expect(queryByText("The source index's health status is Yellow!")).not.toBeNull(); + }); + + it("shows warning when source index is set to read-only", async () => { + mockApi(); + const { getByText, queryByText } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test6`]); + + await waitFor(() => { + getByText("Source index details"); + }); + + expect(queryByText("The source index's setting [index.blocks.read_only] is [true]!")).not.toBeNull(); + }); + + it("shows error when source index cannot be opened", async () => { + mockApi(); + const { getByText, getByTestId, queryByText } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test7`]); + + await waitFor(() => { + getByText("Source index details"); + }); + + expect(queryByText("The source index must be open.")).not.toBeNull(); + expect(getByTestId("shrinkIndexConfirmButton")).toHaveAttribute("disabled"); + expect(getByTestId("onOpenIndexButton")).not.toBeNull(); + + userEvent.click(getByTestId("onOpenIndexButton")); + + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledWith({ + endpoint: "indices.open", + data: { + index: "test7", + }, + }); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledWith( + "[cluster_block_exception] index [test7] blocked by: [FORBIDDEN/5/index read-only (api)];" + ); + }); + }); + + it("shows error when source index cannot be set to block write operations", async () => { + mockApi(); + const { getByText, getByTestId, queryByText } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test7`]); + + await waitFor(() => { + getByText("Source index details"); + }); + + expect(queryByText("The source index must block write operations before shrinking.")).not.toBeNull(); + expect(getByTestId("shrinkIndexConfirmButton")).toHaveAttribute("disabled"); + expect(getByTestId("onSetIndexWriteBlockButton")).not.toBeNull(); + + userEvent.click(getByTestId("onSetIndexWriteBlockButton")); + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledWith({ + endpoint: "indices.putSettings", + method: "PUT", + data: { + index: "test7", + body: { + settings: { + "index.blocks.write": true, + }, + }, + }, + }); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledWith( + "[cluster_block_exception] index [test7] blocked by: [FORBIDDEN/5/index read-only (api)];" + ); + }); + }); + + it("shows warning when source index's has no index.routing.allocation.require._* setting", async () => { + mockApi(); + const { getByText, queryByText } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test6`]); + + await waitFor(() => { + getByText("Source index details"); + }); + + expect(queryByText("A copy of every shard in the source index may not reside on the same node.")).not.toBeNull(); + }); + + it("no warning when source index is ready", async () => { + mockApi(); + const { getByText, queryByText, getByTestId } = renderWithRouter([`${ROUTES.SHRINK_INDEX}?source=test3`]); + + await waitFor(() => { + getByText("Configure target index"); + }); + + expect(queryByText("The source index's health status is Red.")).toBeNull(); + expect(queryByText("The source index has only one primary shard, you cannot shrink it anymore.")).toBeNull(); + expect(queryByText("The source index must block write operations before shrinking.")).toBeNull(); + expect(queryByText("The source index must be open.")).toBeNull(); + expect(queryByText("The source index's health status is Yellow!")).toBeNull(); + expect(queryByText("The source index's setting [index.blocks.read_only] is [true]!")).toBeNull(); + expect(queryByText("A copy of every shard in the source index may not reside on the same node.")).toBeNull(); + + userEvent.type(getByTestId("targetIndexNameInput"), "test3_shrunken"); + + await act(async () => { + fireEvent.click(getByTestId("shrinkIndexConfirmButton")); + }); + + await waitFor(() => { + expect(browserServicesMock.commonService.apiCaller).toHaveBeenCalledWith({ + endpoint: "indices.shrink", + data: { + index: "test3", + target: "test3_shrunken", + body: { + settings: { + "index.number_of_shards": "1", + "index.number_of_replicas": "1", + }, + }, + }, + }); + }); + }); +}); diff --git a/public/pages/ShrinkIndex/container/ShrinkIndex/ShrinkIndex.tsx b/public/pages/ShrinkIndex/container/ShrinkIndex/ShrinkIndex.tsx new file mode 100644 index 000000000..8936fc067 --- /dev/null +++ b/public/pages/ShrinkIndex/container/ShrinkIndex/ShrinkIndex.tsx @@ -0,0 +1,672 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, + EuiTitle, + EuiText, + EuiLoadingSpinner, +} from "@elastic/eui"; +import React, { Component } from "react"; + +import { CatIndex } from "../../../../../server/models/interfaces"; +import FormGenerator, { IField, IFormGeneratorRef } from "../../../../components/FormGenerator"; +import { ContentPanel } from "../../../../components/ContentPanel"; +import IndexDetail from "../../../../containers/IndexDetail"; +import { IndexItem } from "../../../../../models/interfaces"; +import { IFieldComponentProps } from "../../../../components/FormGenerator/built_in_components"; +import AliasSelect from "../../../CreateIndex/components/AliasSelect"; +import EuiToolTipWrapper from "../../../../components/EuiToolTipWrapper"; +import { CommonService } from "../../../../services"; +import { RouteComponentProps } from "react-router-dom"; +import { + SHRINK_DOCUMENTATION_URL, + INDEX_SETTINGS_URL, + ROUTES, + INDEX_NAMING_MESSAGE, + REPLICA_NUMBER_MESSAGE, +} from "../../../../utils/constants"; +import { BREADCRUMBS } from "../../../../utils/constants"; +import queryString from "query-string"; +import { jobSchedulerInstance } from "../../../../context/JobSchedulerContext"; +import { RecoveryJobMetaData } from "../../../../models/interfaces"; +import { getErrorMessage } from "../../../../utils/helpers"; +import { ServerResponse } from "../../../../../server/models/types"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { + DEFAULT_INDEX_SETTINGS, + INDEX_BLOCKS_WRITE_SETTING, + INDEX_BLOCKS_READONLY_SETTING, + INDEX_ROUTING_ALLOCATION_SETTING, +} from "../../utils/constants"; +import { get } from "lodash"; + +const WrappedAliasSelect = EuiToolTipWrapper(AliasSelect as any, { + disabledKey: "isDisabled", +}); + +interface ShrinkIndexProps extends RouteComponentProps { + commonService: CommonService; +} + +interface ShrinkIndexState { + sourceIndex: CatIndex; + requestPayload: Required["settings"]; + sourceIndexSettings: Object; +} + +export default class ShrinkIndex extends Component { + static contextType = CoreServicesContext; + + constructor(props: ShrinkIndexProps) { + super(props); + + this.state = { + sourceIndex: {} as CatIndex, + requestPayload: DEFAULT_INDEX_SETTINGS, + sourceIndexSettings: {}, + }; + } + + async componentDidMount() { + this.context.chrome.setBreadcrumbs([ + BREADCRUMBS.INDEX_MANAGEMENT, + BREADCRUMBS.INDICES, + { ...BREADCRUMBS.SHRINK_INDEX, href: `#${this.props.location.pathname}${this.props.location.search}` }, + ]); + const { source } = queryString.parse(this.props.location.search); + if (typeof source === "string" && !!source) { + await this.getIndex(source); + } else { + const errorMessage = !source ? "Index is empty." : `Cound not find the index: ${source}.`; + this.context.notifications.toasts.addDanger(errorMessage); + this.props.history.push(ROUTES.INDICES); + } + } + + getIndex = async (indexName: string) => { + try { + const { commonService } = this.props; + const result: ServerResponse = await commonService.apiCaller({ + endpoint: "cat.indices", + data: { + index: [indexName], + format: "json", + }, + }); + + if (result.ok && result.response.length > 0) { + this.setState({ + sourceIndex: result.response[0], + }); + await this.isSourceIndexReady(); + } else { + const errorMessage = result.ok ? `Index ${indexName} does not exist` : result.error; + this.context.notifications.toasts.addDanger(`Could not shrink index: ${errorMessage}`); + this.props.history.push(ROUTES.INDICES); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem getting index.")); + this.props.history.push(ROUTES.INDICES); + } + }; + + formRef: IFormGeneratorRef | null = null; + + onClickAction = async () => { + const { sourceIndex } = this.state; + const { targetIndex, ...others } = this.state.requestPayload; + + const result = await this.formRef?.validatePromise(); + if (result?.errors) { + return; + } + this.shrinkIndex(sourceIndex.index, targetIndex, others); + }; + + onCancel = () => { + this.props.history.push(ROUTES.INDICES); + }; + + shrinkIndex = async (sourceIndexName: string, targetIndexName: string, requestPayload: Required["settings"]) => { + try { + const { commonService } = this.props; + const { aliases, ...settings } = requestPayload; + + const result = await commonService.apiCaller({ + endpoint: "indices.shrink", + data: { + index: sourceIndexName, + target: targetIndexName, + body: { + settings: { + ...settings, + }, + aliases, + }, + }, + }); + if (result && result.ok) { + const toastInstance = this.context.notifications.toasts.addSuccess( + `Successfully started shrinking ${sourceIndexName}. The shrunken index will be named ${targetIndexName}.`, + { + toastLifeTimeMs: 1000 * 60 * 60 * 24 * 5, + } + ); + this.onCancel(); + jobSchedulerInstance.addJob({ + interval: 30000, + extras: { + toastId: toastInstance.id, + sourceIndex: sourceIndexName, + destIndex: targetIndexName, + }, + type: "shrink", + } as RecoveryJobMetaData); + } else { + this.context.notifications.toasts.addDanger(result.error); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem shrinking index.")); + } + }; + + getIndexSettings = async (indexName: string, flat: boolean): Promise | void> => { + try { + const { commonService } = this.props; + const result: ServerResponse> = await commonService.apiCaller({ + endpoint: "indices.getSettings", + data: { + index: indexName, + flat_settings: flat, + }, + }); + if (result && result.ok) { + return result.response; + } else { + const errorMessage = `There is a problem getting index setting for ${indexName}, please check with Admin.`; + this.context.notifications.toasts.addDanger(result?.error || errorMessage); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem getting index settings.")); + } + }; + + setIndexSettings = async (indexName: string, settings: {}) => { + try { + const { commonService } = this.props; + const result = await commonService.apiCaller({ + endpoint: "indices.putSettings", + method: "PUT", + data: { + index: indexName, + body: { + settings: { + ...settings, + }, + }, + }, + }); + if (result && result.ok) { + this.context.notifications.toasts.addSuccess(`${indexName} has been set to block write operations.`); + } else { + const errorMessage = `There is a problem set index setting for ${indexName}, please check with Admin`; + this.context.notifications.toasts.addDanger(result?.error || errorMessage); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem updating index settings.")); + } + }; + + getAlias = async (aliasName: string) => { + try { + const { commonService } = this.props; + return await commonService.apiCaller<{ alias: string }[]>({ + endpoint: "cat.aliases", + method: "GET", + data: { + format: "json", + name: `${aliasName || ""}*`, + s: "alias:desc", + }, + }); + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem getting aliases.")); + } + }; + + openIndex = async (index: string) => { + try { + const { commonService } = this.props; + const result = await commonService.apiCaller({ + endpoint: "indices.open", + data: { + index: index, + }, + }); + if (result && result.ok) { + this.context.notifications.toasts.addSuccess(`[${index}] has been set to Open.`); + } else { + this.context.notifications.toasts.addDanger(result.error); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem opening index.")); + } + }; + + isSourceIndexReady = async () => { + const { sourceIndex } = this.state; + const indexSettings = await this.getIndexSettings(sourceIndex.index, true); + if (!!indexSettings) { + this.setState({ + sourceIndexSettings: indexSettings, + }); + } + }; + + onUpdateIndexSettings = async (indexName: string, settings: {}) => { + await this.setIndexSettings(indexName, settings); + + // refresh index settings + const indexSettings = await this.getIndexSettings(indexName, true); + if (!!indexSettings) { + this.setState({ + sourceIndexSettings: indexSettings, + }); + } + }; + + onOpenIndex = async () => { + const { sourceIndex } = this.state; + await this.openIndex(sourceIndex.index); + + // refresh status + await this.getIndex(sourceIndex.index); + }; + + render() { + const { sourceIndex } = this.state; + if (!sourceIndex.index) { + return ( + + + + ); + } + const { requestPayload, sourceIndexSettings } = this.state; + const sourceIndexNotReadyToShrinkReasons: React.ReactChild[] = []; + let disableShrinkButton = false; + const blockNameList = ["targetIndex"]; + + if (sourceIndex.health === "red") { + disableShrinkButton = true; + sourceIndexNotReadyToShrinkReasons.push( + <> + +

+ The state of the shards in the source index are abnormal, you must check the index's health status before shrinking, and it is + recommended to shrink an index when its health status is Green. +

+
+ + + ); + } + + if (sourceIndex.pri === "1") { + disableShrinkButton = true; + sourceIndexNotReadyToShrinkReasons.push( + <> + + + + ); + } + + // check index settings only if the source index is not red or has more than one primary shard. + if (sourceIndexNotReadyToShrinkReasons.length == 0) { + const indexWriteBlock = get(sourceIndexSettings, [sourceIndex.index, "settings", INDEX_BLOCKS_WRITE_SETTING]); + if (indexWriteBlock !== "true" && indexWriteBlock !== true) { + disableShrinkButton = true; + sourceIndexNotReadyToShrinkReasons.push( + <> + +

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

+ + { + const indexWriteBlockSettings = { + "index.blocks.write": true, + }; + this.onUpdateIndexSettings(sourceIndex.index, indexWriteBlockSettings); + }} + fill + data-test-subj="onSetIndexWriteBlockButton" + > + Block write operations + +
+ + + ); + } + + if (sourceIndex.status === "close") { + disableShrinkButton = true; + sourceIndexNotReadyToShrinkReasons.push( + <> + +

+ You must first open the index before shrinking 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. +

+ + { + this.onOpenIndex(); + }} + fill + data-test-subj="onOpenIndexButton" + > + Open index + +
+ + + ); + } + + // show warnings only if the source index is not red, has more than one primary shard, has write block + // and not in close status. + if (sourceIndexNotReadyToShrinkReasons.length === 0) { + // It's better to do shrink when the source index's health status is green, but the user can still do shrink when the source index's health status + // is yellow, we only give a warning to the user. + if (sourceIndex.health === "yellow") { + sourceIndexNotReadyToShrinkReasons.push( + <> + +

+ It's recommended to shrink an index when its health status is Green, because if the index's health status is Yellow, it + may cause problems when initializing the new shrunken index's shards. +

+
+ + + ); + } + + // Check whether `index.blocks.read_only` is set to `true`, + // because shrink operation will timeout and then the new shrunken index's shards cannot be allocated. + const indexReadOnlyBlock = get(sourceIndexSettings, [sourceIndex.index, "settings", INDEX_BLOCKS_READONLY_SETTING]); + if (indexReadOnlyBlock === "true" || indexReadOnlyBlock === true) { + sourceIndexNotReadyToShrinkReasons.push( + <> + +

+ When the source index's setting [index.blocks.read_only] is [true], it will be copied to the new shrunken index and then + the new shrunken index's metadata write will be blocked, this will cause the new shrunken index's shards to be unassigned, + you can set the setting to [null] or [false] in the advanced settings bellow. +

+
+ + + ); + } + + // This check may not be accurate in the following cases: + // 1. the cluster only has one node, so the source index's primary shards are allocated to the same node. + // 2. the primary shards of the source index are just allocated to the same node, not manually. + // 3. the user set `index.routing.rebalance.enable` to `none` and then manually move each shard's copy to one node. + // In the above cases, the source index does not have a `index.routing.allocation.require._*` setting which can + // rellocate one copy of every shard to one node, but it can also execute shrinking successfully if other conditions are met. + // But in most cases, source index always have many shards distributed on different node, + // so index.routing.allocation.require._*` setting is required. + // In above, we just show a warning in the page, it does not affect any button or form. + const settings = get(sourceIndexSettings, [sourceIndex.index, "settings"]); + let shardsAllocatedToOneNode = false; + for (let settingKey in settings) { + if (settingKey.startsWith(INDEX_ROUTING_ALLOCATION_SETTING)) { + shardsAllocatedToOneNode = true; + break; + } + } + if (!shardsAllocatedToOneNode) { + sourceIndexNotReadyToShrinkReasons.push( + <> + +

+ When shrinking an index, a copy of every shard in the index must reside on the same node, you can use the index setting + `index.routing.allocation.require._*` to relocate the copy of every shard to one node. +

+

+ Ignore this warning if the copy of every shard in the source index just reside on the same node in some cases, like the + OpenSearch cluster has only one node or the cluster has two nodes and each primary shard in the source index has one + replia. +

+
+ + + ); + } + } + } + + const numberOfShardsSelectOptions = []; + const sourceIndexSharsNum = Number(sourceIndex.pri); + for (let i = 1; i <= sourceIndexSharsNum; i++) { + if (sourceIndexSharsNum % i === 0) { + numberOfShardsSelectOptions.push({ + value: i.toString(), + text: i, + }); + } + } + + const formFields: IField[] = [ + { + rowProps: { + label: "Target index name", + helpText:
{INDEX_NAMING_MESSAGE}
, + position: "bottom", + }, + name: "targetIndex", + type: "Input", + options: { + rules: [ + { + validator: (_, value) => { + if (!value || !value.toString().trim()) { + return Promise.reject("Target index name required."); + } + return Promise.resolve(); + }, + }, + { + pattern: /^[^A-Z-_"*+/\\|?#<>][^A-Z"*+/\\|?#<>]*$/, + message: "Invalid target index name.", + }, + ], + props: { + "data-test-subj": "targetIndexNameInput", + placeholder: "Specify a name for the new shrunken index.", + }, + }, + }, + { + rowProps: { + label: "Number of primary shards", + helpText: ( + <> +

Specify the number of shards for the new shrunken index.

+

The number must be a factor of the primary shard count of the source index.

+ + ), + }, + name: "index.number_of_shards", + type: "Select", + options: { + rules: [ + { + validator: (_, value) => { + if (!value || !value.toString().trim() || Number(value) <= 0 || Number(sourceIndex.pri) % value != 0) { + return Promise.reject( + "The number of primary shards in the new shrunken index " + + " must be a positive factor of the number of primary shards in the source index." + ); + } + return Promise.resolve(); + }, + }, + ], + props: { + "data-test-subj": "numberOfShardsInput", + options: numberOfShardsSelectOptions, + placeholder: "Select primary shard count", + }, + }, + }, + { + rowProps: { + label: "Number of replicas", + helpText:
{REPLICA_NUMBER_MESSAGE}
, + }, + name: "index.number_of_replicas", + type: "Number", + options: { + rules: [ + { + validator: (_, value) => { + if (!value || !value.toString().trim() || Number(value) < 0) { + return Promise.reject("Number of replicas must be greater than or equal to 0."); + } + return Promise.resolve(); + }, + }, + ], + props: { + "data-test-subj": "numberOfReplicasInput", + min: 0, + }, + }, + }, + { + name: "aliases", + rowProps: { + label: `Index alias - optional`, + helpText: "Allow this index to be referenced by existing aliases or specify a new alias.", + }, + options: { + props: { + "data-test-subj": "aliasesInput", + refreshOptions: this.getAlias, + }, + }, + component: WrappedAliasSelect as React.ComponentType, + }, + ]; + + const indices = sourceIndex.index ? [sourceIndex.index] : []; + const indexDetailChildren = ( +
    + {sourceIndexNotReadyToShrinkReasons.map((reason, index) => ( +
  • {reason}
  • + ))} +
+ ); + + const configurationChildren: React.ReactChild = ( + <> + + + + (this.formRef = ref)} + value={requestPayload} + onChange={(value) => { + if (!!value) { + this.setState({ + requestPayload: value, + }); + } + }} + formFields={formFields} + hasAdvancedSettings + advancedSettingsProps={{ + accordionProps: { + initialIsOpen: false, + id: "accordion_for_create_index_settings", + buttonContent:

Advanced settings

, + }, + blockedNameList: blockNameList, + rowProps: { + label: "Specify advanced index settings", + helpText: ( + <> + Specify a comma-delimited list of settings. + + View index settings. + + + ), + }, + }} + /> +
+ + + ); + + const subTitleText = ( + +

+ Shrink an existing index into a new index with fewer primary shards.{" "} + + Learn more. + +

+
+ ); + + return ( +
+ +

Shrink index

+
+ {subTitleText} + + + {!!disableShrinkButton ? null : configurationChildren} + + + + + + Cancel + + + + + Shrink + + + +
+ ); + } +} diff --git a/public/pages/ShrinkIndex/container/ShrinkIndex/__snapshots__/ShrinkIndex.test.tsx.snap b/public/pages/ShrinkIndex/container/ShrinkIndex/__snapshots__/ShrinkIndex.test.tsx.snap new file mode 100644 index 000000000..286540ba2 --- /dev/null +++ b/public/pages/ShrinkIndex/container/ShrinkIndex/__snapshots__/ShrinkIndex.test.tsx.snap @@ -0,0 +1,702 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+

+ Shrink index +

+
+
+

+ Shrink an existing index into a new index with fewer primary shards. + + + Learn more. + EuiIconMock + + (opens in a new tab or window) + + +

+
+
+
+
+
+
+

+ Source index details +

+
+
+
+
+
+
+
+
+
+ Index name +
+
+ test3 +
+
+
+
+
+
+ Primary shards +
+
+ 5 +
+
+
+
+
+
+ Replica shards +
+
+ 0 +
+
+
+
+
+
+ Total index size +
+
+ 100KB +
+
+
+
+
+
    +
+
+
+
+
+
+

+ Configure target index +

+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ Must be in lowercase letters. Cannot begin with underscores or hyphens. Spaces, commas, and characters :, ", *, +, /, , |, ?, #, > are not allowed. +
+
+
+
+
+
+ +
+
+
+

+ Specify the number of shards for the new shrunken index. +

+

+ The number must be a factor of the primary shard count of the source index. +

+
+ +
+
+ +
+ + EuiIconMock + +
+
+
+
+
+
+
+
+ +
+
+
+
+ Specify the number of replicas each primary shard should have. Default is 1. +
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+
+ Allow this index to be referenced by existing aliases or specify a new alias. +
+ + +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ + +
+ +
+