/* * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ 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 { CoreStart } from "opensearch-dashboards/public"; import IndexDetail, { IndexDetailProps, IIndexDetailRef, defaultIndexSettings } from "../../../../components/IndexDetail"; import { IAliasAction, IndexItem, IndexItemRemote, MappingsProperties } from "../../../../../models/interfaces"; import { IndicesUpdateMode } from "../../../../utils/constants"; import { CoreServicesContext } from "../../../../components/core_services"; import { transformArrayToObject, transformObjectToArray } from "../../../../components/IndexMapping/IndexMapping"; import { ServerResponse } from "../../../../../server/models/types"; import { BrowserServices } from "../../../../models/interfaces"; import { ServicesContext } from "../../../../services"; export const getAliasActionsByDiffArray = ( oldAliases: string[], newAliases: string[], callback: (val: string) => IAliasAction[string] ): IAliasAction[] => { const diffedAliasArrayes = diffArrays(oldAliases, newAliases); return diffedAliasArrayes.reduce((total: IAliasAction[], current) => { if (current.added) { return [ ...total, ...current.value.map((item) => ({ add: callback(item), })), ]; } else if (current.removed) { return [ ...total, ...current.value.map((item) => ({ remove: callback(item), })), ]; } return total; }, [] as IAliasAction[]); }; export interface IndexFormProps extends Pick { index?: string; value?: Partial; mode?: IndicesUpdateMode; onCancel?: () => void; onSubmitSuccess?: (indexName: string) => void; hideButtons?: boolean; } interface CreateIndexState { indexDetail: IndexItem; oldIndexDetail?: IndexItem; isSubmitting: boolean; loading: boolean; } const findLineNumber = (regexp: RegExp, str: string): number => { const propertyExecResult = regexp.exec(str); if (propertyExecResult && propertyExecResult.indices && propertyExecResult.indices[1]) { const [startPosition] = propertyExecResult.indices[1]; const cutString = str.substring(0, startPosition); return cutString.split("\n").length; } return 0; }; export class IndexForm extends Component { static contextType = CoreServicesContext; /** * convert the mappings.properies to array * @param payload index detail with the mappings.properties is a map */ static transformIndexDetailToLocal(payload?: Partial): Partial { const newPayload = { ...payload }; set(newPayload, "mappings.properties", transformObjectToArray(get(newPayload, "mappings.properties", {}))); return newPayload as IndexItem; } static transformIndexDetailToRemote(payload?: Partial): Partial { const newPayload = { ...payload }; set(newPayload, "mappings.properties", transformArrayToObject(get(newPayload, "mappings.properties", []))); return newPayload as IndexItemRemote; } constructor(props: IndexFormProps & { services: BrowserServices }) { super(props); const isEdit = this.isEdit; this.state = { isSubmitting: false, indexDetail: merge({}, defaultIndexSettings, IndexForm.transformIndexDetailToLocal(props.value)), oldIndexDetail: undefined, loading: isEdit, }; } componentDidMount(): void { const isEdit = this.isEdit; if (isEdit) { this.refreshIndex(); } } indexDetailRef: IIndexDetailRef | null = null; get commonService() { return this.props.services.commonService; } get index() { return this.props.index; } get isEdit() { return this.index !== undefined; } get mode() { return this.props.mode; } hasUnsavedChanges = (mode: IndicesUpdateMode) => this.indexDetailRef?.hasUnsavedChanges(mode); getValue = () => IndexForm.transformIndexDetailToRemote(JSON.parse(JSON.stringify(this.state.indexDetail))); getIndexDetail = async (indexName: string): Promise => { const response = await this.commonService.apiCaller>({ endpoint: "indices.get", data: { index: indexName, flat_settings: true, }, }); if (response.ok) { return response.response[indexName]; } this.context.notifications.toasts.addDanger(response.error); return Promise.reject(); }; refreshIndex = async () => { this.setState({ loading: true, }); try { const indexDetail = await this.getIndexDetail(this.index as string); const payload = IndexForm.transformIndexDetailToLocal({ ...indexDetail, index: this.index, }); this.setState({ indexDetail: payload as IndexItem, oldIndexDetail: JSON.parse(JSON.stringify(payload)), }); } catch (e) { // do nothing } finally { this.setState({ loading: false, }); } }; onCancel = () => { this.props.onCancel && this.props.onCancel(); }; onDetailChange: IndexDetailProps["onChange"] = (value) => { this.setState({ indexDetail: { ...this.state.indexDetail, ...value, }, }); }; updateAlias = async (): Promise> => { const { indexDetail, oldIndexDetail } = this.state; const { index } = indexDetail; const aliasActions = getAliasActionsByDiffArray( Object.keys(oldIndexDetail?.aliases || {}), Object.keys(indexDetail.aliases || {}), (alias) => ({ index, alias, }) ); if (aliasActions.length) { return await this.commonService.apiCaller({ endpoint: "indices.updateAliases", method: "PUT", data: { body: { actions: aliasActions, }, }, }); } return Promise.resolve({ ok: true, response: {}, }); }; updateSettings = async (): Promise> => { const { indexDetail, oldIndexDetail } = this.state; const { index } = indexDetail; const newSettings = (indexDetail?.settings || {}) as Required["settings"]; const oldSettings = (oldIndexDetail?.settings || {}) as Required["settings"]; const differences = differenceWith(Object.entries(newSettings), Object.entries(oldSettings), isEqual); if (!differences.length) { return { ok: true, response: {}, }; } const finalSettings = differences.reduce((total, current) => { if (newSettings[current[0]] !== undefined) { return { ...total, [current[0]]: newSettings[current[0]], }; } return total; }, {}); return await this.commonService.apiCaller({ endpoint: "indices.putSettings", method: "PUT", data: { index, flat_settings: true, // In edit mode, only dynamic settings can be modified body: finalSettings, }, }); }; updateMappings = async (): Promise> => { const { indexDetail, oldIndexDetail } = this.state; const { index } = indexDetail; // handle the mappings here const newMappingProperties = indexDetail?.mappings?.properties || []; const diffedMappingArrayes = diffArrays( (oldIndexDetail?.mappings?.properties || []).map((item) => item.fieldName), newMappingProperties.map((item) => item.fieldName) ); const newValue = newMappingProperties.filter((item, index) => index >= (oldIndexDetail?.mappings?.properties || []).length); const newMappingFields: MappingsProperties = diffedMappingArrayes .filter((item) => item.added) .reduce((total, current) => [...total, ...current.value], [] as string[]) .map((current) => newValue.find((item) => item.fieldName === current) as MappingsProperties[number]) .filter((item) => item); const newMappingSettings = transformArrayToObject(newMappingFields); if (!isEqual(indexDetail.mappings, oldIndexDetail?.mappings)) { return await this.commonService.apiCaller({ endpoint: "indices.putMapping", method: "PUT", data: { index, body: { ...indexDetail.mappings, properties: newMappingSettings, }, }, }); } return Promise.resolve({ ok: true, response: {}, }); }; chainPromise = async (promises: Promise>[]): Promise> => { const newPromises = [...promises]; while (newPromises.length) { const result = (await newPromises.shift()) as ServerResponse; if (!result?.ok) { return result; } } return { ok: true, response: {}, }; }; onSubmit = async (): Promise<{ ok: boolean }> => { const mode = this.mode; const { indexDetail } = this.state; const { index, mappings, ...others } = indexDetail; if (!(await this.indexDetailRef?.validate())) { return { ok: false }; } this.setState({ isSubmitting: true }); let result: ServerResponse; if (this.isEdit) { let chainedPromises: Promise>[] = []; if (!mode) { chainedPromises.push(...[this.updateMappings(), this.updateAlias(), this.updateSettings()]); } else { switch (mode) { case IndicesUpdateMode.alias: chainedPromises.push(this.updateAlias()); break; case IndicesUpdateMode.settings: chainedPromises.push(this.updateSettings()); break; case IndicesUpdateMode.mappings: chainedPromises.push(this.updateMappings()); break; } } result = await this.chainPromise(chainedPromises); } else { result = await this.commonService.apiCaller({ endpoint: "indices.create", method: "PUT", data: { index, body: { ...others, mappings: { ...mappings, properties: transformArrayToObject(mappings?.properties || []), }, }, }, }); } this.setState({ isSubmitting: false }); // handle all the response here if (result && result.ok) { this.context.notifications.toasts.addSuccess(`${indexDetail.index} has been successfully ${this.isEdit ? "updated" : "created"}.`); this.props.onSubmitSuccess && this.props.onSubmitSuccess(indexDetail.index); } else { const mapperParseExceptionReg = /\[mapper_parsing_exception\] unknown parameter \[([^\]]+)\] on mapper \[([^\]]+)\] of type \[([^\]]+)\]/; const mapperTypeParseExceptionReg = /\[mapper_parsing_exception\] No handler for type \[([^\]]+)\] declared on field \[([^\]]+)\]/; const execResult = mapperParseExceptionReg.exec(result.error); const typeParseExceptionResult = mapperTypeParseExceptionReg.exec(result.error); let finalMessage = result.error; const mappingsEditorValue = this.indexDetailRef?.getMappingsJSONEditorValue() || ""; if (execResult) { const jsonRegExp = new RegExp(`"${execResult[2]}":\\s*\\{[\\S\\s]*("${execResult[1]}"\\s*:)[\\S\\s]*\\}`, "d"); if (findLineNumber(jsonRegExp, mappingsEditorValue)) { finalMessage = `There is a problem with the index mapping syntax. Unknown parameter "${execResult[1]}" on line ${findLineNumber( jsonRegExp, mappingsEditorValue )}.`; } } if (typeParseExceptionResult) { const jsonRegExp = new RegExp( `"${typeParseExceptionResult[2]}":\\s*\\{[\\S\\s]*("type":\\s*"${typeParseExceptionResult[1]}"\\s*)[\\S\\s]*\\}`, "d" ); if (findLineNumber(jsonRegExp, mappingsEditorValue)) { finalMessage = `There is a problem with the index mapping syntax. Unsupported type "${ typeParseExceptionResult[1] }" on line ${findLineNumber(jsonRegExp, mappingsEditorValue)}.`; } } this.context.notifications.toasts.addDanger(finalMessage); } return result; }; onSimulateIndexTemplate = (indexName: string): Promise> => { return this.commonService .apiCaller<{ template: IndexItemRemote }>({ endpoint: "transport.request", data: { path: `/_index_template/_simulate_index/${indexName}`, method: "POST", }, }) .then((res) => { if (res.ok && res.response && res.response.template) { return { ...res, response: { ...res.response.template, settings: flattern(res.response.template?.settings || {}, { safe: true, }), }, }; } return { ok: false, error: "", } as ServerResponse; }); }; render() { const isEdit = this.isEdit; const { hideButtons, readonly } = this.props; const { indexDetail, isSubmitting, oldIndexDetail, loading } = this.state; if (loading) { return ; } return ( <> (this.indexDetailRef = ref)} isEdit={this.isEdit} value={indexDetail} oldValue={oldIndexDetail} onChange={this.onDetailChange} onSimulateIndexTemplate={this.onSimulateIndexTemplate} sourceIndices={this.props.sourceIndices} onGetIndexDetail={this.getIndexDetail} refreshOptions={(aliasName) => this.commonService.apiCaller({ endpoint: "cat.aliases", method: "GET", data: { format: "json", name: `*${aliasName || ""}*`, s: "alias:desc", }, }) } onSubmit={this.onSubmit} refreshIndex={this.refreshIndex} docVersion={(this.context as CoreStart).docLinks.DOC_LINK_VERSION} /> {hideButtons ? null : ( <> Cancel {isEdit ? "Update" : "Create"} )} ); } } export default forwardRef(function IndexFormWrapper(props: IndexFormProps, ref: React.Ref) { const services = useContext(ServicesContext); return ; });