diff --git a/cypress/integration/force_merge.js b/cypress/integration/force_merge.js new file mode 100644 index 000000000..d60f29360 --- /dev/null +++ b/cypress/integration/force_merge.js @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { PLUGIN_NAME } from "../support/constants"; + +const rolloverValidAlias = "rollover-valid-alias"; +const rolloverAliasNeedTargetIndex = "rollover-alias-need-target-index"; +const rolloverDataStream = "data-stream-rollover"; +const validIndex = "index-000001"; +const invalidIndex = "index-test-rollover"; + +describe("force_merge", () => { + before(() => { + // Set welcome screen tracking to false + localStorage.setItem("home:welcome:show", "false"); + cy.deleteTemplate("index-common-template"); + cy.deleteAllIndices(); + cy.request({ + url: `${Cypress.env("opensearch")}/_data_stream/*`, + method: "DELETE", + failOnStatusCode: false, + }); + cy.createIndex(validIndex); + cy.createIndex(invalidIndex); + cy.addAlias(rolloverValidAlias, validIndex); + cy.addAlias(rolloverAliasNeedTargetIndex, invalidIndex); + cy.createIndexTemplate("index-common-template", { + index_patterns: ["data-stream-*"], + data_stream: {}, + template: { + aliases: { + alias_for_common_1: {}, + alias_for_common_2: {}, + }, + settings: { + number_of_shards: 2, + number_of_replicas: 1, + }, + }, + }); + cy.request({ + url: `${Cypress.env("opensearch")}/_data_stream/${rolloverDataStream}`, + method: "PUT", + failOnStatusCode: false, + }); + }); + + describe("force merge", () => { + it("force merge data stream / index / alias successfully", () => { + // Visit ISM OSD + cy.visit(`${Cypress.env("opensearch_dashboards")}/app/${PLUGIN_NAME}#/force-merge`); + cy.contains("Configure source index", { timeout: 60000 }); + + // click create + cy.get('[data-test-subj="forceMergeConfirmButton"]').click({ force: true }); + + cy.contains("Index or data stream is required."); + cy.get('[data-test-subj="sourceSelector"] [data-test-subj="comboBoxSearchInput"]').type( + `${rolloverValidAlias}{downArrow}{enter}${rolloverAliasNeedTargetIndex}{downArrow}{enter}${rolloverDataStream}{downArrow}{enter}${validIndex}{downArrow}{enter}${invalidIndex}{downArrow}{enter}` + ); + + cy.get('[data-test-subj="forceMergeConfirmButton"]').click({ force: true }); + + cy.contains(/Some shards could not be force merged/); + }); + }); + + after(() => { + cy.deleteTemplate("index-common-template"); + cy.deleteAllIndices(); + cy.request({ + url: `${Cypress.env("opensearch")}/_data_stream/*`, + method: "DELETE", + failOnStatusCode: false, + }); + }); +}); diff --git a/public/components/FormGenerator/built_in_components/index.tsx b/public/components/FormGenerator/built_in_components/index.tsx index 561b41e5f..2b302816e 100644 --- a/public/components/FormGenerator/built_in_components/index.tsx +++ b/public/components/FormGenerator/built_in_components/index.tsx @@ -1,9 +1,9 @@ -import React, { forwardRef } from "react"; -import { EuiFieldNumber, EuiFieldText, EuiSwitch, EuiSelect, EuiText } from "@elastic/eui"; +import React, { forwardRef, useRef } from "react"; +import { EuiFieldNumber, EuiFieldText, EuiSwitch, EuiSelect, EuiText, EuiCheckbox } from "@elastic/eui"; import EuiToolTipWrapper, { IEuiToolTipWrapperProps } from "../../EuiToolTipWrapper"; import EuiComboBox from "../../ComboBoxWithoutWarning"; -export type ComponentMapEnum = "Input" | "Number" | "Switch" | "Select" | "Text" | "ComboBoxSingle"; +export type ComponentMapEnum = "Input" | "Number" | "Switch" | "Select" | "Text" | "ComboBoxSingle" | "CheckBox"; export interface IFieldComponentProps extends IEuiToolTipWrapperProps { onChange: (val: IFieldComponentProps["value"]) => void; @@ -11,6 +11,8 @@ export interface IFieldComponentProps extends IEuiToolTipWrapperProps { [key: string]: any; } +let globalId = 0; + const componentMap: Record> = { Input: EuiToolTipWrapper( forwardRef(({ onChange, value, ...others }, ref: React.Ref) => ( @@ -39,6 +41,20 @@ const componentMap: Record onChange(e.target.value)} value={value || ""} {...others} /> )) as React.ComponentType ), + CheckBox: EuiToolTipWrapper( + forwardRef(({ onChange, value, ...others }, ref: React.Ref) => { + const idRef = useRef(globalId++); + return ( + onChange(e.target.checked)} + {...others} + /> + ); + }) as React.ComponentType + ), ComboBoxSingle: EuiToolTipWrapper( forwardRef(({ onChange, value, options, ...others }, ref: React.Ref) => { return ( diff --git a/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/ForceMergeAdvancedOptions.test.tsx b/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/ForceMergeAdvancedOptions.test.tsx new file mode 100644 index 000000000..16b579b86 --- /dev/null +++ b/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/ForceMergeAdvancedOptions.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render, waitFor } from "@testing-library/react"; +import React from "react"; +import ForceMergeAdvancedOptions, { ForceMergeOptionsProps } from "./ForceMergeAdvancedOptions"; +import useField from "../../../../lib/field"; + +const WrappedComponent = (props: Partial) => { + const field = useField(); + return ; +}; + +describe(" spec", () => { + it("renders the component", async () => { + const component = render(); + // wait for one tick + await waitFor(() => {}); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/ForceMergeAdvancedOptions.tsx b/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/ForceMergeAdvancedOptions.tsx new file mode 100644 index 000000000..59edbb9bf --- /dev/null +++ b/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/ForceMergeAdvancedOptions.tsx @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { EuiSpacer } from "@elastic/eui"; +import React from "react"; +import CustomFormRow from "../../../../components/CustomFormRow"; +import { AllBuiltInComponents } from "../../../../components/FormGenerator"; +import { FieldInstance } from "../../../../lib/field"; +import SwitchNumber from "../SwitchNumber"; + +export interface ForceMergeOptionsProps { + field: FieldInstance; +} + +const ForceMergeAdvancedOptions = (props: ForceMergeOptionsProps) => { + const { field } = props; + + return ( +
+ + + + + + + + + + + + +
+ ); +}; + +export default ForceMergeAdvancedOptions; diff --git a/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/__snapshots__/ForceMergeAdvancedOptions.test.tsx.snap b/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/__snapshots__/ForceMergeAdvancedOptions.test.tsx.snap new file mode 100644 index 000000000..666ea931b --- /dev/null +++ b/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/__snapshots__/ForceMergeAdvancedOptions.test.tsx.snap @@ -0,0 +1,412 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+
+
+ Define how many segments to merge to. +
+
+
+ +
+ +
+
+ +
+ +
+
+
+
+
+
+
+ +
+
+
+ Opensearch will perform a flush on the indexes after the force merge. +
+ +
+ +
+ +
+ +
+
+
+
+
+ +
+
+
+ Expunge all segments containing more than 10% of deleted documents. The percentage is configurable with the setting index.merge.policy.expunge_deletes_allowed. +
+ +
+ +
+ +
+ +
+
+
+
+
+ , + "container":
+
+
+
+ +
+
+
+ Define how many segments to merge to. +
+
+
+ +
+ +
+
+ +
+ +
+
+
+
+
+
+
+ +
+
+
+ Opensearch will perform a flush on the indexes after the force merge. +
+ +
+ +
+ +
+ +
+
+
+
+
+ +
+
+
+ Expunge all segments containing more than 10% of deleted documents. The percentage is configurable with the setting index.merge.policy.expunge_deletes_allowed. +
+ +
+ +
+ +
+ +
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/index.ts b/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/index.ts new file mode 100644 index 000000000..e21eee7a2 --- /dev/null +++ b/public/pages/ForceMerge/components/ForceMergeAdvancedOptions/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import ForceMergeAdvancedOptions from "./ForceMergeAdvancedOptions"; + +export default ForceMergeAdvancedOptions; +export * from "./ForceMergeAdvancedOptions"; diff --git a/public/pages/ForceMerge/components/IndexSelect/IndexSelect.test.tsx b/public/pages/ForceMerge/components/IndexSelect/IndexSelect.test.tsx new file mode 100644 index 000000000..076ceab0b --- /dev/null +++ b/public/pages/ForceMerge/components/IndexSelect/IndexSelect.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render, waitFor } from "@testing-library/react"; +import React from "react"; +import { coreServicesMock } from "../../../../../test/mocks"; +import { CoreServicesContext } from "../../../../components/core_services"; +import IndexSelect from "./IndexSelect"; +import { act } from "react-dom/test-utils"; + +describe(" spec", () => { + it("renders the component", async () => { + const component = render( + + Promise.resolve([{ label: "sourceIndex" }])} + onSelectedOptions={(options) => {}} + selectedOption={[{ label: "sourceIndex" }]} + singleSelect={true} + /> + + ); + await waitFor(() => {}); + expect(component).toMatchSnapshot(); + }); + + it("renders the component with error", async () => { + const getIndexOptionsFn = jest.fn().mockRejectedValue("service not available"); + act(() => { + render( + + {}} + selectedOption={[{ label: "sourceIndex" }]} + singleSelect={true} + /> + + ); + }); + await waitFor(() => {}); + expect(coreServicesMock.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + }); + + it("search async", async () => { + const getIndexOptionsFn = jest.fn().mockResolvedValue([{ label: "test-index" }]); + act(() => { + render( + + {}} + selectedOption={[{ label: "sourceIndex" }]} + singleSelect={false} + /> + + ); + }); + await waitFor(() => {}); + expect(getIndexOptionsFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/pages/ForceMerge/components/IndexSelect/IndexSelect.tsx b/public/pages/ForceMerge/components/IndexSelect/IndexSelect.tsx new file mode 100644 index 000000000..2f9d4b395 --- /dev/null +++ b/public/pages/ForceMerge/components/IndexSelect/IndexSelect.tsx @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBoxOptionOption } from "@elastic/eui"; +import { _EuiComboBoxProps } from "@elastic/eui/src/components/combo_box/combo_box"; +import { CoreStart } from "opensearch-dashboards/public"; +import { debounce } from "lodash"; +import ComboBoxWithoutWarning from "../../../../components/ComboBoxWithoutWarning"; +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { IndexSelectItem } from "../../models/interfaces"; +import { filterByMinimatch } from "../../../../../utils/helper"; +import { SYSTEM_ALIAS, SYSTEM_INDEX } from "../../../../../utils/constants"; + +interface IndexSelectProps extends Pick<_EuiComboBoxProps, "data-test-subj" | "placeholder"> { + getIndexOptions: (searchValue: string) => Promise[]>; + onChange: (options: string[]) => void; + singleSelect: boolean; + value?: string[]; + excludeSystemIndex?: boolean; +} + +export default function IndexSelect(props: IndexSelectProps) { + const [indexOptions, setIndexOptions] = useState([] as EuiComboBoxOptionOption[]); + const coreServices = useContext(CoreServicesContext) as CoreStart; + const destroyRef = useRef(false); + + const searchIndex = (searchValue?: string) => { + props + .getIndexOptions(searchValue ? searchValue : "") + .then((options) => { + props.excludeSystemIndex && filterSystemIndices(options); + if (!destroyRef.current) { + setIndexOptions(options); + } + }) + .catch((err) => { + coreServices.notifications.toasts.addDanger(`fetch indices error ${err}`); + }); + }; + + useEffect(() => { + searchIndex(); + }, [props.getIndexOptions, props.excludeSystemIndex]); + + useEffect(() => { + return () => { + destroyRef.current = true; + }; + }, []); + + const onSearchChangeRef = useRef( + debounce( + (searchValue: string) => { + searchIndex(searchValue); + }, + 200, + { + leading: true, + } + ) + ); + + const filterSystemIndices = (list: EuiComboBoxOptionOption[]) => { + list.map((it) => { + it.options = it.options?.filter((item) => !filterByMinimatch(item.label, SYSTEM_ALIAS)); + it.options = it.options?.filter((item) => !filterByMinimatch(item.label, SYSTEM_INDEX)); + }); + }; + + const flattenedOptions = useMemo( + () => indexOptions.reduce((total, current) => [...total, ...(current.options || [])], [] as EuiComboBoxOptionOption[]), + [indexOptions] + ); + + const finalSelectedOptions: EuiComboBoxOptionOption[] = + props.value + ?.map((item) => flattenedOptions.find((option) => option.label === item) as EuiComboBoxOptionOption) + .filter((item) => item) || []; + + return ( +
+ props.onChange(selectedOptions.map((item) => item.label))} + onSearchChange={onSearchChangeRef.current} + isClearable={true} + singleSelection={props.singleSelect ? { asPlainText: true } : false} + /> +
+ ); +} diff --git a/public/pages/ForceMerge/components/IndexSelect/__snapshots__/IndexSelect.test.tsx.snap b/public/pages/ForceMerge/components/IndexSelect/__snapshots__/IndexSelect.test.tsx.snap new file mode 100644 index 000000000..fed9f737e --- /dev/null +++ b/public/pages/ForceMerge/components/IndexSelect/__snapshots__/IndexSelect.test.tsx.snap @@ -0,0 +1,164 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+ +
+ , + "container":
+
+ +
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/public/pages/ForceMerge/components/IndexSelect/index.ts b/public/pages/ForceMerge/components/IndexSelect/index.ts new file mode 100644 index 000000000..bfc369d83 --- /dev/null +++ b/public/pages/ForceMerge/components/IndexSelect/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import IndexSelect from "./IndexSelect"; + +export default IndexSelect; diff --git a/public/pages/ForceMerge/components/SwitchNumber/index.tsx b/public/pages/ForceMerge/components/SwitchNumber/index.tsx new file mode 100644 index 000000000..011feeb88 --- /dev/null +++ b/public/pages/ForceMerge/components/SwitchNumber/index.tsx @@ -0,0 +1,49 @@ +import React, { useState } from "react"; +import { EuiRadioGroup, EuiSpacer } from "@elastic/eui"; +import CustomFormRow from "../../../../components/CustomFormRow"; +import { AllBuiltInComponents } from "../../../../components/FormGenerator"; + +interface SwitchNumberProps { + value?: number; + onChange: (val: SwitchNumberProps["value"]) => void; +} + +export default function SwitchNumber(props: SwitchNumberProps) { + const [id, setId] = useState(props.value && props.value > 0 ? "1" : "0"); + return ( + <> + { + setId(id); + if (id === "0") { + props.onChange(undefined); + } else { + props.onChange(1); + } + }} + /> + {id === "1" ? ( + <> + + + + + + ) : null} + + ); +} diff --git a/public/pages/ForceMerge/container/ForceMerge/ForceMerge.test.tsx b/public/pages/ForceMerge/container/ForceMerge/ForceMerge.test.tsx new file mode 100644 index 000000000..247271ae0 --- /dev/null +++ b/public/pages/ForceMerge/container/ForceMerge/ForceMerge.test.tsx @@ -0,0 +1,221 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, fireEvent, waitFor, findByText } 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 ForceMerge from "./ForceMerge"; +import { ModalProvider, ModalRoot } from "../../../../components/Modal"; +import { BrowserServices } from "../../../../models/interfaces"; + +function renderWithRouter(initialEntries = [ROUTES.FORCE_MERGE] 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: "index-source", + 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: "index-dest", + 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: "index-source-2", + pri: "1", + "pri.store.size": "100KB", + rep: "0", + status: "open", + "store.size": "100KB", + uuid: "some_uuid", + }, +]; + +const dataStreams = [ + { + name: "log-redis-daily", + timestamp_field: "@timestamp", + indices: [".ds-log-redis-daily-000001", ".ds-log-redis-daily-000002"], + writingIndex: ".ds-log-redis-daily-000002", + generation: 2, + status: "GREEN", + template: "", + }, +]; + +const aliases = [ + { + alias: "alias-1", + index: "index-source", + filter: "-", + is_write_index: "false", + "routing.index": "-", + "routing.search": "-", + }, + { + alias: "alias-1", + index: "index-source-2", + filter: "-", + is_write_index: "true", + "routing.index": "-", + "routing.search": "-", + }, + { + alias: "alias-2", + index: "index-test-1", + filter: "-", + is_write_index: "-", + "routing.index": "-", + "routing.search": "-", + }, + { + alias: "alias-2", + index: "index-test-2", + filter: "-", + is_write_index: "-", + "routing.index": "-", + "routing.search": "-", + }, +]; + +const mockApi = (validateQueryFail?: boolean) => { + 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.indexService.getDataStreams = jest.fn().mockImplementation((args) => ({ + ok: true, + response: { + dataStreams: args.search.length > 0 ? dataStreams.filter((ds) => ds.name.startsWith(args.search)) : dataStreams, + }, + })); + browserServicesMock.indexService.getAliases = jest.fn().mockImplementation((args) => ({ + ok: true, + response: { + aliases: args.search.length > 0 ? aliases.filter((alias) => alias.alias.startsWith(args.search)) : aliases, + }, + })); + + browserServicesMock.commonService.apiCaller = jest.fn().mockImplementation((args) => { + return Promise.resolve({ ok: true }); + }); +}; + +describe(" spec", () => { + beforeEach(() => { + mockApi(); + }); + + it("renders the component", async () => { + const { container } = renderWithRouter(); + // wait for one tick + await waitFor(() => {}); + expect(container.firstChild).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.FORCE_MERGE, + ]); + }); + + it("cancel back to indices page", async () => { + const { getByText } = renderWithRouter(); + await waitFor(() => {}); + userEvent.click(getByText("Cancel")); + + expect(getByText(`location is: ${ROUTES.INDICES}`)).toBeInTheDocument(); + }); + + it("source is required", async () => { + const { getByText, getByTestId, findByText } = renderWithRouter(); + + await waitFor(() => { + getByText("Configure source index"); + }); + + userEvent.click(getByTestId("forceMergeConfirmButton")); + await findByText("Index or data stream is required."); + }); + + it("it goes to indices page when submit force merge successfully", async () => { + const { getByText, getAllByTestId, getByTestId } = renderWithRouter(); + + userEvent.type(getAllByTestId("comboBoxSearchInput")[0], "index-source"); + await waitFor(() => {}); + fireEvent.keyDown(getAllByTestId("comboBoxSearchInput")[0], { key: "ArrowDown", code: "ArrowDown" }); + fireEvent.keyDown(getAllByTestId("comboBoxSearchInput")[0], { key: "Enter", code: "Enter" }); + + userEvent.click(getByTestId("forceMergeConfirmButton")); + + await waitFor(() => {}); + expect(getByText(`location is: ${ROUTES.INDICES}`)).toBeInTheDocument(); + }); +}); diff --git a/public/pages/ForceMerge/container/ForceMerge/ForceMerge.tsx b/public/pages/ForceMerge/container/ForceMerge/ForceMerge.tsx new file mode 100644 index 000000000..38677b195 --- /dev/null +++ b/public/pages/ForceMerge/container/ForceMerge/ForceMerge.tsx @@ -0,0 +1,280 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTitle, +} from "@elastic/eui"; +import _ from "lodash"; +import React, { useContext, useEffect, useRef, useState } from "react"; +import { RouteComponentProps } from "react-router-dom"; +import { CoreStart } from "opensearch-dashboards/public"; +import { CoreServicesContext } from "../../../../components/core_services"; +import CustomFormRow from "../../../../components/CustomFormRow"; +import { ContentPanel } from "../../../../components/ContentPanel"; +import ForceMergeAdvancedOptions from "../../components/ForceMergeAdvancedOptions"; +import IndexSelect from "../../components/IndexSelect"; +import { checkNotReadOnlyIndexes, getIndexOptions } from "../../utils/helper"; +import { BrowserServices } from "../../../../models/interfaces"; +import { ServicesContext } from "../../../../services"; +import useField from "../../../../lib/field"; +import { BREADCRUMBS, ROUTES } from "../../../../utils/constants"; +import { Modal } from "../../../../components/Modal"; +import { IndexItem } from "../../../../../models/interfaces"; + +interface ForceMergeProps extends RouteComponentProps<{ indexes?: string }> { + services: BrowserServices; +} + +export default function ForceMergeWrapper(props: Omit) { + const services = useContext(ServicesContext) as BrowserServices; + const context = useContext(CoreServicesContext) as CoreStart; + const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false); + const [executing, setExecuting] = useState(false); + const [notReadOnlyIndexes, setNotReadOnlyIndexes] = useState< + [ + string, + { + settings: IndexItem["settings"]; + } + ][] + >([]); + const { indexes = "" } = props.match.params; + const destroyedRef = useRef(false); + const field = useField({ + values: { + flush: true, + indexes: indexes ? indexes.split(",") : [], + }, + }); + const getIndexOptionsCachedRef = useRef((searchValue: string) => + getIndexOptions({ + services, + searchValue, + context, + }) + ); + + const onCancel = () => { + props.history.push(ROUTES.INDICES); + }; + const onClickAction = async () => { + const { errors, values } = await field.validatePromise(); + if (errors) { + const errorsKey = Object.keys(errors); + if (errorsKey.includes("max_num_segments")) { + setAdvancedSettingsOpen(true); + } + return; + } + setExecuting(true); + const { indexes, ...others } = values as { indexes: { label: string }[] }; + const result = await services.commonService.apiCaller<{ + _shards?: { + successful: number; + total: number; + failed: number; + failures?: { + index: string; + status: string; + shard: number; + }[]; + }; + }>({ + endpoint: "indices.forcemerge", + data: { + index: indexes, + ...others, + }, + }); + if (result && result.ok) { + const { _shards } = result.response || {}; + const { successful = 0, total = 0, failures = [] } = _shards || {}; + if (successful === total) { + context.notifications.toasts.addSuccess("The indexes are successfully force merged."); + } else { + context.notifications.toasts.addWarning({ + title: "Some shards could not be force merged", + text: (( + <> +
+ {total - successful} out of {total} could not be force merged. +
+ + { + Modal.show({ + locale: { + ok: "Close", + }, + title: "Some shards could not be force merged", + content: ( + +
+ {total - successful} out of {total} could not be force merged. The following reasons may prevent shards from + performing a force merge: +
+
    + {failures.map((item) => ( +
  • + The shard {item.shard} of index {item.index} failed to merge because of {item.status}. +
  • + ))} +
  • Some shards are unassigned.
  • +
  • + Insufficient disk space: Force merging requires disk space to create a new, larger segment. If the disk does not + have enough space, the merge process may fail. +
  • +
  • + Index read-only: If the index is marked as read-only, a force merge operation cannot modify the index, and the + merge process will fail. +
  • +
  • + Too many open files: The operating system may limit the number of files that a process can have open + simultaneously, and a force merge operation may exceed this limit, causing the merge process to fail. +
  • +
  • + Index corruption: If the index is corrupted or has some inconsistencies, the force merge operation may fail. +
  • +
+
+ ), + }); + }} + style={{ float: "right" }} + > + View details +
+ + ) as unknown) as string, + iconType: "", + }); + } + props.history.push(ROUTES.INDICES); + } else { + context.notifications.toasts.addDanger(result.error); + } + if (destroyedRef.current) { + return; + } + setExecuting(false); + }; + + const advanceTitle = ( + + + { + setAdvancedSettingsOpen(!advancedSettingsOpen); + }} + aria-label="drop down icon" + /> + + + +

