/* * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ import React, { ChangeEvent, Component } from "react"; import { EuiButton, EuiButtonEmpty, EuiComboBoxOptionOption, EuiFlexGroup, EuiFlexItem } from "@elastic/eui"; import { RouteComponentProps } from "react-router-dom"; import moment from "moment"; import { RollupService, TransformService } from "../../../../services"; import { BREADCRUMBS, ROUTES } from "../../../../utils/constants"; import IndexService from "../../../../services/IndexService"; import { ManagedCatIndex } from "../../../../../server/models/interfaces"; import SetUpIndices from "../SetUpIndicesStep"; import DefineTransformsStep from "../DefineTransformsStep"; import { FieldItem, IndexItem, Transform, TRANSFORM_AGG_TYPE, TransformAggItem, TransformGroupItem, } from "../../../../../models/interfaces"; import { getErrorMessage } from "../../../../utils/helpers"; import { DefaultSampleDataSize, EMPTY_TRANSFORM } from "../../utils/constants"; import SpecifyScheduleStep from "../SpecifyScheduleStep"; import ReviewAndCreateStep from "../ReviewAndCreateStep"; import { compareFieldItem, createdTransformToastMessage, isGroupBy, parseFieldOptions } from "../../utils/helpers"; import { CoreServicesContext } from "../../../../components/core_services"; interface CreateTransformFormProps extends RouteComponentProps { rollupService: RollupService; transformService: TransformService; indexService: IndexService; } interface CreateTransformFormState { currentStep: number; transformId: string; transformIdError: string; transformSeqNo: number | null; transformPrimaryTerm: number | null; submitError: string; isSubmitting: boolean; hasSubmitted: boolean; loadingIndices: boolean; indices: ManagedCatIndex[]; totalIndices: number; previewTransform: any[]; description: string; sourceIndex: { label: string; value?: IndexItem }[]; sourceIndexError: string; sourceIndexFilter: string; sourceIndexFilterError: string; targetIndex: { label: string; value?: IndexItem }[]; targetIndexError: string; mappings: any; allMappings: FieldItem[][]; fields: FieldItem[]; fieldSelectedOption: string; selectedGroupField: TransformGroupItem[]; selectedAggregations: any; aggList: TransformAggItem[]; selectedFields: FieldItem[]; jobEnabledByDefault: boolean; continuousJob: string; interval: number; intervalError: string; intervalTimeunit: string; pageSize: number; transformJSON: any; beenWarned: boolean; isLoading: boolean; } export default class CreateTransformForm extends Component { static contextType = CoreServicesContext; _isMount: boolean; constructor(props: CreateTransformFormProps) { super(props); this.state = { currentStep: 1, transformSeqNo: null, transformPrimaryTerm: null, transformId: "", transformIdError: "", submitError: "", isSubmitting: false, hasSubmitted: false, loadingIndices: true, indices: [], totalIndices: 0, previewTransform: [], mappings: "", allMappings: [], fields: [], fieldSelectedOption: "", selectedFields: [], selectedGroupField: [], selectedAggregations: {}, aggList: [], description: "", sourceIndex: [], sourceIndexError: "", sourceIndexFilter: "", sourceIndexFilterError: "", targetIndex: [], targetIndexError: "", intervalError: "", jobEnabledByDefault: true, continuousJob: "no", interval: 1, intervalTimeunit: "MINUTES", pageSize: 1000, transformJSON: JSON.parse(EMPTY_TRANSFORM), beenWarned: false, isLoading: false, }; this._next = this._next.bind(this); this._prev = this._prev.bind(this); this._isMount = true; } componentDidMount = async (): Promise => { this.context.chrome.setBreadcrumbs([BREADCRUMBS.INDEX_MANAGEMENT, BREADCRUMBS.TRANSFORMS, BREADCRUMBS.CREATE_TRANSFORM]); }; componentWillUnmount() { this._isMount = false; } getMappings = async (srcIndex: string): Promise => { if (!srcIndex.length) return; try { const { rollupService } = this.props; const response = await rollupService.getMappings(srcIndex); if (response.ok) { let allMappings: FieldItem[][] = []; const mappings = response.response; //Push mappings array to allMappings 2D array first for (let index in mappings) { allMappings.push(parseFieldOptions("", mappings[index].mappings.properties)); } //Find intersect from all mappings const fields = allMappings.reduce((mappingA, mappingB) => mappingA.filter((itemA) => mappingB.some((itemB) => compareFieldItem(itemA, itemB))) ); this.setState({ mappings, fields, allMappings }); } else { this.context.notifications.toasts.addDanger(`Could not load fields: ${response.error}`); } } catch (err) { this.context.notifications.toasts.addDanger(getErrorMessage(err, "Could not load fields")); } }; previewTransform = async (transform: any): Promise => { try { const { transformService } = this.props; const previewResponse = await transformService.previewTransform(transform); if (previewResponse.ok) { this.setState({ previewTransform: previewResponse.response.documents }); return true; } else { this.context.notifications.toasts.addDanger(`Could not preview transform: ${previewResponse.error}`); return false; } } catch (err) { this.context.notifications.toasts.addDanger(getErrorMessage(err, "Could not load preview transform")); return false; } }; searchData = async (): Promise => { const { transformService } = this.props; const { sourceIndex, sourceIndexFilter } = this.state; this.setState({ isLoading: true }); try { const response = await transformService.searchSampleData( sourceIndex[0].label, { from: 0, size: DefaultSampleDataSize }, sourceIndexFilter ); if (!response.ok) { const errMsg = response.error ? response.error : "There was a problem searching data from source index."; this.setState({ sourceIndexFilterError: errMsg }); this.context.notifications.toasts.addDanger(errMsg); } this.setState({ isLoading: false }); return response.ok; } catch (err) { this.setState({ sourceIndexFilterError: "There was an error applying data filter, please modify or remove filter." }); this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem searching data from source index.")); this.setState({ isLoading: false }); return false; } }; _next = async () => { let currentStep = this.state.currentStep; let warned = this.state.beenWarned; let error = false; // Verification here if (currentStep == 1) { const { transformId, sourceIndex, targetIndex, sourceIndexFilterError } = this.state; if (!transformId) { this.setState({ submitError: "Job name is required.", transformIdError: "Job name is required." }); error = true; } else { // Check if transform job name is duplicated const response = await this.props.transformService.getTransform(transformId); if (response.ok && response.response._id == transformId) { this.setState({ submitError: `There is already a job named "${transformId}". Please provide a different name.`, transformIdError: `There is already a job named "${transformId}". Please provide a different name.`, }); error = true; } } if (sourceIndex.length == 0) { this.setState({ submitError: "Source index is required.", sourceIndexError: "Source index is required." }); error = true; } if (targetIndex.length == 0) { this.setState({ submitError: "Target index is required.", targetIndexError: "Target index is required." }); error = true; } if (sourceIndexFilterError !== "") { this.setState({ submitError: "Source index filter is invalid" }); error = true; } // Run search of source index data if (!error) { const searchDataOk = await this.searchData(); error = !searchDataOk; } } else if (currentStep == 2) { //TODO: Add checking to see if grouping is defined } if (error) return; currentStep = currentStep >= 3 ? 4 : currentStep + 1; warned = true; this.setState({ submitError: "", currentStep: currentStep, beenWarned: warned, }); }; _prev() { let currentStep = this.state.currentStep; // If the current step is 2 or 3, then subtract one on "previous" button click currentStep = currentStep <= 1 ? 1 : currentStep - 1; this.setState({ currentStep: currentStep, }); } onChangeStep = (step: number): void => { if (step > 3) return; this.setState({ currentStep: step, }); }; onChangeDescription = (e: ChangeEvent): void => { const description = e.target.value; let newJSON = this.state.transformJSON; newJSON.transform.description = description; this.setState({ description: description, transformJSON: newJSON }); }; onChangeName = (e: ChangeEvent): void => { const transformId = e.target.value; this.setState({ transformId, transformIdError: transformId ? "" : "Name is required" }); }; onChangeSourceIndex = async (options: EuiComboBoxOptionOption[]): Promise => { let newJSON = this.state.transformJSON; let sourceIndex = options.map(function (option) { return option.label; }); const sourceIndexError = sourceIndex.length ? "" : "Source index is required"; const srcIndexText = sourceIndex.length ? sourceIndex[0] : ""; newJSON.transform.source_index = srcIndexText; this.setState({ sourceIndex: options, transformJSON: newJSON, sourceIndexError: sourceIndexError }); this.setState({ selectedGroupField: [], selectedAggregations: {}, aggList: [], }); await this.getMappings(srcIndexText); }; //TODO: Change type from string to string[] or something else when multiple data filter is supported onChangeSourceIndexFilter = (newFilter: string): void => { let newJSON = this.state.transformJSON; if (newFilter == "") { newJSON.transform.hasOwnProperty("data_selection_query") && delete newJSON.transform.data_selection_query; this.setState({ sourceIndexFilterError: "" }); } else { try { newJSON.transform.data_selection_query = JSON.parse(newFilter); this.setState({ sourceIndexFilterError: "" }); } catch (err) { this.setState({ sourceIndexFilterError: "Invalid source index filter JSON" }); } } this.setState({ sourceIndexFilter: newFilter, transformJSON: newJSON }); }; onChangeTargetIndex = (options: EuiComboBoxOptionOption[]): void => { //Try to get label text from option from the only array element in options, if exists let newJSON = this.state.transformJSON; let targetIndex = options.map(function (option) { return option.label; }); const targetIndexError = targetIndex.length ? "" : "Target index is required"; newJSON.transform.target_index = targetIndex[0]; this.setState({ targetIndex: options, transformJSON: newJSON, targetIndexError: targetIndexError }); }; onGroupSelectionChange = async (selectedGroupField: TransformGroupItem[], aggItem: TransformAggItem): Promise => { const { aggList } = this.state; aggList.push(aggItem); this.updateGroup(); const previewSuccess = await this.previewTransform(this.state.transformJSON); // If preview successfully update groupings, else remove from list of transformation if (previewSuccess) this.setState({ selectedGroupField }); else await this.onRemoveTransformation(aggItem.name); }; onAggregationSelectionChange = async (selectedAggregations: any, aggItem: TransformAggItem): Promise => { const { aggList } = this.state; aggList.push(aggItem); this.updateAggregation(); const previewSuccess = await this.previewTransform(this.state.transformJSON); // If preview successfully update aggregations, else remove from list of transformation if (previewSuccess) this.setState({ selectedAggregations: selectedAggregations }); else await this.onRemoveTransformation(aggItem.name); }; onEditTransformation = async (oldName: string, newName: string): Promise => { const { aggList } = this.state; const toEditIndex = aggList.findIndex((item) => { return item.name === oldName; }); let newAggItem = aggList[toEditIndex]; const type = aggList[toEditIndex].type; // Modify the name of transform newAggItem.name = newName; // Also modify the target field if the transformation is a group by definition if (isGroupBy(type)) newAggItem.item[type].target_field = newName; this.setState({ aggList }); this.updateGroup(); this.updateAggregation(); await this.previewTransform(this.state.transformJSON); }; onRemoveTransformation = async (name: string): Promise => { const { aggList } = this.state; const toRemoveIndex = aggList.findIndex((item) => { return item.name === name; }); aggList.splice(toRemoveIndex, 1); this.setState({ aggList }); this.updateGroup(); this.updateAggregation(); await this.previewTransform(this.state.transformJSON); }; onChangeJobEnabledByDefault = (): void => { const checked = this.state.jobEnabledByDefault; let newJSON = this.state.transformJSON; newJSON.transform.enabled = !checked; this.setState({ jobEnabledByDefault: !checked, transformJSON: newJSON }); }; onChangeContinuousJob = (optionId: string): void => { let newJSON = this.state.transformJSON; newJSON.transform.continuous = optionId == "yes"; this.setState({ continuousJob: optionId, transformJSON: newJSON }); }; onChangeIntervalTime = (e: ChangeEvent): void => { this.setState({ interval: e.target.valueAsNumber }); if (e.target.value == "") { const intervalErrorMsg = "Interval value is required."; this.setState({ submitError: intervalErrorMsg, intervalError: intervalErrorMsg }); } else { this.setState({ intervalError: "" }); } }; onChangePage = (e: ChangeEvent): void => { let newJSON = this.state.transformJSON; newJSON.transform.page_size = e.target.valueAsNumber; this.setState({ pageSize: e.target.valueAsNumber, transformJSON: newJSON }); }; updateSchedule = (): void => { const { interval, intervalTimeunit } = this.state; let newJSON = this.state.transformJSON; newJSON.transform.schedule.interval = { start_time: moment().unix(), unit: `${intervalTimeunit}`, period: `${interval}`, }; delete newJSON.transform.schedule["cron"]; this.setState({ transformJSON: newJSON }); }; onChangeIntervalTimeunit = (e: ChangeEvent): void => { this.setState({ intervalTimeunit: e.target.value }); }; updateGroup = (): void => { const { transformJSON, aggList } = this.state; let newJSON = transformJSON; let tempGroupSelect: TransformGroupItem[] = []; aggList.map((aggItem) => { if ( aggItem.type == TRANSFORM_AGG_TYPE.histogram || aggItem.type == TRANSFORM_AGG_TYPE.terms || aggItem.type == TRANSFORM_AGG_TYPE.date_histogram ) tempGroupSelect.push(aggItem.item); }); if (tempGroupSelect.length) newJSON.transform.groups = tempGroupSelect; this.setState({ transformJSON: newJSON }); }; updateAggregation = (): void => { const { transformJSON, aggList } = this.state; let newJSON = transformJSON; let aggJSON: any = {}; aggList.map((aggItem) => { // Form the final aggregation object with items with correct types from aggList if ( aggItem.type !== TRANSFORM_AGG_TYPE.histogram && aggItem.type !== TRANSFORM_AGG_TYPE.terms && aggItem.type !== TRANSFORM_AGG_TYPE.date_histogram ) aggJSON[aggItem.name] = aggItem.item; }); newJSON.transform.aggregations = aggJSON; this.setState({ transformJSON: newJSON }); }; onSubmit = async (): Promise => { const { transformId, transformJSON } = this.state; this.setState({ submitError: "", isSubmitting: true, hasSubmitted: true }); try { if (!transformId) { this.setState({ transformIdError: "Required" }); } else { this.updateGroup(); this.updateAggregation(); this.updateSchedule(); await this.onCreate(transformId, transformJSON); } } catch (err) { this.context.notifications.toasts.addDanger("Invalid Transform JSON"); console.error(err); } if (!this._isMount) { return; } this.setState({ isSubmitting: false }); }; onCancel = (): void => { this.props.history.push(ROUTES.TRANSFORMS); }; onCreate = async (transformId: string, transform: Transform): Promise => { const { transformService } = this.props; try { const response = await transformService.putTransform(transform, transformId); if (response.ok) { this.context.notifications.toasts.addSuccess(createdTransformToastMessage(response.response._id)); this.props.history.push(ROUTES.TRANSFORMS); } else { this.setState({ submitError: response.error }); this.context.notifications.toasts.addDanger(`Failed to create transform: ${response.error}`); } } catch (err) { this.setState({ submitError: getErrorMessage(err, "There was a problem creating the transform job") }); this.context.notifications.toasts.addDanger( `Failed to create transform: ${getErrorMessage(err, "There was a problem creating the transform job")}` ); } }; render() { const { transformId, transformIdError, submitError, isSubmitting, hasSubmitted, description, sourceIndex, sourceIndexError, sourceIndexFilter, sourceIndexFilterError, targetIndex, targetIndexError, currentStep, previewTransform, fields, fieldSelectedOption, selectedGroupField, selectedAggregations, aggList, jobEnabledByDefault, continuousJob, interval, intervalTimeunit, intervalError, pageSize, beenWarned, isLoading, } = this.state; return (
Cancel {currentStep != 1 && ( Previous )} {currentStep == 4 ? ( Create ) : ( Next )}
); } }