Forráskód Böngészése

Implement a new WizardModule for `execute options` that include the
`auto_deploy` feature

Signed-off-by: Mihaela Balutoiu <mbalutoiu@cloudbasesolutions.com>

Mihaela Balutoiu 1 éve
szülő
commit
fc304da829

+ 1 - 0
src/@types/WizardData.ts

@@ -23,6 +23,7 @@ export type WizardData = {
   networks?: NetworkMap[] | null;
   source?: Endpoint | null;
   target?: Endpoint | null;
+  executeOptions?: { [prop: string]: any } | null;
 };
 
 export type WizardPage = {

+ 357 - 0
src/components/modules/WizardModule/WizardExecuteOptions/WizardExecuteOptions.tsx

@@ -0,0 +1,357 @@
+/*
+Copyright (C) 2025  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+import { toJS } from "mobx";
+import { observer } from "mobx-react";
+import * as React from "react";
+import styled from "styled-components";
+import FieldInput from "@src/components/ui/FieldInput";
+import { ThemePalette, ThemeProps } from "@src/components/Theme";
+import { deploymentFields } from "@src/constants";
+import LabelDictionary from "@src/utils/LabelDictionary";
+import type { Field } from "@src/@types/Field";
+
+const Wrapper = styled.div<any>`
+  display: flex;
+  min-height: 0;
+  flex-direction: column;
+  width: 100%;
+`;
+
+const Fields = styled.div<any>`
+  ${props => (props.layout === "page" ? "" : "padding: 32px;")}
+  display: flex;
+  flex-direction: column;
+  overflow: auto;
+  padding-right: ${props => (props.layout === "page" ? 4 : 24)}px;
+  flex-grow: 1;
+`;
+const Group = styled.div<any>`
+  display: flex;
+  flex-direction: column;
+  flex-shrink: 0;
+
+  &.field-group-transition-appear {
+    opacity: 0.01;
+  }
+  &.field-group-transition-appear-active {
+    opacity: 1;
+    transition: opacity 250ms ease-out;
+  }
+`;
+const GroupName = styled.div<any>`
+  display: flex;
+  align-items: center;
+  margin: 48px 0 24px 0;
+`;
+const GroupNameText = styled.div<any>`
+  margin: 0 32px;
+  font-size: 16px;
+`;
+const GroupNameBar = styled.div<any>`
+  flex-grow: 1;
+  background: ${ThemePalette.grayscale[3]};
+  height: 1px;
+`;
+const GroupFields = styled.div<any>`
+  display: flex;
+  justify-content: space-between;
+  margin-top: ${props => (props.name === "Deployment options" ? "32px" : "16px")};
+`;
+const Column = styled.div<any>`
+  margin-top: -16px;
+`;
+const FieldInputStyled = styled(FieldInput)`
+  width: ${props => props.width || ThemeProps.inputSizes.wizard.width}px;
+  justify-content: space-between;
+  margin-top: 16px;
+`;
+
+export const shouldRenderField = (field: Field) =>
+    (field.type !== "array" ||
+        (field.enum && field.enum.length && field.enum.length > 0)) &&
+    (field.type !== "object" || field.properties);
+
+type FieldRender = {
+    field: Field;
+    component: React.ReactNode;
+    column: number;
+};
+
+type Props = {
+    options: Field[];
+    wizardType: string;
+    layout?: "page" | "modal";
+    data?: { [prop: string]: any } | null;
+    fieldWidth?: number;
+    getFieldValue?: (
+        fieldName: string,
+        defaultValue: any,
+        parentFieldName: string | undefined
+    ) => any;
+    onChange: (field: Field, value: any, parentFieldName?: string) => void;
+    onScrollableRef?: (ref: HTMLElement) => void;
+};
+type State = {
+    executionOptions: { [prop: string]: any } | null;
+};
+
+@observer
+class WizardExecuteOptions extends React.Component<Props, State> {
+    state: State = {
+        executionOptions: null,
+    };
+
+    getDefaultSimpleFieldsSchema() {
+        const fieldsSchema: Field[] = [];
+        if (this.props.wizardType === "replica-execute" || this.props.wizardType === "migration-execute") {
+            fieldsSchema.push({
+                name: "execute_now",
+                type: "boolean",
+                default: true,
+                nullableBoolean: false,
+                description:
+                    "When enabled, the transfer will be executed immediately after the options are configured.",
+
+            });
+            fieldsSchema.push({
+                name: "auto_deploy",
+                type: "boolean",
+                default: false,
+                nullableBoolean: false,
+                description:
+                    "When enabled, the transfer will automatically deploy the instances on the destination cloud after the transfer is complete.",
+            });
+
+            fieldsSchema.push({
+                name: "shutdown_instances",
+                type: "boolean",
+                default: false,
+                nullableBoolean: false,
+                description:
+                    "When enabled, the instances will be shut down before the transfer is executed.",
+            });
+        }
+
+        return fieldsSchema;
+    }
+
+    generateGroups(fields: FieldRender[]) {
+        let groups: Array<{ fields: FieldRender[]; name?: string }> = [{ fields }];
+    
+        if (this.props.wizardType === "replica-execute" || this.props.wizardType === "migration-execute") {
+            const deploymentFieldNames = deploymentFields.map(f => f.name);
+            const deploymentFieldsInUse = fields.filter(f =>
+                deploymentFieldNames.includes(f.field.name)
+            );
+            const additionalDeploymentFields = deploymentFields.filter(
+                f => !fields.some(field => field.field.name === f.name)
+            ).map((field) => ({
+                column: fields.length % 2,
+                component: this.renderOptionsField({
+                    ...field,
+                    default: field.defaultValue,
+                }),
+                field: {
+                    ...field,
+                    default: field.defaultValue,
+                },
+            }));
+            if (deploymentFieldsInUse.length > 0 || additionalDeploymentFields.length > 0) {
+                groups.push({
+                    name: "Deployment options",
+                    fields: [
+                        ...deploymentFieldsInUse.map((f, i) => ({ ...f, column: i % 2 })),
+                        ...additionalDeploymentFields
+                    ],
+                });
+            }
+        }
+    
+        fields.forEach(f => {
+            if (f.field.groupName) {
+                groups[0].fields = groups[0].fields
+                    ? groups[0].fields.filter(gf => gf.field.name !== f.field.name)
+                    : [];
+    
+                const group = groups.find(g => g.name && g.name === f.field.groupName);
+                if (!group) {
+                    groups.push({
+                        name: f.field.groupName,
+                        fields: [f],
+                    });
+                } else {
+                    group.fields.push(f);
+                }
+            }
+        });
+    
+        return groups;
+    }
+
+    getFieldValue(
+        fieldName: string,
+        defaultValue: any,
+        parentFieldName?: string
+    ) {
+        if (this.props.getFieldValue) {
+            return this.props.getFieldValue(fieldName, defaultValue, parentFieldName);
+        }
+
+        if (!this.props.data) {
+            return defaultValue;
+        }
+
+        if (parentFieldName) {
+            if (
+                this.props.data[parentFieldName] &&
+                this.props.data[parentFieldName][fieldName] !== undefined
+            ) {
+                return this.props.data[parentFieldName][fieldName];
+            }
+            return defaultValue;
+        }
+
+        if (!this.props.data || this.props.data[fieldName] === undefined) {
+            return defaultValue;
+        }
+
+        return this.props.data[fieldName];
+    }
+
+    renderOptionsField(field: Field) {
+        let additionalProps;
+        if (field.type === "object" && field.properties) {
+            additionalProps = {
+                valueCallback: (f: any) =>
+                    this.getFieldValue(f.name, f.default, field.name),
+                onChange: (value: any, f: any) => {
+                    this.props.onChange(f, value, field.name);
+                },
+                properties: field.properties,
+            };
+        } else {
+            additionalProps = {
+                value: this.getFieldValue(field.name, field.default, field.groupName),
+                onChange: (value: any) => {
+                    this.props.onChange(field, value);
+                },
+            };
+        }
+        return (
+            <FieldInputStyled
+                layout={this.props.layout || "page"}
+                key={field.name}
+                name={field.name}
+                type={field.type}
+                minimum={field.minimum}
+                maximum={field.maximum}
+                label={field.label || LabelDictionary.get(field.name)}
+                description={field.description || LabelDictionary.getDescription(field.name)}
+                password={field.name.toLowerCase().includes("password")}
+                enum={field.enum}
+                addNullValue
+                required={field.required}
+                width={this.props.fieldWidth || ThemeProps.inputSizes.wizard.width}
+                nullableBoolean={field.nullableBoolean}
+                disabled={field.disabled}
+                // eslint-disable-next-line react/jsx-props-no-spreading
+                {...additionalProps}
+            />
+        );
+    }
+
+    renderOptionsFields() {
+        let fieldsSchema: Field[] = this.getDefaultSimpleFieldsSchema();
+
+        const isRequired = (f: Field) =>
+            f.required || f.properties?.some(p => p.required);
+
+        const defaultFieldNames = fieldsSchema.map(f => f.name);
+        const filteredOptions = this.props.options.filter(
+            f => !defaultFieldNames.includes(f.name) && isRequired(f)
+        );
+
+        fieldsSchema = fieldsSchema.concat(filteredOptions);
+
+        const nonNullableBooleans: string[] = fieldsSchema
+            .filter(f => f.type === "boolean" && f.nullableBoolean === false)
+            .map(f => f.name);
+
+        let executeNowColumn: number;
+        const fields: FieldRender[] = fieldsSchema
+            .filter(f => shouldRenderField(f))
+            .map((field, i) => {
+                let column: number = i % 2;
+                if (field.name === "execute_now") {
+                    executeNowColumn = column;
+                }
+                const usableField = toJS(field);
+                if (
+                    field.type === "boolean" &&
+                    !nonNullableBooleans.find(name => name === field.name)
+                ) {
+                    usableField.nullableBoolean = true;
+                }
+
+                return {
+                    column,
+                    component: this.renderOptionsField(usableField),
+                    field: usableField,
+                };
+            });
+
+        const groups = this.generateGroups(fields);
+        return (
+            <Fields ref={this.props.onScrollableRef} layout={this.props.layout}>
+                {groups.map((g, i) => {
+                    const getColumnInGroup = (field: any, fieldIndex: number) =>
+                        g.name ? fieldIndex % 2 : field.column;
+                    return (
+                        <Group key={g.name || 0}>
+                            {g.name ? (
+                                <GroupName>
+                                    <GroupNameBar />
+                                    <GroupNameText>{LabelDictionary.get(g.name)}</GroupNameText>
+                                    <GroupNameBar />
+                                </GroupName>
+                            ) : null}
+                            <GroupFields>
+                                <Column left>
+                                    {g.fields.map(
+                                        (f, j) => getColumnInGroup(f, j) === 0 && f.component
+                                    )}
+                                </Column>
+                                <Column right>
+                                    {g.fields.map(
+                                        (f, j) => getColumnInGroup(f, j) === 1 && f.component
+                                    )}
+                                </Column>
+                            </GroupFields>
+                        </Group>
+                    );
+                })}
+            </Fields>
+        );
+    }
+
+    render() {
+        return (
+            <Wrapper>
+                {this.renderOptionsFields()}
+            </Wrapper>
+        );
+    }
+}
+
+export default WizardExecuteOptions;

+ 6 - 0
src/components/modules/WizardModule/WizardExecuteOptions/package.json

@@ -0,0 +1,6 @@
+{
+    "name": "WizardExecuteOptions",
+    "version": "0.0.0",
+    "private": true,
+    "main": "./WizardExecuteOptions.tsx"
+  }

+ 19 - 1
src/components/modules/WizardModule/WizardPageContent/WizardPageContent.tsx

@@ -30,12 +30,13 @@ import WizardOptions from "@src/components/modules/WizardModule/WizardOptions";
 import WizardScripts from "@src/components/modules/WizardModule/WizardScripts";
 import WizardStorage from "@src/components/modules/WizardModule/WizardStorage";
 import WizardSummary from "@src/components/modules/WizardModule/WizardSummary";
+import WizardExecuteOptions from "@src/components/modules/WizardModule/WizardExecuteOptions/WizardExecuteOptions";
 import WizardType from "@src/components/modules/WizardModule/WizardType";
 import { ThemePalette, ThemeProps } from "@src/components/Theme";
 import Button from "@src/components/ui/Button";
 import InfoIcon from "@src/components/ui/InfoIcon";
 import LoadingButton from "@src/components/ui/LoadingButton";
-import { providerTypes, wizardPages } from "@src/constants";
+import { deploymentFields, executeOptionsWithExecuteNow, providerTypes, wizardPages } from "@src/constants";
 import endpointStore from "@src/stores/EndpointStore";
 import instanceStore from "@src/stores/InstanceStore";
 import minionPoolStore from "@src/stores/MinionPoolStore";
@@ -50,6 +51,7 @@ import type { WizardData, WizardPage } from "@src/@types/WizardData";
 import type { Instance, InstanceScript } from "@src/@types/Instance";
 import type { Field } from "@src/@types/Field";
 import type { Schedule as ScheduleType } from "@src/@types/Schedule";
+
 const Wrapper = styled.div<any>`
   ${ThemeProps.exactWidth(`${parseInt(ThemeProps.contentWidth, 10) + 64}px`)}
   margin: 64px auto 32px auto;
@@ -172,6 +174,10 @@ type Props = {
     global: string | null,
     instanceName: string | null
   ) => void;
+  onTransferExecuteOptionsChange: (
+    field: Field, 
+    value: any,
+  ) => void;
 };
 type TimezoneValue = "local" | "utc";
 type State = {
@@ -451,6 +457,17 @@ class WizardPageContent extends React.Component<Props, State> {
           />
         );
         break;
+      case "execute":
+        body = (
+          <WizardExecuteOptions
+            options={[...executeOptionsWithExecuteNow, ...deploymentFields]}
+            wizardType={`${this.props.type}-execute`}
+            layout="page"
+            data={this.props.wizardData.executeOptions}
+            onChange={this.props.onTransferExecuteOptionsChange}
+          />
+        );
+        break;
       case "vms":
         body = (
           <WizardInstances
@@ -627,6 +644,7 @@ class WizardPageContent extends React.Component<Props, State> {
             destinationSchema={this.props.providerStore.destinationSchema}
             uploadedUserScripts={this.props.uploadedUserScripts}
             minionPools={this.props.minionPoolStore.minionPools}
+            executionOptions={[...executeOptionsWithExecuteNow, ...deploymentFields]}
           />
         );
         break;

+ 43 - 0
src/components/modules/WizardModule/WizardSummary/WizardSummary.tsx

@@ -24,6 +24,7 @@ import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from "@src/components/module
 import { getDisks } from "@src/components/modules/WizardModule/WizardStorage";
 import { ThemePalette, ThemeProps } from "@src/components/Theme";
 import StatusPill from "@src/components/ui/StatusComponents/StatusPill";
+import { deploymentFields } from "@src/constants";
 import configLoader from "@src/utils/Config";
 import DateUtils from "@src/utils/DateUtils";
 import LabelDictionary from "@src/utils/LabelDictionary";
@@ -174,6 +175,7 @@ type Props = {
   sourceSchema: Field[];
   destinationSchema: Field[];
   uploadedUserScripts: InstanceScript[];
+  executionOptions: Field[];
 };
 @observer
 class WizardSummary extends React.Component<Props> {
@@ -322,6 +324,46 @@ class WizardSummary extends React.Component<Props> {
     );
   }
 
+  hasDefaultValue(option: any): option is { defaultValue: boolean } {
+    return option && typeof option.defaultValue !== 'undefined';
+  }
+
+  renderTransferExecuteOptions() {
+    const type =
+      this.props.wizardType.charAt(0).toUpperCase() +
+      this.props.wizardType.substr(1);
+    const data = this.props.data;
+
+    if (!this.props.executionOptions || this.props.executionOptions.length === 0) {
+      return null;
+    }
+
+    const deploymentFieldNames = deploymentFields.map(f => f.name);
+    const filteredExecutionOptions = this.props.executionOptions.filter(
+      option => !deploymentFieldNames.includes(option.name)
+    );
+
+    const allOptions = [...filteredExecutionOptions, ...deploymentFields];
+
+    return (
+      <Section>
+        <SectionTitle>{type} Transfer Execution Options</SectionTitle>
+        <OptionsList>
+          {allOptions.map(option => (
+            <Option key={option.name}>
+              <OptionLabel>{option.label}</OptionLabel>
+              <OptionValue>
+                {data.executeOptions && data.executeOptions[option.name] !== undefined
+                  ? data.executeOptions[option.name] ? "Yes" : "No"
+                  : this.hasDefaultValue(option) && option.defaultValue ? "Yes" : "No"}
+              </OptionValue>
+            </Option>
+          ))}
+        </OptionsList>
+      </Section>
+    );
+  }
+
   renderObjectTable(
     options: any,
     schema: Field[],
@@ -764,6 +806,7 @@ class WizardSummary extends React.Component<Props> {
           {this.renderStorageSection("backend")}
           {this.renderStorageSection("disk")}
           {this.renderScheduleSection()}
+          {this.renderTransferExecuteOptions()}
         </Column>
       </Wrapper>
     );

+ 24 - 3
src/components/smart/WizardPage/WizardPage.tsx

@@ -25,8 +25,8 @@ import WizardTemplate from "@src/components/modules/TemplateModule/WizardTemplat
 import { WizardNetworksChangeObject } from "@src/components/modules/WizardModule/WizardNetworks";
 import WizardPageContent from "@src/components/modules/WizardModule/WizardPageContent";
 import Modal from "@src/components/ui/Modal";
-import { executionOptions, providerTypes, wizardPages } from "@src/constants";
 import endpointStore from "@src/stores/EndpointStore";
+import { executeOptionsWithExecuteNow, deploymentFields, providerTypes, wizardPages } from "@src/constants";
 import instanceStore from "@src/stores/InstanceStore";
 import minionPoolStore from "@src/stores/MinionPoolStore";
 import networkStore from "@src/stores/NetworkStore";
@@ -423,6 +423,15 @@ class WizardPage extends React.Component<Props, State> {
     wizardStore.updateUrlState();
   }
 
+  handleTransferExecuteOptionsChange(field: Field, value: any) {
+    wizardStore.updateData({
+      executeOptions: {
+        ...wizardStore.data.executeOptions,
+        [field.name]: value,
+      },
+    });
+  }
+
   handleNetworkChange(changeObject: WizardNetworksChangeObject) {
     wizardStore.updateNetworks({
       sourceNic: changeObject.nic,
@@ -503,6 +512,15 @@ class WizardPage extends React.Component<Props, State> {
     if (wizardStore.currentPage.id !== wizardPages[0].id) {
       this.loadDataForPage(wizardStore.currentPage);
     }
+
+    if (!wizardStore.data.executeOptions) {
+      wizardStore.updateData({
+        executeOptions: [...executeOptionsWithExecuteNow, ...deploymentFields].reduce((acc, option) => {
+          acc[option.name] = option.defaultValue;
+          return acc;
+        }, {} as { [key: string]: any }),
+      });
+    }
   }
 
   async loadExtraOptions(opts: {
@@ -784,8 +802,8 @@ class WizardPage extends React.Component<Props, State> {
       return;
     }
 
-    const executeNowOptions = executionOptions.map(field => {
-      const value = options?.execute_now_options?.[field.name];
+    const executeNowOptions = executeOptionsWithExecuteNow.map(field => {
+      const value = wizardStore.data.executeOptions?.[field.name];
       if (value != null) {
         return { name: field.name, value };
       }
@@ -911,6 +929,9 @@ class WizardPage extends React.Component<Props, State> {
               onUserScriptUpload={s => {
                 this.handleUserScriptUpload(s);
               }}
+              onTransferExecuteOptionsChange={(field, value) => {
+                this.handleTransferExecuteOptionsChange(field, value);
+              }}
             />
           }
         />

+ 33 - 0
src/constants.ts

@@ -80,6 +80,38 @@ export const executionOptions = [
   }
 ];
 
+export const executeOptionsWithExecuteNow = [
+  ...executionOptions,
+  {
+    name: "execute_now",
+    type: "boolean",
+    label: "Execute Now",
+    defaultValue: true,
+    nullableBoolean: false,
+    description:
+      "When enabled, the transfer will be executed immediately after the options are configured.",
+  }
+];
+
+export const deploymentFields = [
+  {
+    name: "clone_disks",
+    type: "boolean",
+    label: "Clone Disks",
+    defaultValue: true,
+    nullableBoolean: false,
+    description: "When enabled, the disks will be cloned during the deployment.",
+  },
+  {
+    name: "skip_os_morphing",
+    type: "boolean",
+    label: "Skip OS Morphing",
+    defaultValue: false,
+    nullableBoolean: false,
+    description: "When enabled, OS morphing will be skipped during the deployment.",
+  }
+];
+
 export const wizardPages: WizardPage[] = [
   { id: "type", title: "New", breadcrumb: "Type" },
   {
@@ -111,6 +143,7 @@ export const wizardPages: WizardPage[] = [
     title: "Schedule",
     breadcrumb: "Schedule",
   },
+  { id: "execute", title: "Transfer Execution options", breadcrumb: "Execute" },
   { id: "summary", title: "Summary", breadcrumb: "Summary" },
 ];
 

+ 13 - 0
src/sources/WizardSource.ts

@@ -24,6 +24,7 @@ import type { InstanceScript } from "@src/@types/Instance";
 import DefaultOptionsSchemaParser from "@src/plugins/default/OptionsSchemaPlugin";
 import { ActionItem } from "@src/@types/MainItem";
 import { INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS } from "@src/components/modules/WizardModule/WizardOptions";
+import { deploymentFields, executionOptions } from "@src/constants";
 
 class WizardSource {
   async create(opts: {
@@ -105,6 +106,18 @@ class WizardSource {
       );
     }
 
+    executionOptions.forEach(option => {
+      if (data.executeOptions && data.executeOptions[option.name] !== undefined) {
+        payload[option.name] = data.executeOptions[option.name];
+      }
+    });
+
+    deploymentFields.forEach(option => {
+      if (data.executeOptions && data.executeOptions[option.name] !== undefined) {
+        payload[option.name] = data.executeOptions[option.name];
+      }
+    });
+
     const scenario = type == "replica" ? "replica" : "live_migration";
     payload.scenario = scenario;
 

+ 12 - 0
src/stores/WizardStore.ts

@@ -229,6 +229,18 @@ class WizardStore {
     this.data.destOptions = updateOptions(this.data.destOptions, data);
   }
 
+  @action updateTransferExecutionOptions(data: {
+    field: Field;
+    value: any;
+    parentFieldName: string | undefined;
+  }) {
+    this.data = { ...this.data };
+    this.data.executeOptions = updateOptions(
+      this.data.executeOptions,
+      data
+    );
+  }
+
   @action updateNetworks(network: NetworkMap) {
     if (!this.data.networks) {
       this.data.networks = [];