Advanced settings

+
+
+
+ ); + + useEffect(() => { + context.chrome.setBreadcrumbs([ + BREADCRUMBS.INDEX_MANAGEMENT, + BREADCRUMBS.INDICES, + { ...BREADCRUMBS.FORCE_MERGE, href: `#${props.location.pathname}` }, + ]); + return () => { + destroyedRef.current = true; + }; + }, []); + + useEffect(() => { + checkNotReadOnlyIndexes({ + services, + indexes: field.getValue("indexes"), + }).then((result) => { + setNotReadOnlyIndexes(result); + }); + }, [field.getValue("indexes")]); + + return ( +
+ +

Force merge

+
+ + + + + + + + + + {notReadOnlyIndexes.length ? ( + + {notReadOnlyIndexes.map((item) => item[0]).join(", ")} is not a read-only index. We recommend only performing force merge with + read-only indexes to pervent large segments being produced. + + ) : null} + + + + + {advancedSettingsOpen && } + + + + + + + Cancel + + + + + Force merge + + + +
+ ); +} diff --git a/public/pages/ForceMerge/container/ForceMerge/__snapshots__/ForceMerge.test.tsx.snap b/public/pages/ForceMerge/container/ForceMerge/__snapshots__/ForceMerge.test.tsx.snap new file mode 100644 index 000000000..f37b8631f --- /dev/null +++ b/public/pages/ForceMerge/container/ForceMerge/__snapshots__/ForceMerge.test.tsx.snap @@ -0,0 +1,226 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+

