/* * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ import React, { Ref, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { EuiSpacer, EuiFormRow, EuiLink, EuiOverlayMask, EuiLoadingSpinner, EuiContextMenu, EuiButton, EuiCallOut, EuiTitle, } from "@elastic/eui"; import { set, merge, omit, pick } from "lodash"; import flat from "flat"; import { ContentPanel } from "../ContentPanel"; import UnsavedChangesBottomBar from "../UnsavedChangesBottomBar"; import AliasSelect, { AliasSelectProps } from "../AliasSelect"; import IndexMapping from "../IndexMapping"; import { IndexItem, IndexItemRemote } from "../../../models/interfaces"; import { ServerResponse } from "../../../server/models/types"; import { INDEX_IMPORT_SETTINGS, INDEX_DYNAMIC_SETTINGS, IndicesUpdateMode, INDEX_NAMING_MESSAGE, REPLICA_NUMBER_MESSAGE, INDEX_SETTINGS_URL, INDEX_NAMING_PATTERN, ALIAS_SELECT_RULE, } from "../../utils/constants"; import { Modal } from "../Modal"; import FormGenerator, { IField, IFormGeneratorRef } from "../FormGenerator"; import EuiToolTipWrapper from "../EuiToolTipWrapper"; import { IIndexMappingsRef, transformArrayToObject, transformObjectToArray } from "../IndexMapping"; import { IFieldComponentProps } from "../FormGenerator"; import SimplePopover from "../SimplePopover"; import { SimpleEuiToast } from "../Toast"; import { filterByMinimatch, getOrderedJson } from "../../../utils/helper"; import { SYSTEM_INDEX } from "../../../utils/constants"; import { diffJson } from "../../utils/helpers"; import { OptionalLabel } from "../CustomFormRow"; const WrappedAliasSelect = EuiToolTipWrapper(AliasSelect as any, { disabledKey: "isDisabled", }); const formatMappings = (mappings: IndexItem["mappings"]): IndexItemRemote["mappings"] => { return { ...mappings, properties: transformArrayToObject(mappings?.properties || []), }; }; export const defaultIndexSettings = { index: "", settings: { "index.number_of_shards": 1, "index.number_of_replicas": 1, "index.refresh_interval": "1s", }, mappings: {}, }; export interface IndexDetailProps { onChange: (value: IndexDetailProps["value"]) => void; docVersion: string; refreshOptions: AliasSelectProps["refreshOptions"]; value?: Partial; oldValue?: Partial; isEdit?: boolean; readonly?: boolean; mode?: IndicesUpdateMode; onSimulateIndexTemplate?: (indexName: string) => Promise>; onGetIndexDetail?: (indexName: string) => Promise; sourceIndices?: string[]; onSubmit?: () => Promise<{ ok: boolean }>; refreshIndex?: () => void; withoutPanel?: boolean; } export interface IIndexDetailRef { validate: () => Promise; hasUnsavedChanges: (mode: IndicesUpdateMode) => number; getMappingsJSONEditorValue: () => string; simulateFromTemplate: () => Promise; importSettings: (args: { index: string }) => Promise; } const TemplateInfoCallout = (props: { visible: boolean }) => { return props.visible ? ( Index alias, settings, and mappings are automatically inherited from matching index templates. ) : null; }; const IndexDetail = ( { value, onChange, isEdit, readonly, oldValue, refreshOptions, mode, onSimulateIndexTemplate, sourceIndices = [], onGetIndexDetail, onSubmit, refreshIndex, docVersion, withoutPanel, }: IndexDetailProps, ref: Ref ) => { const valueRef = useRef(value); valueRef.current = value; const hasEdit = useRef(false); const onValueChange = useCallback( (name: string | string[], val) => { let finalValue = valueRef.current || {}; set(finalValue, name, val); onChange({ ...finalValue }); if (name !== "index") { hasEdit.current = true; } }, [onChange, value] ); const destroyRef = useRef(false); const [templateSimulateLoading, setTemplateSimulateLoading] = useState(false); const [isMatchingTemplate, setIsMatchingTemplate] = useState(false); const finalValue = value || {}; const aliasesRef = useRef(null); const settingsRef = useRef(null); const mappingsRef = useRef(null); const onIndexInputBlur = useCallback(async () => { await new Promise((resolve) => setTimeout(resolve, 200)); if (destroyRef.current) { return; } if (finalValue.index && onSimulateIndexTemplate) { setTemplateSimulateLoading(true); const result = await onSimulateIndexTemplate(finalValue.index); if (destroyRef.current) { return; } setTemplateSimulateLoading(false); if (result && result.ok) { let onChangePromise: Promise; if (hasEdit.current) { onChangePromise = new Promise((resolve) => { Modal.show({ title: "Merge your changes with templates?", content: "The index name matches one or more index templates. Index aliases, settings, and mappings are inherited from matching templates. Do you want to merge your changes with templates?", locale: { confirm: "Merge with templates", cancel: "Overwrite by templates", }, footer: ["cancel", "confirm"], type: "confirm", "data-test-subj": "simulate-confirm", onCancel: () => resolve(result.response), onOk: () => { const formatValue: IndexItemRemote = { index: "", ...finalValue, mappings: { properties: transformArrayToObject(finalValue.mappings?.properties || []), }, }; const mergedValue: IndexItemRemote = { index: finalValue.index || "", }; merge(mergedValue, result.response, formatValue); resolve(mergedValue); }, }); }); } else { onChangePromise = Promise.resolve(result.response); } onChangePromise.then((data) => { onChange({ ...data, mappings: { properties: transformObjectToArray(data?.mappings?.properties || {}), }, }); hasEdit.current = false; setIsMatchingTemplate(true); }); } else { setIsMatchingTemplate(false); } } }, [finalValue.index, onSimulateIndexTemplate]); const onImportSettings = async ({ index }: { index: string }) => { if (onGetIndexDetail) { const indexDetail: IndexItemRemote = await new Promise((resolve) => { if (hasEdit.current) { Modal.show({ type: "confirm", title: "Confirm", content: "We find that you made some changes to this draft, what action do you want to make?", locale: { confirm: "Overwrite", cancel: "do not import", }, onOk: async () => onGetIndexDetail(index).then(resolve), }); } else { onGetIndexDetail(index).then(resolve); } }); onChange({ // omit alias ...omit(indexDetail, ["aliases", "data_stream"]), mappings: { ...indexDetail?.mappings, properties: transformObjectToArray(indexDetail?.mappings?.properties || {}), }, // pick some metadata in index settings: pick(indexDetail?.settings || {}, INDEX_IMPORT_SETTINGS), }); SimpleEuiToast.addSuccess(`Settings and mappings of [${index}] have been import successfully`); hasEdit.current = false; } }; useImperativeHandle(ref, () => ({ validate: async () => { const result = await Promise.all([ aliasesRef.current?.validatePromise().then((result) => result.errors), mappingsRef.current?.validate(), settingsRef.current?.validatePromise().then((result) => result.errors), ]); return result.every((item) => !item); }, hasUnsavedChanges: (mode: IndicesUpdateMode) => diffJson(oldValue?.[mode], finalValue[mode]), getMappingsJSONEditorValue: () => mappingsRef.current?.getJSONEditorValue() || "", simulateFromTemplate: onIndexInputBlur, importSettings: onImportSettings, })); const formFields: IField[] = useMemo(() => { return [ { rowProps: { label: "Number of primary shards", helpText: ( <>
Specify the number of primary shards for the index. Default is 1.
The number of primary shards cannot be changed after the index is created.
), direction: isEdit ? "hoz" : "ver", }, name: "index.number_of_shards", type: readonly || (isEdit && !INDEX_DYNAMIC_SETTINGS.includes("index.number_of_shards")) ? "Text" : "Number", options: { rules: [ { min: 1, message: "Number of primary shards cannot be smaller than 1.", }, { validator(rule, value, values) { if (Number(value) !== parseInt(value)) { return Promise.reject("Number of primary shards must be an integer."); } return Promise.resolve(); }, }, ], props: { placeholder: "Specify primary shard count.", removeWhenEmpty: true, }, }, }, { rowProps: { label: "Number of replicas", helpText: REPLICA_NUMBER_MESSAGE, direction: isEdit ? "hoz" : "ver", }, name: "index.number_of_replicas", type: readonly || (isEdit && !INDEX_DYNAMIC_SETTINGS.includes("index.number_of_replicas")) ? "Text" : "Number", options: { rules: [ { min: 0, message: "Number of replicas cannot be smaller than 0.", }, { validator(rule, value, values) { if (Number(value) !== parseInt(value)) { return Promise.reject("Number of replicas must be an integer."); } return Promise.resolve(); }, }, ], props: { placeholder: "Specify number of replicas.", removeWhenEmpty: true, }, }, }, { rowProps: { label: "Refresh interval", helpText: "Specify how often the index should refresh, which publishes the most recent changes and make them available for search. Default is 1 second.", direction: isEdit ? "hoz" : "ver", }, name: "index.refresh_interval", type: readonly ? "Text" : "Input", options: { props: { placeholder: "Can be set to -1 to disable refreshing.", removeWhenEmpty: true, }, }, }, ] as IField[]; }, [isEdit, finalValue.index, templateSimulateLoading]); useEffect(() => { return () => { destroyRef.current = true; }; }, []); return ( <> {isEdit && !readonly && filterByMinimatch(value?.index as string, SYSTEM_INDEX) ? ( <> This index may contain critical system data. Changing system indexes may break OpenSearch. ) : null} {isEdit && mode && mode !== IndicesUpdateMode.alias ? null : (() => { const content = ( {INDEX_NAMING_MESSAGE}, position: "bottom", style: isEdit ? { display: "none" } : {}, }, type: readonly || isEdit ? "Text" : "Input", options: { props: { placeholder: "Specify a name for the new index.", onBlur: onIndexInputBlur, isLoading: templateSimulateLoading, }, rules: [ { pattern: INDEX_NAMING_PATTERN, message: "Invalid index name.", }, ], }, }, { name: "templateMessage", rowProps: { fullWidth: true, }, component: () => , }, { name: "aliases", rowProps: { label: "Index alias", helpText: "Allow this index to be referenced by existing aliases or specify a new alias.", direction: isEdit ? "hoz" : "ver", isOptional: true, }, options: { props: { refreshOptions: refreshOptions, }, rules: [...ALIAS_SELECT_RULE], }, component: WrappedAliasSelect as React.ComponentType, }, ]} value={{ index: finalValue.index, aliases: finalValue.aliases, }} onChange={(totalValue, name, val) => { onValueChange(name as string, val); }} /> ); if (mode && mode === IndicesUpdateMode.alias) { return content; } const title = "Define index"; if (withoutPanel) { return ( <> {title} {content} ); } return ( <> {content} ); })()} {sourceIndices.length ? ( <> Import settings and mappings } > ({ name: sourceIndex, "data-test-subj": `import-settings-${sourceIndex}`, onClick: () => onImportSettings({ index: sourceIndex }), })), }, ]} /> ) : null} {isEdit && mode && mode !== IndicesUpdateMode.settings ? null : (() => { const content = ( { if (name) { onValueChange(["settings", name as string], val); } else { onValueChange("settings", val); } }} formFields={formFields} hasAdvancedSettings resetValuesWhenPropsValueChange advancedSettingsProps={{ editorProps: { mode: isEdit && !readonly ? "diff" : "json", disabled: readonly, original: JSON.stringify(getOrderedJson(oldValue?.settings || {}), null, 2), width: "100%", formatValue: flat, }, accordionProps: { initialIsOpen: false, id: "accordionForCreateIndexSettings", buttonContent:

Advanced settings

, }, rowProps: { label: "Specify advanced index settings", fullWidth: true, helpText: ( <>

Specify a comma-delimited list of settings.{" "} View index settings.

All the settings will be handled in flat structure.{" "} Learn more .

), }, }} /> ); if (mode && mode === IndicesUpdateMode.settings) { return content; } const title = "Index settings"; if (withoutPanel) { return ( <>

{title}

{content} ); } return ( <> {content} ); })()} {isEdit && mode && mode !== IndicesUpdateMode.mappings ? null : (() => { const content = ( onValueChange("mappings", val)} ref={mappingsRef} readonly={readonly} docVersion={docVersion} /> ); if (mode && mode === IndicesUpdateMode.mappings) { return content; } if (withoutPanel) { return ( <>
Index mapping
{content} ); } return (
Index mapping
Define how documents and their fields are stored and indexed.{" "} Learn more
Mappings and field types cannot be changed after the index is created.
} > <>
} titleSize="s" > {content}
); })()} {templateSimulateLoading ? ( We are simulating your template with existing templates, please wait for a second. ) : null} {isEdit && mode === IndicesUpdateMode.settings && diffJson(oldValue?.settings, finalValue.settings) ? ( { onValueChange("settings", JSON.parse(JSON.stringify(oldValue?.settings || {}))); }} onClickSubmit={async () => { const result = (await onSubmit?.()) || { ok: false }; if (result.ok) { refreshIndex?.(); } }} /> ) : null} {isEdit && mode === IndicesUpdateMode.mappings && diffJson(formatMappings(oldValue?.mappings), formatMappings(finalValue.mappings)) ? ( { onValueChange("mappings", JSON.parse(JSON.stringify(oldValue?.mappings || {}))); }} onClickSubmit={async () => { const result = (await onSubmit?.()) || { ok: false }; if (result.ok) { refreshIndex?.(); } }} /> ) : null} {isEdit && mode === IndicesUpdateMode.alias && diffJson(oldValue?.aliases, finalValue.aliases) ? ( { onValueChange("aliases", JSON.parse(JSON.stringify(oldValue?.aliases || {}))); }} onClickSubmit={async () => { const result = (await onSubmit?.()) || { ok: false }; if (result.ok) { refreshIndex?.(); } }} /> ) : null} ); }; // @ts-ignore export default forwardRef(IndexDetail);