+ Force merge +

+
+
+
+
+

+ Configure source index + + + + +

+
+
+
+
+
+
+
+ +
+
+
+ Specify one or more indexes or data streams you want to force merge. +
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+

+ Advanced settings +

+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+`; diff --git a/public/pages/ForceMerge/container/ForceMerge/index.ts b/public/pages/ForceMerge/container/ForceMerge/index.ts new file mode 100644 index 000000000..d8ec796cf --- /dev/null +++ b/public/pages/ForceMerge/container/ForceMerge/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import ForceMerge from "./ForceMerge"; + +export default ForceMerge; diff --git a/public/pages/ForceMerge/index.ts b/public/pages/ForceMerge/index.ts new file mode 100644 index 000000000..02634ad95 --- /dev/null +++ b/public/pages/ForceMerge/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import ForceMerge from "./container/ForceMerge"; + +export default ForceMerge; diff --git a/public/pages/ForceMerge/models/interfaces.ts b/public/pages/ForceMerge/models/interfaces.ts new file mode 100644 index 000000000..65492795e --- /dev/null +++ b/public/pages/ForceMerge/models/interfaces.ts @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Conflicts } from "elasticsearch"; + +export interface ForceMergeRequest { + waitForCompletion: boolean; + slices?: number | string; + maxDocs?: number; + body: { + conflicts?: Conflicts; + source: { + index: string; + [key: string]: any; + }; + dest: { + index: string; + pipeline?: string; + op_type?: string; + }; + }; + script?: { + source: string; + lang: string; + }; +} +export interface ForceMergeResponse { + task: string; +} + +export interface IndexSelectItem { + status?: string; + health?: string; + isIndex?: boolean; + isDataStream?: boolean; + isAlias?: boolean; + indices?: string[]; + writingIndex?: string; +} diff --git a/public/pages/ForceMerge/utils/constants.ts b/public/pages/ForceMerge/utils/constants.ts new file mode 100644 index 000000000..2f2376a84 --- /dev/null +++ b/public/pages/ForceMerge/utils/constants.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const DATA_STREAM_REGEX = /^.ds-(.*)-\d{6}$/; + +export const DEFAULT_SLICE = "1"; + +export const DEFAULT_QUERY = JSON.stringify({ query: { match_all: {} } }, null, 2); + +export const REINDEX_ERROR_PROMPT = { + DEST_REQUIRED: "Destination is required.", + SOURCE_REQUIRED: "Source is required.", + HEALTH_RED: "health status is red.", + SLICES_FORMAT_ERROR: "Must be an integer greater than or equal to 2.", +}; diff --git a/public/pages/ForceMerge/utils/helper.test.ts b/public/pages/ForceMerge/utils/helper.test.ts new file mode 100644 index 000000000..92fce52a8 --- /dev/null +++ b/public/pages/ForceMerge/utils/helper.test.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parseIndexNames } from "./helper"; + +test("parse index names", () => { + expect(parseIndexNames("")).toEqual([]); + expect(parseIndexNames("test-index")).toEqual(["test-index"]); + expect(parseIndexNames("test-index,test-index-2")).toEqual(["test-index", "test-index-2"]); + expect(parseIndexNames("test-index,.ds-redis-logs-000001")).toEqual(["test-index", "redis-logs"]); + expect(parseIndexNames(".ds-redis-logs-000001,.ds-redis-logs-000001")).toEqual(["redis-logs", "redis-logs"]); + expect(parseIndexNames(".ds-nginx-logs-000001,.ds-redis-logs-000001")).toEqual(["nginx-logs", "redis-logs"]); + expect(parseIndexNames(".ds-abc-001")).toEqual([".ds-abc-001"]); +}); diff --git a/public/pages/ForceMerge/utils/helper.ts b/public/pages/ForceMerge/utils/helper.ts new file mode 100644 index 000000000..fb8ff5b27 --- /dev/null +++ b/public/pages/ForceMerge/utils/helper.ts @@ -0,0 +1,155 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DATA_STREAM_REGEX } from "./constants"; +import { IndexSelectItem } from "../models/interfaces"; +import { EuiComboBoxOptionOption } from "@elastic/eui"; +import _ from "lodash"; +import { BrowserServices } from "../../../models/interfaces"; +import { CoreStart } from "opensearch-dashboards/public"; +import { getErrorMessage } from "../../../utils/helpers"; +import { IndexItem } from "../../../../models/interfaces"; + +/** + * parse index names to extract data stream name if the index is a backing index of data stream, + * otherwise using whatever it is + * + * the reason for this is that GET _cat/indices/*.ds* will not return any result, it will need data stream name + * to pull all data stream indices + * @param indices + */ +export const parseIndexNames = (indices: string): string[] => { + let indexArray: string[] = []; + indices && + indices.split(",").forEach((index) => { + // need extract data stream name first + if (DATA_STREAM_REGEX.test(index)) { + let match = index.match(DATA_STREAM_REGEX); + indexArray.push(match ? match[1] : index); + } else { + indexArray.push(index); + } + }); + return indexArray; +}; + +export const getIndexOptions = async (props: { services: BrowserServices; searchValue: string; context: CoreStart }) => { + const { services, searchValue, context } = props; + let options: EuiComboBoxOptionOption[] = []; + try { + let actualSearchValue = parseIndexNames(searchValue); + + const [indexResponse, dataStreamResponse, aliasResponse] = await Promise.all([ + services.indexService.getIndices({ + from: 0, + size: 50, + search: actualSearchValue.join(","), + indices: [actualSearchValue.join(",")], + sortDirection: "desc", + sortField: "index", + showDataStreams: true, + }), + services.indexService.getDataStreams({ search: searchValue.trim() }), + services.indexService.getAliases({ search: searchValue.trim() }), + ]); + if (indexResponse.ok) { + const indices = indexResponse.response.indices.map((index) => ({ + label: index.index, + value: { isIndex: true, status: index.status, health: index.health }, + })); + options.push({ label: "indices", options: indices }); + } else { + context.notifications.toasts.addDanger(indexResponse.error); + } + + if (dataStreamResponse && dataStreamResponse.ok) { + const dataStreams = dataStreamResponse.response.dataStreams.map((ds) => ({ + label: ds.name, + health: ds.status.toLowerCase(), + value: { + isDataStream: true, + indices: ds.indices.map((item) => item.index_name), + writingIndex: ds.indices + .map((item) => item.index_name) + .sort() + .reverse()[0], + }, + })); + options.push({ label: "dataStreams", options: dataStreams }); + } + + if (aliasResponse && aliasResponse.ok) { + const aliases = _.uniq(aliasResponse.response.aliases.map((alias) => alias.alias)).map((name) => { + const indexBelongsToAlias = aliasResponse.response.aliases.filter((alias) => alias.alias === name).map((alias) => alias.index); + let writingIndex = aliasResponse.response.aliases + .filter((alias) => alias.alias === name && alias.is_write_index === "true") + .map((alias) => alias.index); + if (writingIndex.length === 0 && indexBelongsToAlias.length === 1) { + // set writing index when there is only 1 index for alias + writingIndex = indexBelongsToAlias; + } + return { + label: name, + value: { + isAlias: true, + indices: indexBelongsToAlias, + writingIndex: writingIndex[0], + }, + }; + }); + options.push({ label: "aliases", options: aliases }); + } else { + context.notifications.toasts.addDanger(aliasResponse.error); + } + } catch (err) { + context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem fetching index options.")); + } + return options; +}; + +export const checkNotReadOnlyIndexes = async (props: { + services: BrowserServices; + indexes: string[]; +}): Promise< + [ + string, + { + settings: IndexItem["settings"]; + } + ][] +> => { + const { services, indexes } = props; + const result = await services.commonService.apiCaller< + Record< + string, + { + settings: IndexItem["settings"]; + } + > + >({ + endpoint: "indices.getSettings", + data: { + flat_settings: true, + index: indexes, + }, + }); + if (result.ok) { + const valueArray = Object.entries(result?.response || {}); + if (valueArray.length) { + return valueArray.filter(([indexName, indexDetail]) => { + let included = indexes.includes(indexName); + if (!included) { + return false; + } + + return ["index.blocks.read_only", "index.blocks.read_only_allow_delete", "index.blocks.write"].every( + (blockName) => indexDetail?.settings?.[blockName] !== "true" + ); + }); + } + } + + return []; +}; diff --git a/public/pages/Indices/containers/IndicesActions/index.tsx b/public/pages/Indices/containers/IndicesActions/index.tsx index d16987746..91876268f 100644 --- a/public/pages/Indices/containers/IndicesActions/index.tsx +++ b/public/pages/Indices/containers/IndicesActions/index.tsx @@ -201,6 +201,13 @@ export default function IndicesActions(props: IndicesActionsProps) { props.history.push(`${ROUTES.SPLIT_INDEX}${source}`); }, }, + { + name: "Force merge", + "data-test-subj": "ForceMergeAction", + onClick: () => { + props.history.push(`${ROUTES.FORCE_MERGE}/${selectedItems.map((item) => item.index).join(",")}`); + }, + }, { isSeparator: true, }, diff --git a/public/pages/Main/Main.tsx b/public/pages/Main/Main.tsx index f5175b2f1..c91710e6e 100644 --- a/public/pages/Main/Main.tsx +++ b/public/pages/Main/Main.tsx @@ -44,6 +44,7 @@ import ShrinkIndex from "../ShrinkIndex/container/ShrinkIndex"; import Rollover from "../Rollover"; import DataStreams from "../DataStreams"; import CreateDataStream from "../CreateDataStream"; +import ForceMerge from "../ForceMerge"; enum Navigation { IndexManagement = "Index Management", @@ -92,10 +93,17 @@ const HIDDEN_NAV_ROUTES = [ ROUTES.CREATE_TEMPLATE, ROUTES.SPLIT_INDEX, ROUTES.SHRINK_INDEX, + ROUTES.FORCE_MERGE, ROUTES.CREATE_DATA_STREAM, ]; -const HIDDEN_NAV_STARTS_WITH_ROUTE = [ROUTES.CREATE_TEMPLATE, ROUTES.INDEX_DETAIL, ROUTES.ROLLOVER, ROUTES.CREATE_DATA_STREAM]; +const HIDDEN_NAV_STARTS_WITH_ROUTE = [ + ROUTES.CREATE_TEMPLATE, + ROUTES.INDEX_DETAIL, + ROUTES.ROLLOVER, + ROUTES.CREATE_DATA_STREAM, + ROUTES.FORCE_MERGE, +]; interface MainProps extends RouteComponentProps { landingPage: string; @@ -518,6 +526,7 @@ export default class Main extends Component {
)} /> + ( @@ -550,6 +559,22 @@ export default class Main extends Component {
)} /> + ( +
+ +
+ )} + /> + ( +
+ +
+ )} + /> diff --git a/public/utils/constants.ts b/public/utils/constants.ts index 407cbc547..2794d2591 100644 --- a/public/utils/constants.ts +++ b/public/utils/constants.ts @@ -64,6 +64,7 @@ export const ROUTES = Object.freeze({ ROLLOVER: "/rollover", DATA_STREAMS: "/data-streams", CREATE_DATA_STREAM: "/create-data-stream", + FORCE_MERGE: "/force-merge", }); export const BREADCRUMBS = Object.freeze({ @@ -112,6 +113,7 @@ export const BREADCRUMBS = Object.freeze({ ROLLOVER: { text: "Rollover", href: `#${ROUTES.ROLLOVER}` }, DATA_STREAMS: { text: "Data streams", href: `#${ROUTES.DATA_STREAMS}` }, CREATE_DATA_STREAM: { text: "Create data stream", href: `#${ROUTES.CREATE_DATA_STREAM}` }, + FORCE_MERGE: { text: "Force merge", href: `#${ROUTES.FORCE_MERGE}` }, }); // TODO: EUI has a SortDirection already