Copilot commented on code in PR #12357:
URL: https://github.com/apache/cloudstack/pull/12357#discussion_r2779371778


##########
api/src/main/java/org/apache/cloudstack/api/command/admin/backup/CloneBackupOfferingCmd.java:
##########
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package org.apache.cloudstack.api.command.admin.backup;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.acl.RoleType;
+import org.apache.cloudstack.api.APICommand;
+import org.apache.cloudstack.api.ApiConstants;
+import org.apache.cloudstack.api.ApiErrorCode;
+import org.apache.cloudstack.api.BaseAsyncCmd;
+import org.apache.cloudstack.api.BaseCmd;
+import org.apache.cloudstack.api.Parameter;
+import org.apache.cloudstack.api.ServerApiException;
+import org.apache.cloudstack.api.command.offering.DomainAndZoneIdResolver;
+import org.apache.cloudstack.api.response.BackupOfferingResponse;
+import org.apache.cloudstack.api.response.ZoneResponse;
+import org.apache.cloudstack.backup.BackupManager;
+import org.apache.cloudstack.backup.BackupOffering;
+import org.apache.cloudstack.context.CallContext;
+
+import com.cloud.event.EventTypes;
+import com.cloud.exception.ConcurrentOperationException;
+import com.cloud.exception.InsufficientCapacityException;
+import com.cloud.exception.InvalidParameterValueException;
+import com.cloud.exception.NetworkRuleConflictException;
+import com.cloud.exception.ResourceAllocationException;
+import com.cloud.exception.ResourceUnavailableException;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.LongFunction;
+
+@APICommand(name = "cloneBackupOffering",
+        description = "Clones a backup offering from an existing offering",
+        responseObject = BackupOfferingResponse.class, since = "4.14.0",

Review Comment:
   The API annotation says `since = "4.14.0"`, but this clone API is introduced 
alongside the other clone APIs marked `since = "4.23.0"` and even has a 
parameter marked `since = "4.23.0"`. Consider aligning the command-level 
`since` value to the actual release where this API is added.
   ```suggestion
           responseObject = BackupOfferingResponse.class, since = "4.23.0",
   ```



##########
api/src/main/java/org/apache/cloudstack/api/command/admin/backup/CloneBackupOfferingCmd.java:
##########
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package org.apache.cloudstack.api.command.admin.backup;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.acl.RoleType;
+import org.apache.cloudstack.api.APICommand;
+import org.apache.cloudstack.api.ApiConstants;
+import org.apache.cloudstack.api.ApiErrorCode;
+import org.apache.cloudstack.api.BaseAsyncCmd;
+import org.apache.cloudstack.api.BaseCmd;
+import org.apache.cloudstack.api.Parameter;
+import org.apache.cloudstack.api.ServerApiException;
+import org.apache.cloudstack.api.command.offering.DomainAndZoneIdResolver;
+import org.apache.cloudstack.api.response.BackupOfferingResponse;
+import org.apache.cloudstack.api.response.ZoneResponse;
+import org.apache.cloudstack.backup.BackupManager;
+import org.apache.cloudstack.backup.BackupOffering;
+import org.apache.cloudstack.context.CallContext;
+
+import com.cloud.event.EventTypes;
+import com.cloud.exception.ConcurrentOperationException;
+import com.cloud.exception.InsufficientCapacityException;
+import com.cloud.exception.InvalidParameterValueException;
+import com.cloud.exception.NetworkRuleConflictException;
+import com.cloud.exception.ResourceAllocationException;
+import com.cloud.exception.ResourceUnavailableException;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.LongFunction;
+
+@APICommand(name = "cloneBackupOffering",
+        description = "Clones a backup offering from an existing offering",
+        responseObject = BackupOfferingResponse.class, since = "4.14.0",
+        authorized = {RoleType.Admin})
+public class CloneBackupOfferingCmd extends BaseAsyncCmd implements 
DomainAndZoneIdResolver {
+
+    @Inject
+    protected BackupManager backupManager;
+
+    /////////////////////////////////////////////////////
+    //////////////// API parameters /////////////////////
+    ////////////////////////////////////////////////////
+
+    @Parameter(name = ApiConstants.SOURCE_OFFERING_ID, type = 
BaseCmd.CommandType.UUID, entityType = BackupOfferingResponse.class,
+            required = true, description = "The ID of the source backup 
offering to clone from")
+    private Long sourceOfferingId;
+
+    @Parameter(name = ApiConstants.NAME, type = BaseCmd.CommandType.STRING, 
required = false,

Review Comment:
   `name` is marked `required = false`, but the manager uses `cmd.getName()` 
for uniqueness checks and to persist the cloned offering. If `name` is omitted, 
this can lead to NPEs/invalid DB rows. Make the parameter required (or 
implement a safe default like `<sourceName>-clone`).
   ```suggestion
       @Parameter(name = ApiConstants.NAME, type = BaseCmd.CommandType.STRING, 
required = true,
   ```



##########
ui/src/views/offering/CloneComputeOffering.vue:
##########
@@ -0,0 +1,674 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+<template>
+  <div class="form-layout" v-ctrl-enter="handleSubmit">
+    <a-spin :spinning="loading">
+      <a-alert
+         v-if="resource"
+         type="info"
+         style="margin-bottom: 16px">
+        <template #message>
+          <div style="display: block; width: 100%;">
+            <div style="display: block; margin-bottom: 8px;">
+              <strong>{{ $t('message.clone.offering.from') }}: {{ 
resource.name }}</strong>
+            </div>
+            <div style="display: block; font-size: 12px;">
+              {{ $t('message.clone.offering.edit.hint') }}
+            </div>
+          </div>
+        </template>
+      </a-alert>
+      <ComputeOfferingForm
+        :initialValues="form"
+        :rules="rules"
+        :apiParams="apiParams"
+        :isSystem="isSystem"
+        :isAdmin="isAdmin"
+        :ref="formRef"
+        @submit="handleSubmit">
+
+        <!-- form content is provided by ComputeOfferingForm component -->
+        <template #form-actions>
+          <br/>
+          <div :span="24" class="action-button">
+            <a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
+            <a-button :loading="loading" ref="submit" type="primary" 
@click="handleSubmit">{{ $t('label.ok') }}</a-button>
+          </div>
+        </template>
+      </ComputeOfferingForm>
+     </a-spin>
+   </div>
+ </template>
+
+<script>
+import ComputeOfferingForm from '@/components/offering/ComputeOfferingForm'
+import { ref, reactive } from 'vue'
+import { getAPI, postAPI } from '@/api'
+import AddDiskOffering from '@/views/offering/AddDiskOffering'
+import { isAdmin } from '@/role'
+import { mixinForm } from '@/utils/mixin'
+import ResourceIcon from '@/components/view/ResourceIcon'
+import TooltipLabel from '@/components/widgets/TooltipLabel'
+import DetailsInput from '@/components/widgets/DetailsInput'
+import store from '@/store'
+
+export default {
+  name: 'CreateComputeOffering',
+  mixins: [mixinForm],
+  components: {

Review Comment:
   The component is registered with name 'CreateComputeOffering', but this view 
is for cloning. This breaks devtools/component identification and may affect 
caching/keep-alive behavior if other components rely on the name. Rename it to 
'CloneComputeOffering' (or another clone-specific name).



##########
ui/src/components/offering/DiskOfferingForm.vue:
##########
@@ -0,0 +1,507 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+<template>
+  <a-form
+    ref="internalFormRef"
+    :model="form"
+    :rules="rules"
+    @finish="onInternalSubmit"
+    layout="vertical">
+    <a-form-item name="name" ref="name">
+      <template #label>
+        <tooltip-label :title="$t('label.name')" 
:tooltip="apiParams.name.description"/>
+      </template>
+      <a-input
+        v-focus="true"
+        v-model:value="form.name"
+        :placeholder="apiParams.name.description"/>
+    </a-form-item>
+    <a-form-item name="displaytext" ref="displaytext">
+      <template #label>
+        <tooltip-label :title="$t('label.displaytext')" 
:tooltip="apiParams.displaytext.description"/>
+      </template>
+      <a-input
+        v-model:value="form.displaytext"
+        :placeholder="apiParams.displaytext.description"/>
+    </a-form-item>
+    <a-form-item name="storagetype" ref="storagetype">
+      <template #label>
+        <tooltip-label :title="$t('label.storagetype')" 
:tooltip="apiParams.storagetype.description"/>
+      </template>
+      <a-radio-group
+        v-model:value="form.storagetype"
+        buttonStyle="solid">
+        <a-radio-button value="shared">
+          {{ $t('label.shared') }}
+        </a-radio-button>
+        <a-radio-button value="local">
+          {{ $t('label.local') }}
+        </a-radio-button>
+      </a-radio-group>
+    </a-form-item>
+    <a-form-item name="provisioningtype" ref="provisioningtype">
+      <template #label>
+        <tooltip-label :title="$t('label.provisioningtype')" 
:tooltip="apiParams.provisioningtype.description"/>
+      </template>
+      <a-radio-group
+        v-model:value="form.provisioningtype"
+        buttonStyle="solid">
+        <a-radio-button value="thin">
+          {{ $t('label.provisioningtype.thin') }}
+        </a-radio-button>
+        <a-radio-button value="sparse">
+          {{ $t('label.provisioningtype.sparse') }}
+        </a-radio-button>
+        <a-radio-button value="fat">
+          {{ $t('label.provisioningtype.fat') }}
+        </a-radio-button>
+      </a-radio-group>
+    </a-form-item>
+    <a-form-item name="encryptdisk" ref="encryptdisk">
+      <template #label>
+        <tooltip-label :title="$t('label.encrypt')" 
:tooltip="apiParams.encrypt.description" />
+      </template>
+      <a-switch v-model:checked="form.encryptdisk" :checked="encryptdisk" 
@change="val => { encryptdisk = val }" />
+    </a-form-item>
+    <a-form-item name="disksizestrictness" ref="disksizestrictness">
+      <template #label>
+        <tooltip-label :title="$t('label.disksizestrictness')" 
:tooltip="apiParams.disksizestrictness.description" />
+      </template>
+      <a-switch v-model:checked="form.disksizestrictness" 
:checked="disksizestrictness" @change="val => { disksizestrictness = val }" />

Review Comment:
   Same issue as encrypt switch: `v-model:checked` plus explicit `:checked` 
backed by `disksizestrictness` can cause the displayed value to diverge from 
`form.disksizestrictness` / `initialValues`. Bind the switch to a single source 
of truth (the form model).
   ```suggestion
         <a-switch v-model:checked="form.encryptdisk" />
       </a-form-item>
       <a-form-item name="disksizestrictness" ref="disksizestrictness">
         <template #label>
           <tooltip-label :title="$t('label.disksizestrictness')" 
:tooltip="apiParams.disksizestrictness.description" />
         </template>
         <a-switch v-model:checked="form.disksizestrictness" />
   ```



##########
ui/src/views/offering/CloneComputeOffering.vue:
##########
@@ -0,0 +1,674 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+<template>
+  <div class="form-layout" v-ctrl-enter="handleSubmit">
+    <a-spin :spinning="loading">
+      <a-alert
+         v-if="resource"
+         type="info"
+         style="margin-bottom: 16px">
+        <template #message>
+          <div style="display: block; width: 100%;">
+            <div style="display: block; margin-bottom: 8px;">
+              <strong>{{ $t('message.clone.offering.from') }}: {{ 
resource.name }}</strong>
+            </div>
+            <div style="display: block; font-size: 12px;">
+              {{ $t('message.clone.offering.edit.hint') }}
+            </div>
+          </div>
+        </template>
+      </a-alert>
+      <ComputeOfferingForm
+        :initialValues="form"
+        :rules="rules"
+        :apiParams="apiParams"
+        :isSystem="isSystem"
+        :isAdmin="isAdmin"
+        :ref="formRef"
+        @submit="handleSubmit">
+
+        <!-- form content is provided by ComputeOfferingForm component -->
+        <template #form-actions>
+          <br/>
+          <div :span="24" class="action-button">
+            <a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
+            <a-button :loading="loading" ref="submit" type="primary" 
@click="handleSubmit">{{ $t('label.ok') }}</a-button>
+          </div>
+        </template>
+      </ComputeOfferingForm>
+     </a-spin>
+   </div>
+ </template>
+
+<script>
+import ComputeOfferingForm from '@/components/offering/ComputeOfferingForm'
+import { ref, reactive } from 'vue'
+import { getAPI, postAPI } from '@/api'
+import AddDiskOffering from '@/views/offering/AddDiskOffering'
+import { isAdmin } from '@/role'
+import { mixinForm } from '@/utils/mixin'
+import ResourceIcon from '@/components/view/ResourceIcon'
+import TooltipLabel from '@/components/widgets/TooltipLabel'
+import DetailsInput from '@/components/widgets/DetailsInput'
+import store from '@/store'
+
+export default {
+  name: 'CreateComputeOffering',
+  mixins: [mixinForm],
+  components: {
+    ComputeOfferingForm,
+    AddDiskOffering,
+    ResourceIcon,
+    TooltipLabel,
+    DetailsInput
+  },
+  props: {
+    resource: {
+      type: Object,
+      required: true
+    }
+  },
+  data () {
+    return {
+      isSystem: false,
+      naturalNumberRule: {
+        type: 'number',
+        validator: this.validateNumber
+      },
+      wholeNumberRule: {
+        type: 'number',
+        validator: async (rule, value) => {
+          if (value && (isNaN(value) || value < 0)) {
+            return Promise.reject(this.$t('message.error.number'))
+          }
+          return Promise.resolve()
+        }
+      },
+      storageType: 'shared',
+      provisioningType: 'thin',
+      cacheMode: 'none',
+      offeringType: 'fixed',
+      isCustomizedDiskIops: false,
+      isPublic: true,
+      domains: [],
+      domainLoading: false,
+      zones: [],
+      zoneLoading: false,
+      selectedDeploymentPlanner: null,
+      storagePolicies: null,
+      storageTags: [],
+      storageTagLoading: false,
+      deploymentPlanners: [],
+      deploymentPlannerLoading: false,
+      plannerModeVisible: false,
+      plannerMode: '',
+      selectedGpuCard: '',
+      showDiskOfferingModal: false,
+      gpuCardLoading: false,
+      gpuCards: [],
+      loading: false,
+      dynamicscalingenabled: true,
+      diskofferingstrictness: false,
+      encryptdisk: false,
+      computeonly: true,
+      diskOfferingLoading: false,
+      diskOfferings: [],
+      selectedDiskOfferingId: '',
+      qosType: '',
+      isDomainAdminAllowedToInformTags: false,
+      isLeaseFeatureEnabled: this.$store.getters.features.instanceleaseenabled,
+      showLeaseOptions: false,
+      expiryActions: ['STOP', 'DESTROY'],
+      defaultLeaseDuration: 90,
+      defaultLeaseExpiryAction: 'STOP',
+      leaseduration: undefined,
+      leaseexpiryaction: undefined,
+      vgpuProfiles: [],
+      vgpuProfileLoading: false,
+      externalDetailsEnabled: false
+    }
+  },
+  beforeCreate () {
+    this.apiParams = this.$getApiParams('cloneServiceOffering')
+  },
+  created () {
+    this.zones = [
+      {
+        id: null,
+        name: this.$t('label.all.zone')
+      }
+    ]
+    if (this.$route.meta.name === 'systemoffering') {
+      this.isSystem = true
+    }
+    this.initForm()
+    this.fetchData()
+    this.isPublic = isAdmin()
+    this.form.ispublic = this.isPublic
+  },
+  methods: {
+    initForm () {
+      this.formRef = ref()
+      this.form = reactive({
+        systemvmtype: 'domainrouter',
+        offeringtype: this.offeringType,
+        ispublic: this.isPublic,
+        dynamicscalingenabled: true,
+        plannermode: this.plannerMode,
+        gpucardid: this.selectedGpuCard,
+        vgpuprofile: '',
+        gpucount: '1',
+        gpudisplay: false,
+        computeonly: this.computeonly,
+        storagetype: this.storageType,
+        provisioningtype: this.provisioningType,
+        cachemode: this.cacheMode,
+        qostype: this.qosType,
+        iscustomizeddiskiops: this.isCustomizedDiskIops,
+        diskofferingid: this.selectedDiskOfferingId,
+        diskofferingstrictness: this.diskofferingstrictness,
+        encryptdisk: this.encryptdisk,
+        leaseduration: this.leaseduration,
+        leaseexpiryaction: this.leaseexpiryaction
+      })
+      this.rules = reactive({
+        name: [{ required: true, message: 
this.$t('message.error.required.input') }],
+        cpunumber: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        cpuspeed: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.wholeNumberRule
+        ],
+        mincpunumber: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        maxcpunumber: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        memory: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        minmemory: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        maxmemory: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        networkrate: [this.naturalNumberRule],
+        rootdisksize: [this.naturalNumberRule],
+        diskbytesreadrate: [this.naturalNumberRule],
+        diskbyteswriterate: [this.naturalNumberRule],
+        diskiopsreadrate: [this.naturalNumberRule],
+        diskiopswriterate: [this.naturalNumberRule],
+        diskiopsmin: [this.naturalNumberRule],
+        diskiopsmax: [this.naturalNumberRule],
+        hypervisorsnapshotreserve: [this.naturalNumberRule],
+        domainid: [{ type: 'array', required: true, message: 
this.$t('message.error.select') }],
+        diskofferingid: [{ required: true, message: 
this.$t('message.error.select') }],
+        gpucount: [{
+          type: 'number',
+          validator: async (rule, value) => {
+            if (value && (isNaN(value) || value < 1)) {
+              return 
Promise.reject(this.$t('message.error.number.minimum.one'))
+            }
+            return Promise.resolve()
+          }
+        }],
+        zoneid: [{
+          type: 'array',
+          validator: async (rule, value) => {
+            if (value && value.length > 1 && value.indexOf(0) !== -1) {
+              return Promise.reject(this.$t('message.error.zone.combined'))
+            }
+            return Promise.resolve()
+          }
+        }],
+        leaseduration: [this.naturalNumberRule]
+      })
+    },
+    fetchData () {
+      this.fetchDomainData()
+      this.fetchZoneData()
+      this.fetchGPUCards()
+      if (isAdmin()) {
+        this.fetchStorageTagData()
+        this.fetchDeploymentPlannerData()
+      } else if (this.isDomainAdmin()) {
+        this.checkIfDomainAdminIsAllowedToInformTag()
+        if (this.isDomainAdminAllowedToInformTags) {
+          this.fetchStorageTagData()
+        }
+      }
+      this.fetchDiskOfferings()
+      this.populateFormFromResource()
+    },
+    populateFormFromResource () {
+      if (!this.resource) return
+
+      // Pre-fill form with source offering values
+      const r = this.resource
+      this.form.name = r.name + ' - Clone'
+      this.form.displaytext = r.displaytext
+
+      if (r.iscustomized) {
+        if (r.cpunumber || r.cpuspeed || r.memory) {
+          this.offeringType = 'customconstrained'
+          this.form.offeringtype = 'customconstrained'
+        } else {
+          this.offeringType = 'customunconstrained'
+          this.form.offeringtype = 'customunconstrained'
+        }
+      } else {
+        this.offeringType = 'fixed'
+        this.form.offeringtype = 'fixed'
+      }
+
+      if (r.cpunumber) this.form.cpunumber = r.cpunumber
+      if (r.cpuspeed) this.form.cpuspeed = r.cpuspeed
+      if (r.memory) this.form.memory = r.memory
+
+      if (r.mincpunumber) this.form.mincpunumber = r.mincpunumber
+      if (r.maxcpunumber) this.form.maxcpunumber = r.maxcpunumber
+      if (r.minmemory) this.form.minmemory = r.minmemory
+      if (r.maxmemory) this.form.maxmemory = r.maxmemory
+
+      if (r.hosttags) this.form.hosttags = r.hosttags
+      if (r.networkrate) this.form.networkrate = r.networkrate
+      if (r.offerha !== undefined) this.form.offerha = r.offerha
+      if (r.dynamicscalingenabled !== undefined) {
+        this.form.dynamicscalingenabled = r.dynamicscalingenabled
+        this.dynamicscalingenabled = r.dynamicscalingenabled
+      }
+      if (r.limitcpuuse !== undefined) this.form.limitcpuuse = r.limitcpuuse
+      if (r.isvolatile !== undefined) this.form.isvolatile = r.isvolatile
+
+      if (r.storagetype) {
+        this.storageType = r.storagetype
+        this.form.storagetype = r.storagetype
+      }
+      if (r.provisioningtype) {
+        this.provisioningType = r.provisioningtype
+        this.form.provisioningtype = r.provisioningtype
+      }
+      if (r.cachemode) {
+        this.cacheMode = r.cachemode
+        this.form.cachemode = r.cachemode
+      }
+
+      if (r.diskofferingstrictness !== undefined) {
+        this.form.diskofferingstrictness = r.diskofferingstrictness
+        this.diskofferingstrictness = r.diskofferingstrictness
+      }
+      if (r.encryptroot !== undefined) {
+        this.form.encryptdisk = r.encryptroot
+        this.encryptdisk = r.encryptroot
+      }
+
+      if (r.diskBytesReadRate || r.diskBytesWriteRate || r.diskIopsReadRate || 
r.diskIopsWriteRate) {
+        this.qosType = 'hypervisor'
+        this.form.qostype = 'hypervisor'
+        if (r.diskBytesReadRate) this.form.diskbytesreadrate = 
r.diskBytesReadRate
+        if (r.diskBytesWriteRate) this.form.diskbyteswriterate = 
r.diskBytesWriteRate
+        if (r.diskIopsReadRate) this.form.diskiopsreadrate = r.diskIopsReadRate
+        if (r.diskIopsWriteRate) this.form.diskiopswriterate = 
r.diskIopsWriteRate
+      } else if (r.miniops || r.maxiops) {
+        this.qosType = 'storage'
+        this.form.qostype = 'storage'
+        if (r.miniops) this.form.diskiopsmin = r.miniops
+        if (r.maxiops) this.form.diskiopsmax = r.maxiops
+        if (r.hypervisorsnapshotreserve) this.form.hypervisorsnapshotreserve = 
r.hypervisorsnapshotreserve
+      }
+      if (r.iscustomizediops !== undefined) {
+        this.form.iscustomizeddiskiops = r.iscustomizediops
+        this.isCustomizedDiskIops = r.iscustomizediops
+      }
+
+      if (r.rootdisksize) this.form.rootdisksize = r.rootdisksize
+
+      if (r.tags) {
+        this.form.storagetags = r.tags.split(',')
+      }
+
+      if (r.gpucardid) {
+        this.form.gpucardid = r.gpucardid
+        this.selectedGpuCard = r.gpucardid
+        if (r.gpucardid) {
+          this.fetchVgpuProfiles(r.gpucardid)
+        }
+      }
+      if (r.vgpuprofileid) this.form.vgpuprofile = r.vgpuprofileid
+      if (r.gpucount) this.form.gpucount = r.gpucount
+      if (r.gpudisplay !== undefined) this.form.gpudisplay = r.gpudisplay
+
+      if (r.leaseduration) {
+        this.form.leaseduration = r.leaseduration
+        this.showLeaseOptions = true
+      }
+      if (r.leaseexpiryaction) this.form.leaseexpiryaction = 
r.leaseexpiryaction
+
+      if (r.purgeresources !== undefined) this.form.purgeresources = 
r.purgeresources
+
+      if (r.vspherestoragepolicy) this.form.storagepolicy = 
r.vspherestoragepolicy
+
+      if (r.systemvmtype) this.form.systemvmtype = r.systemvmtype
+
+      if (r.deploymentplanner) {
+        this.form.deploymentplanner = r.deploymentplanner
+        this.handleDeploymentPlannerChange(r.deploymentplanner)
+      }
+
+      if (r.serviceofferingdetails && 
Object.keys(r.serviceofferingdetails).length > 0) {
+        this.externalDetailsEnabled = true
+        this.form.externaldetails = r.serviceofferingdetails
+      }
+    },
+    fetchGPUCards () {
+      this.gpuCardLoading = true
+      getAPI('listGpuCards', {
+      }).then(json => {
+        this.gpuCards = json.listgpucardsresponse.gpucard || []
+        this.gpuCards.unshift({
+          id: '',
+          name: this.$t('label.none')
+        })
+      }).finally(() => {
+        this.gpuCardLoading = false
+      })
+    },
+    fetchDiskOfferings () {
+      this.diskOfferingLoading = true
+      getAPI('listDiskOfferings', {
+        listall: true
+      }).then(json => {
+        this.diskOfferings = json.listdiskofferingsresponse.diskoffering || []
+        if (this.selectedDiskOfferingId === '') {
+          this.selectedDiskOfferingId = this.diskOfferings[0].id || ''
+        }
+      }).finally(() => {
+        this.diskOfferingLoading = false
+      })
+    },
+    isAdmin () {
+      return isAdmin()
+    },
+    isDomainAdmin () {
+      return ['DomainAdmin'].includes(this.$store.getters.userInfo.roletype)
+    },
+    checkIfDomainAdminIsAllowedToInformTag () {
+      const params = { id: store.getters.userInfo.accountid }
+      getAPI('isAccountAllowedToCreateOfferingsWithTags', params).then(json => 
{
+        this.isDomainAdminAllowedToInformTags = 
json.isaccountallowedtocreateofferingswithtagsresponse.isallowed.isallowed
+      })
+    },
+    fetchDomainData () {
+      const params = {}
+      params.listAll = true
+      params.showicon = true
+      params.details = 'min'
+      this.domainLoading = true
+      getAPI('listDomains', params).then(json => {
+        const listDomains = json.listdomainsresponse.domain
+        this.domains = this.domains.concat(listDomains)
+      }).finally(() => {
+        this.domainLoading = false
+      })
+    },
+    fetchZoneData () {
+      const params = {}
+      params.showicon = true
+      this.zoneLoading = true
+      getAPI('listZones', params).then(json => {
+        const listZones = json.listzonesresponse.zone
+        if (listZones) {
+          this.zones = this.zones.concat(listZones)
+        }
+      }).finally(() => {
+        this.zoneLoading = false
+      })
+    },
+    fetchStorageTagData () {
+      this.storageTagLoading = true
+      this.storageTags = []
+      getAPI('listStorageTags').then(json => {
+        const tags = json.liststoragetagsresponse.storagetag || []
+        for (const tag of tags) {
+          if (!this.storageTags.includes(tag.name)) {
+            this.storageTags.push(tag.name)
+          }
+        }
+      }).finally(() => {
+        this.storageTagLoading = false
+      })
+    },
+    fetchDeploymentPlannerData () {
+      this.deploymentPlannerLoading = true
+      getAPI('listDeploymentPlanners').then(json => {
+        const planners = json.listdeploymentplannersresponse.deploymentPlanner
+        this.deploymentPlanners = this.deploymentPlanners.concat(planners)
+        this.deploymentPlanners.unshift({ name: '' })
+        this.form.deploymentplanner = this.deploymentPlanners.length > 0 ? 
this.deploymentPlanners[0].name : ''
+      }).finally(() => {
+        this.deploymentPlannerLoading = false
+      })
+    },
+    handleSubmit (e) {
+      if (e && e.preventDefault) {
+        e.preventDefault()
+      }
+      if (this.loading) return
+
+      this.formRef.value.validate().then((values) => {
+        var params = {
+          issystem: this.isSystem,
+          name: values.name,
+          displaytext: values.displaytext,
+          storagetype: values.storagetype,
+          provisioningtype: values.provisioningtype,
+          cachemode: values.cachemode,
+          customized: values.offeringtype !== 'fixed',
+          offerha: values.offerha === true,
+          limitcpuuse: values.limitcpuuse === true,
+          dynamicscalingenabled: values.dynamicscalingenabled,
+          diskofferingstrictness: values.diskofferingstrictness,
+          encryptroot: values.encryptdisk,
+          purgeresources: values.purgeresources,
+          leaseduration: values.leaseduration,
+          leaseexpiryaction: values.leaseexpiryaction
+        }
+
+        if (values.diskofferingid) {
+          params.diskofferingid = values.diskofferingid
+        }
+
+        if (values.vgpuprofile) {
+          params.vgpuprofileid = values.vgpuprofile
+        }
+        if (values.gpucount && values.gpucount > 0) {
+          params.gpucount = values.gpucount
+        }
+        if (values.gpudisplay !== undefined) {
+          params.gpudisplay = values.gpudisplay
+        }
+
+        if (values.offeringtype === 'fixed') {
+          params.cpunumber = values.cpunumber
+          params.cpuspeed = values.cpuspeed
+          params.memory = values.memory
+        } else {
+          if (values.cpuspeed != null &&
+               values.mincpunumber != null &&
+               values.maxcpunumber != null &&
+               values.minmemory != null &&
+               values.maxmemory != null) {
+            params.cpuspeed = values.cpuspeed
+            params.mincpunumber = values.mincpunumber
+            params.maxcpunumber = values.maxcpunumber
+            params.minmemory = values.minmemory
+            params.maxmemory = values.maxmemory
+          }
+        }
+
+        if (values.networkrate != null && values.networkrate.length > 0) {
+          params.networkrate = values.networkrate
+        }
+        if (values.rootdisksize != null && values.rootdisksize.length > 0) {
+          params.rootdisksize = values.rootdisksize
+        }
+        if (values.qostype === 'storage') {
+          var customIops = values.iscustomizeddiskiops === true
+          params.customizediops = customIops
+          if (!customIops) {
+            if (values.diskiopsmin != null && values.diskiopsmin.length > 0) {
+              params.miniops = values.diskiopsmin
+            }
+            if (values.diskiopsmax != null && values.diskiopsmax.length > 0) {
+              params.maxiops = values.diskiopsmax
+            }
+            if (values.hypervisorsnapshotreserve !== undefined &&
+               values.hypervisorsnapshotreserve != null && 
values.hypervisorsnapshotreserve.length > 0) {
+              params.hypervisorsnapshotreserve = 
values.hypervisorsnapshotreserve
+            }
+          }
+        } else if (values.qostype === 'hypervisor') {
+          if (values.diskbytesreadrate != null && 
values.diskbytesreadrate.length > 0) {
+            params.bytesreadrate = values.diskbytesreadrate
+          }
+          if (values.diskbyteswriterate != null && 
values.diskbyteswriterate.length > 0) {
+            params.byteswriterate = values.diskbyteswriterate
+          }
+          if (values.diskiopsreadrate != null && 
values.diskiopsreadrate.length > 0) {
+            params.iopsreadrate = values.diskiopsreadrate
+          }
+          if (values.diskiopswriterate != null && 
values.diskiopswriterate.length > 0) {
+            params.iopswriterate = values.diskiopswriterate
+          }
+        }
+        if (values.storagetags != null && values.storagetags.length > 0) {
+          var tags = values.storagetags.join(',')
+          params.tags = tags
+        }
+        if (values.hosttags != null && values.hosttags.length > 0) {
+          params.hosttags = values.hosttags
+        }
+        if ('deploymentplanner' in values &&
+           values.deploymentplanner !== undefined &&
+           values.deploymentplanner != null && values.deploymentplanner.length 
> 0) {
+          params.deploymentplanner = values.deploymentplanner
+        }
+        if ('deploymentplanner' in values &&
+           values.deploymentplanner !== undefined &&
+           values.deploymentplanner === 'ImplicitDedicationPlanner' &&
+           values.plannermode !== undefined &&
+           values.plannermode !== '') {
+          params['serviceofferingdetails[0].key'] = 'ImplicitDedicationMode'
+          params['serviceofferingdetails[0].value'] = values.plannermode
+        }
+        if ('isvolatile' in values && values.isvolatile !== undefined) {
+          params.isvolatile = values.isvolatile === true
+        }
+        if ('systemvmtype' in values && values.systemvmtype !== undefined) {
+          params.systemvmtype = values.systemvmtype
+        }
+
+        if ('leaseduration' in values && values.leaseduration !== undefined) {
+          params.leaseduration = values.leaseduration
+        }
+
+        if ('leaseexpiryaction' in values && values.leaseexpiryaction !== 
undefined) {
+          params.leaseexpiryaction = values.leaseexpiryaction
+        }
+
+        if (values.ispublic !== true) {
+          var domainIndexes = values.domainid
+          var domainId = null
+          if (domainIndexes && domainIndexes.length > 0) {
+            var domainIds = []
+            for (var i = 0; i < domainIndexes.length; i++) {
+              domainIds = domainIds.concat(this.domains[domainIndexes[i]].id)
+            }
+            domainId = domainIds.join(',')
+          }
+          if (domainId) {
+            params.domainid = domainId
+          }
+        }
+        var zoneIndexes = values.zoneid
+        var zoneId = null
+        if (zoneIndexes && zoneIndexes.length > 0) {
+          var zoneIds = []
+          for (var j = 0; j < zoneIndexes.length; j++) {
+            zoneIds = zoneIds.concat(this.zones[zoneIndexes[j]].id)
+          }
+          zoneId = zoneIds.join(',')
+        }
+        if (zoneId) {
+          params.zoneid = zoneId
+        }
+        if (values.storagepolicy) {
+          params.storagepolicy = values.storagepolicy
+        }
+        if (values.externaldetails) {
+          Object.entries(values.externaldetails).forEach(([key, value]) => {
+            params['externaldetails[0].' + key] = value
+          })
+        }
+
+        params.sourceofferingid = this.resource.id
+
+        postAPI('cloneServiceOffering', params).then(json => {
+          const message = this.isSystem

Review Comment:
   `this.loading` is never set to `true` before calling 
`postAPI('cloneServiceOffering', ...)`, so the submit-guard (`if (this.loading) 
return`) does not prevent double submits and the UI spinner never activates. 
Set `this.loading = true` immediately before the API call (and keep the 
existing `finally` that resets it).



##########
server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java:
##########
@@ -811,6 +818,348 @@ protected void checkCapabilityPerServiceProvider(final 
Set<Provider> providers,
         }
     }
 
+    @Override
+    public VpcOffering cloneVPCOffering(CloneVPCOfferingCmd cmd) {
+        Long sourceVpcOfferingId = cmd.getSourceOfferingId();
+
+        final VpcOffering sourceVpcOffering = 
_vpcOffDao.findById(sourceVpcOfferingId);
+        if (sourceVpcOffering == null) {
+            throw new InvalidParameterValueException("Unable to find source 
VPC offering by id " + sourceVpcOfferingId);
+        }
+
+        String name = cmd.getVpcOfferingName();
+        if (name == null || name.isEmpty()) {
+            throw new InvalidParameterValueException("Name is required when 
cloning a VPC offering");
+        }
+
+        VpcOfferingVO vpcOfferingVO = _vpcOffDao.findByUniqueName(name);
+        if (vpcOfferingVO != null) {
+            throw new InvalidParameterValueException(String.format("A VPC 
offering with name %s already exists", name));
+        }
+
+        logger.info("Cloning VPC offering {} (id: {}) to new offering with 
name: {}",
+                sourceVpcOffering.getName(), sourceVpcOfferingId, name);
+
+        Map<Network.Service, Set<Network.Provider>> sourceServiceProviderMap = 
getVpcOffSvcProvidersMap(sourceVpcOfferingId);
+        validateProvider(sourceVpcOffering, sourceServiceProviderMap, 
cmd.getProvider(), cmd.getNetworkMode());
+
+        applySourceOfferingValuesToCloneCmd(cmd, sourceServiceProviderMap, 
sourceVpcOffering);
+
+        return createVpcOffering(cmd);
+    }
+
+    private void validateProvider(VpcOffering sourceVpcOffering,
+                                  Map<Network.Service, Set<Network.Provider>> 
sourceServiceProviderMap,
+                                  String provider, String networkMode) {
+        provider = 
ConfigurationManagerImpl.getExternalNetworkProvider(provider, 
sourceServiceProviderMap);
+        if (provider != null && (provider.equals("NSX") || 
provider.equals("Netris"))) {
+            if (networkMode != null && sourceVpcOffering.getNetworkMode() != 
null) {
+                if 
(!networkMode.equalsIgnoreCase(sourceVpcOffering.getNetworkMode().toString())) {
+                    throw new InvalidParameterValueException(
+                            String.format("Cannot change network mode when 
cloning %s provider VPC offerings. " +
+                                            "Source offering has network mode 
'%s', but '%s' was specified. ",
+                                    provider, 
sourceVpcOffering.getNetworkMode(), networkMode));
+                }
+            }
+        }
+    }
+
+    private void applySourceOfferingValuesToCloneCmd(CloneVPCOfferingCmd cmd,
+                                                     Map<Network.Service, 
Set<Network.Provider>> sourceServiceProviderMap,
+                                                     VpcOffering 
sourceVpcOffering) {
+        Long sourceOfferingId = sourceVpcOffering.getId();
+
+        List<String> finalServices = resolveFinalServicesList(cmd, 
sourceServiceProviderMap);
+
+        Map finalServiceProviderMap = resolveServiceProviderMap(cmd, 
sourceServiceProviderMap, finalServices);
+
+        List<Long> sourceDomainIds = 
vpcOfferingDetailsDao.findDomainIds(sourceOfferingId);
+        List<Long> sourceZoneIds = 
vpcOfferingDetailsDao.findZoneIds(sourceOfferingId);
+
+        Map<String, String> sourceServiceCapabilityList = 
reconstructServiceCapabilityList(sourceVpcOffering);
+
+        applyResolvedValuesToCommand(cmd, (VpcOfferingVO)sourceVpcOffering, 
finalServices, finalServiceProviderMap,
+                sourceDomainIds, sourceZoneIds, sourceServiceCapabilityList);
+    }
+
+    /**
+     * Reconstructs the service capability list from the source VPC offering's 
stored capability flags.
+     * These capabilities were originally passed during creation and stored as 
boolean flags in the offering.
+     *
+     * Returns a Map in the format expected by 
CreateVPCOfferingCmd.serviceCapabilityList:
+     * Map<String, String> with keys like "0.service", "0.capabilitytype", 
"0.capabilityvalue"
+     */
+    private Map<String, String> reconstructServiceCapabilityList(VpcOffering 
sourceOffering) {
+        Map<String, String> capabilityList = new HashMap<>();
+        int index = 0;
+
+        if (sourceOffering.isOffersRegionLevelVPC()) {
+            capabilityList.put(index + ".service", 
Network.Service.Connectivity.getName());
+            capabilityList.put(index + ".capabilitytype", 
Network.Capability.RegionLevelVpc.getName());
+            capabilityList.put(index + ".capabilityvalue", "true");
+            index++;
+        }
+
+        if (sourceOffering.isSupportsDistributedRouter()) {
+            capabilityList.put(index + ".service", 
Network.Service.Connectivity.getName());
+            capabilityList.put(index + ".capabilitytype", 
Network.Capability.DistributedRouter.getName());
+            capabilityList.put(index + ".capabilityvalue", "true");
+            index++;
+        }
+
+        if (sourceOffering.isRedundantRouter()) {
+            Map<Network.Service, Set<Network.Provider>> serviceProviderMap = 
getVpcOffSvcProvidersMap(sourceOffering.getId());
+
+            // Check which service has VPCVirtualRouter provider - SourceNat 
takes precedence
+            Network.Service redundantRouterService = null;
+            for (Network.Service service : 
Arrays.asList(Network.Service.SourceNat, Network.Service.Gateway, 
Network.Service.StaticNat)) {
+                Set<Network.Provider> providers = 
serviceProviderMap.get(service);
+                if (providers != null && 
providers.contains(Network.Provider.VPCVirtualRouter)) {
+                    redundantRouterService = service;
+                    break;
+                }
+            }
+
+            if (redundantRouterService != null) {
+                capabilityList.put(index + ".service", 
redundantRouterService.getName());
+                capabilityList.put(index + ".capabilitytype", 
Network.Capability.RedundantRouter.getName());
+                capabilityList.put(index + ".capabilityvalue", "true");
+            }
+        }
+
+        return capabilityList;
+    }
+
+    private List<String> resolveFinalServicesList(CloneVPCOfferingCmd cmd,
+                                                  Map<Network.Service, 
Set<Network.Provider>> sourceServiceProviderMap) {
+
+        List<String> cmdServices = cmd.getSupportedServices();
+        List<String> addServices = cmd.getAddServices();
+        List<String> dropServices = cmd.getDropServices();
+
+        if (cmdServices != null && !cmdServices.isEmpty()) {
+            return cmdServices;
+        }
+
+        List<String> finalServices = new ArrayList<>();
+        for (Network.Service service : sourceServiceProviderMap.keySet()) {
+            finalServices.add(service.getName());
+        }
+
+        if (dropServices != null && !dropServices.isEmpty()) {
+            List<String> normalizedDropServices = new ArrayList<>();
+            for (String serviceName : dropServices) {
+                Network.Service service = 
Network.Service.getService(serviceName);
+                if (service == null) {
+                    throw new InvalidParameterValueException("Service " + 
serviceName + " is not supported in VPC");
+                }
+                normalizedDropServices.add(service.getName());
+            }
+            finalServices.removeAll(dropServices);

Review Comment:
   `normalizedDropServices` is computed to canonicalize service names, but the 
code removes `dropServices` (raw input) from `finalServices` instead of 
removing `normalizedDropServices`. This can fail to drop services when callers 
provide non-canonical casing/names, and it leaves `normalizedDropServices` 
unused. Use `finalServices.removeAll(normalizedDropServices)`.
   ```suggestion
               finalServices.removeAll(normalizedDropServices);
   ```



##########
ui/src/views/offering/CloneComputeOffering.vue:
##########
@@ -0,0 +1,674 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+<template>
+  <div class="form-layout" v-ctrl-enter="handleSubmit">
+    <a-spin :spinning="loading">
+      <a-alert
+         v-if="resource"
+         type="info"
+         style="margin-bottom: 16px">
+        <template #message>
+          <div style="display: block; width: 100%;">
+            <div style="display: block; margin-bottom: 8px;">
+              <strong>{{ $t('message.clone.offering.from') }}: {{ 
resource.name }}</strong>
+            </div>
+            <div style="display: block; font-size: 12px;">
+              {{ $t('message.clone.offering.edit.hint') }}
+            </div>
+          </div>
+        </template>
+      </a-alert>
+      <ComputeOfferingForm
+        :initialValues="form"
+        :rules="rules"
+        :apiParams="apiParams"
+        :isSystem="isSystem"
+        :isAdmin="isAdmin"
+        :ref="formRef"
+        @submit="handleSubmit">
+
+        <!-- form content is provided by ComputeOfferingForm component -->
+        <template #form-actions>
+          <br/>
+          <div :span="24" class="action-button">
+            <a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
+            <a-button :loading="loading" ref="submit" type="primary" 
@click="handleSubmit">{{ $t('label.ok') }}</a-button>
+          </div>
+        </template>
+      </ComputeOfferingForm>
+     </a-spin>
+   </div>
+ </template>
+
+<script>
+import ComputeOfferingForm from '@/components/offering/ComputeOfferingForm'
+import { ref, reactive } from 'vue'
+import { getAPI, postAPI } from '@/api'
+import AddDiskOffering from '@/views/offering/AddDiskOffering'
+import { isAdmin } from '@/role'
+import { mixinForm } from '@/utils/mixin'
+import ResourceIcon from '@/components/view/ResourceIcon'
+import TooltipLabel from '@/components/widgets/TooltipLabel'
+import DetailsInput from '@/components/widgets/DetailsInput'
+import store from '@/store'
+
+export default {
+  name: 'CreateComputeOffering',
+  mixins: [mixinForm],
+  components: {
+    ComputeOfferingForm,
+    AddDiskOffering,
+    ResourceIcon,
+    TooltipLabel,
+    DetailsInput
+  },
+  props: {
+    resource: {
+      type: Object,
+      required: true
+    }
+  },
+  data () {
+    return {
+      isSystem: false,
+      naturalNumberRule: {
+        type: 'number',
+        validator: this.validateNumber
+      },
+      wholeNumberRule: {
+        type: 'number',
+        validator: async (rule, value) => {
+          if (value && (isNaN(value) || value < 0)) {
+            return Promise.reject(this.$t('message.error.number'))
+          }
+          return Promise.resolve()
+        }
+      },
+      storageType: 'shared',
+      provisioningType: 'thin',
+      cacheMode: 'none',
+      offeringType: 'fixed',
+      isCustomizedDiskIops: false,
+      isPublic: true,
+      domains: [],
+      domainLoading: false,
+      zones: [],
+      zoneLoading: false,
+      selectedDeploymentPlanner: null,
+      storagePolicies: null,
+      storageTags: [],
+      storageTagLoading: false,
+      deploymentPlanners: [],
+      deploymentPlannerLoading: false,
+      plannerModeVisible: false,
+      plannerMode: '',
+      selectedGpuCard: '',
+      showDiskOfferingModal: false,
+      gpuCardLoading: false,
+      gpuCards: [],
+      loading: false,
+      dynamicscalingenabled: true,
+      diskofferingstrictness: false,
+      encryptdisk: false,
+      computeonly: true,
+      diskOfferingLoading: false,
+      diskOfferings: [],
+      selectedDiskOfferingId: '',
+      qosType: '',
+      isDomainAdminAllowedToInformTags: false,
+      isLeaseFeatureEnabled: this.$store.getters.features.instanceleaseenabled,
+      showLeaseOptions: false,
+      expiryActions: ['STOP', 'DESTROY'],
+      defaultLeaseDuration: 90,
+      defaultLeaseExpiryAction: 'STOP',
+      leaseduration: undefined,
+      leaseexpiryaction: undefined,
+      vgpuProfiles: [],
+      vgpuProfileLoading: false,
+      externalDetailsEnabled: false
+    }
+  },
+  beforeCreate () {
+    this.apiParams = this.$getApiParams('cloneServiceOffering')
+  },
+  created () {
+    this.zones = [
+      {
+        id: null,
+        name: this.$t('label.all.zone')
+      }
+    ]
+    if (this.$route.meta.name === 'systemoffering') {
+      this.isSystem = true
+    }
+    this.initForm()
+    this.fetchData()
+    this.isPublic = isAdmin()
+    this.form.ispublic = this.isPublic
+  },
+  methods: {
+    initForm () {
+      this.formRef = ref()
+      this.form = reactive({
+        systemvmtype: 'domainrouter',
+        offeringtype: this.offeringType,
+        ispublic: this.isPublic,
+        dynamicscalingenabled: true,
+        plannermode: this.plannerMode,
+        gpucardid: this.selectedGpuCard,
+        vgpuprofile: '',
+        gpucount: '1',
+        gpudisplay: false,
+        computeonly: this.computeonly,
+        storagetype: this.storageType,
+        provisioningtype: this.provisioningType,
+        cachemode: this.cacheMode,
+        qostype: this.qosType,
+        iscustomizeddiskiops: this.isCustomizedDiskIops,
+        diskofferingid: this.selectedDiskOfferingId,
+        diskofferingstrictness: this.diskofferingstrictness,
+        encryptdisk: this.encryptdisk,
+        leaseduration: this.leaseduration,
+        leaseexpiryaction: this.leaseexpiryaction
+      })
+      this.rules = reactive({
+        name: [{ required: true, message: 
this.$t('message.error.required.input') }],
+        cpunumber: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        cpuspeed: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.wholeNumberRule
+        ],
+        mincpunumber: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        maxcpunumber: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        memory: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        minmemory: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        maxmemory: [
+          { required: true, message: this.$t('message.error.required.input') },
+          this.naturalNumberRule
+        ],
+        networkrate: [this.naturalNumberRule],
+        rootdisksize: [this.naturalNumberRule],
+        diskbytesreadrate: [this.naturalNumberRule],
+        diskbyteswriterate: [this.naturalNumberRule],
+        diskiopsreadrate: [this.naturalNumberRule],
+        diskiopswriterate: [this.naturalNumberRule],
+        diskiopsmin: [this.naturalNumberRule],
+        diskiopsmax: [this.naturalNumberRule],
+        hypervisorsnapshotreserve: [this.naturalNumberRule],
+        domainid: [{ type: 'array', required: true, message: 
this.$t('message.error.select') }],
+        diskofferingid: [{ required: true, message: 
this.$t('message.error.select') }],
+        gpucount: [{
+          type: 'number',
+          validator: async (rule, value) => {
+            if (value && (isNaN(value) || value < 1)) {
+              return 
Promise.reject(this.$t('message.error.number.minimum.one'))
+            }
+            return Promise.resolve()
+          }
+        }],
+        zoneid: [{
+          type: 'array',
+          validator: async (rule, value) => {
+            if (value && value.length > 1 && value.indexOf(0) !== -1) {
+              return Promise.reject(this.$t('message.error.zone.combined'))
+            }
+            return Promise.resolve()
+          }
+        }],
+        leaseduration: [this.naturalNumberRule]
+      })
+    },
+    fetchData () {
+      this.fetchDomainData()
+      this.fetchZoneData()
+      this.fetchGPUCards()
+      if (isAdmin()) {
+        this.fetchStorageTagData()
+        this.fetchDeploymentPlannerData()
+      } else if (this.isDomainAdmin()) {
+        this.checkIfDomainAdminIsAllowedToInformTag()
+        if (this.isDomainAdminAllowedToInformTags) {
+          this.fetchStorageTagData()
+        }
+      }
+      this.fetchDiskOfferings()
+      this.populateFormFromResource()
+    },
+    populateFormFromResource () {
+      if (!this.resource) return
+
+      // Pre-fill form with source offering values
+      const r = this.resource
+      this.form.name = r.name + ' - Clone'
+      this.form.displaytext = r.displaytext
+
+      if (r.iscustomized) {
+        if (r.cpunumber || r.cpuspeed || r.memory) {
+          this.offeringType = 'customconstrained'
+          this.form.offeringtype = 'customconstrained'
+        } else {
+          this.offeringType = 'customunconstrained'
+          this.form.offeringtype = 'customunconstrained'
+        }
+      } else {
+        this.offeringType = 'fixed'
+        this.form.offeringtype = 'fixed'
+      }
+
+      if (r.cpunumber) this.form.cpunumber = r.cpunumber
+      if (r.cpuspeed) this.form.cpuspeed = r.cpuspeed
+      if (r.memory) this.form.memory = r.memory
+
+      if (r.mincpunumber) this.form.mincpunumber = r.mincpunumber
+      if (r.maxcpunumber) this.form.maxcpunumber = r.maxcpunumber
+      if (r.minmemory) this.form.minmemory = r.minmemory
+      if (r.maxmemory) this.form.maxmemory = r.maxmemory
+
+      if (r.hosttags) this.form.hosttags = r.hosttags
+      if (r.networkrate) this.form.networkrate = r.networkrate
+      if (r.offerha !== undefined) this.form.offerha = r.offerha
+      if (r.dynamicscalingenabled !== undefined) {
+        this.form.dynamicscalingenabled = r.dynamicscalingenabled
+        this.dynamicscalingenabled = r.dynamicscalingenabled
+      }
+      if (r.limitcpuuse !== undefined) this.form.limitcpuuse = r.limitcpuuse
+      if (r.isvolatile !== undefined) this.form.isvolatile = r.isvolatile
+
+      if (r.storagetype) {
+        this.storageType = r.storagetype
+        this.form.storagetype = r.storagetype
+      }
+      if (r.provisioningtype) {
+        this.provisioningType = r.provisioningtype
+        this.form.provisioningtype = r.provisioningtype
+      }
+      if (r.cachemode) {
+        this.cacheMode = r.cachemode
+        this.form.cachemode = r.cachemode
+      }
+
+      if (r.diskofferingstrictness !== undefined) {
+        this.form.diskofferingstrictness = r.diskofferingstrictness
+        this.diskofferingstrictness = r.diskofferingstrictness
+      }
+      if (r.encryptroot !== undefined) {
+        this.form.encryptdisk = r.encryptroot
+        this.encryptdisk = r.encryptroot
+      }
+
+      if (r.diskBytesReadRate || r.diskBytesWriteRate || r.diskIopsReadRate || 
r.diskIopsWriteRate) {
+        this.qosType = 'hypervisor'
+        this.form.qostype = 'hypervisor'
+        if (r.diskBytesReadRate) this.form.diskbytesreadrate = 
r.diskBytesReadRate
+        if (r.diskBytesWriteRate) this.form.diskbyteswriterate = 
r.diskBytesWriteRate
+        if (r.diskIopsReadRate) this.form.diskiopsreadrate = r.diskIopsReadRate
+        if (r.diskIopsWriteRate) this.form.diskiopswriterate = 
r.diskIopsWriteRate
+      } else if (r.miniops || r.maxiops) {
+        this.qosType = 'storage'
+        this.form.qostype = 'storage'
+        if (r.miniops) this.form.diskiopsmin = r.miniops
+        if (r.maxiops) this.form.diskiopsmax = r.maxiops
+        if (r.hypervisorsnapshotreserve) this.form.hypervisorsnapshotreserve = 
r.hypervisorsnapshotreserve
+      }
+      if (r.iscustomizediops !== undefined) {
+        this.form.iscustomizeddiskiops = r.iscustomizediops
+        this.isCustomizedDiskIops = r.iscustomizediops
+      }
+
+      if (r.rootdisksize) this.form.rootdisksize = r.rootdisksize
+
+      if (r.tags) {
+        this.form.storagetags = r.tags.split(',')
+      }
+
+      if (r.gpucardid) {
+        this.form.gpucardid = r.gpucardid
+        this.selectedGpuCard = r.gpucardid
+        if (r.gpucardid) {
+          this.fetchVgpuProfiles(r.gpucardid)
+        }
+      }
+      if (r.vgpuprofileid) this.form.vgpuprofile = r.vgpuprofileid
+      if (r.gpucount) this.form.gpucount = r.gpucount
+      if (r.gpudisplay !== undefined) this.form.gpudisplay = r.gpudisplay
+
+      if (r.leaseduration) {
+        this.form.leaseduration = r.leaseduration
+        this.showLeaseOptions = true
+      }
+      if (r.leaseexpiryaction) this.form.leaseexpiryaction = 
r.leaseexpiryaction
+
+      if (r.purgeresources !== undefined) this.form.purgeresources = 
r.purgeresources
+
+      if (r.vspherestoragepolicy) this.form.storagepolicy = 
r.vspherestoragepolicy
+
+      if (r.systemvmtype) this.form.systemvmtype = r.systemvmtype
+
+      if (r.deploymentplanner) {
+        this.form.deploymentplanner = r.deploymentplanner
+        this.handleDeploymentPlannerChange(r.deploymentplanner)
+      }
+
+      if (r.serviceofferingdetails && 
Object.keys(r.serviceofferingdetails).length > 0) {
+        this.externalDetailsEnabled = true
+        this.form.externaldetails = r.serviceofferingdetails
+      }
+    },
+    fetchGPUCards () {
+      this.gpuCardLoading = true
+      getAPI('listGpuCards', {
+      }).then(json => {
+        this.gpuCards = json.listgpucardsresponse.gpucard || []
+        this.gpuCards.unshift({
+          id: '',
+          name: this.$t('label.none')
+        })
+      }).finally(() => {
+        this.gpuCardLoading = false
+      })
+    },
+    fetchDiskOfferings () {
+      this.diskOfferingLoading = true
+      getAPI('listDiskOfferings', {
+        listall: true
+      }).then(json => {
+        this.diskOfferings = json.listdiskofferingsresponse.diskoffering || []
+        if (this.selectedDiskOfferingId === '') {
+          this.selectedDiskOfferingId = this.diskOfferings[0].id || ''
+        }

Review Comment:
   `this.diskOfferings[0].id` will throw if `listDiskOfferings` returns an 
empty list. Guard with a length check (or use optional chaining) before reading 
the first element.



##########
ui/src/components/offering/DiskOfferingForm.vue:
##########
@@ -0,0 +1,507 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+<template>
+  <a-form
+    ref="internalFormRef"
+    :model="form"
+    :rules="rules"
+    @finish="onInternalSubmit"
+    layout="vertical">
+    <a-form-item name="name" ref="name">
+      <template #label>
+        <tooltip-label :title="$t('label.name')" 
:tooltip="apiParams.name.description"/>
+      </template>
+      <a-input
+        v-focus="true"
+        v-model:value="form.name"
+        :placeholder="apiParams.name.description"/>
+    </a-form-item>
+    <a-form-item name="displaytext" ref="displaytext">
+      <template #label>
+        <tooltip-label :title="$t('label.displaytext')" 
:tooltip="apiParams.displaytext.description"/>
+      </template>
+      <a-input
+        v-model:value="form.displaytext"
+        :placeholder="apiParams.displaytext.description"/>
+    </a-form-item>
+    <a-form-item name="storagetype" ref="storagetype">
+      <template #label>
+        <tooltip-label :title="$t('label.storagetype')" 
:tooltip="apiParams.storagetype.description"/>
+      </template>
+      <a-radio-group
+        v-model:value="form.storagetype"
+        buttonStyle="solid">
+        <a-radio-button value="shared">
+          {{ $t('label.shared') }}
+        </a-radio-button>
+        <a-radio-button value="local">
+          {{ $t('label.local') }}
+        </a-radio-button>
+      </a-radio-group>
+    </a-form-item>
+    <a-form-item name="provisioningtype" ref="provisioningtype">
+      <template #label>
+        <tooltip-label :title="$t('label.provisioningtype')" 
:tooltip="apiParams.provisioningtype.description"/>
+      </template>
+      <a-radio-group
+        v-model:value="form.provisioningtype"
+        buttonStyle="solid">
+        <a-radio-button value="thin">
+          {{ $t('label.provisioningtype.thin') }}
+        </a-radio-button>
+        <a-radio-button value="sparse">
+          {{ $t('label.provisioningtype.sparse') }}
+        </a-radio-button>
+        <a-radio-button value="fat">
+          {{ $t('label.provisioningtype.fat') }}
+        </a-radio-button>
+      </a-radio-group>
+    </a-form-item>
+    <a-form-item name="encryptdisk" ref="encryptdisk">
+      <template #label>
+        <tooltip-label :title="$t('label.encrypt')" 
:tooltip="apiParams.encrypt.description" />
+      </template>
+      <a-switch v-model:checked="form.encryptdisk" :checked="encryptdisk" 
@change="val => { encryptdisk = val }" />
+    </a-form-item>
+    <a-form-item name="disksizestrictness" ref="disksizestrictness">
+      <template #label>
+        <tooltip-label :title="$t('label.disksizestrictness')" 
:tooltip="apiParams.disksizestrictness.description" />
+      </template>
+      <a-switch v-model:checked="form.disksizestrictness" 
:checked="disksizestrictness" @change="val => { disksizestrictness = val }" />

Review Comment:
   The switch uses both `v-model:checked` and an explicit `:checked` bound to a 
different data property (`encryptdisk`). This can desynchronize the UI from 
`form.encryptdisk` (especially when `initialValues` sets a non-default). Prefer 
binding only via `v-model:checked` and remove the separate `:checked`/`@change` 
state mirror.
   ```suggestion
         <a-switch v-model:checked="form.encryptdisk" />
       </a-form-item>
       <a-form-item name="disksizestrictness" ref="disksizestrictness">
         <template #label>
           <tooltip-label :title="$t('label.disksizestrictness')" 
:tooltip="apiParams.disksizestrictness.description" />
         </template>
         <a-switch v-model:checked="form.disksizestrictness" />
   ```



##########
ui/src/components/offering/DiskOfferingForm.vue:
##########
@@ -0,0 +1,507 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+<template>
+  <a-form
+    ref="internalFormRef"
+    :model="form"
+    :rules="rules"
+    @finish="onInternalSubmit"
+    layout="vertical">
+    <a-form-item name="name" ref="name">
+      <template #label>
+        <tooltip-label :title="$t('label.name')" 
:tooltip="apiParams.name.description"/>
+      </template>
+      <a-input
+        v-focus="true"
+        v-model:value="form.name"
+        :placeholder="apiParams.name.description"/>
+    </a-form-item>
+    <a-form-item name="displaytext" ref="displaytext">
+      <template #label>
+        <tooltip-label :title="$t('label.displaytext')" 
:tooltip="apiParams.displaytext.description"/>
+      </template>
+      <a-input
+        v-model:value="form.displaytext"
+        :placeholder="apiParams.displaytext.description"/>
+    </a-form-item>
+    <a-form-item name="storagetype" ref="storagetype">
+      <template #label>
+        <tooltip-label :title="$t('label.storagetype')" 
:tooltip="apiParams.storagetype.description"/>
+      </template>
+      <a-radio-group
+        v-model:value="form.storagetype"
+        buttonStyle="solid">
+        <a-radio-button value="shared">
+          {{ $t('label.shared') }}
+        </a-radio-button>
+        <a-radio-button value="local">
+          {{ $t('label.local') }}
+        </a-radio-button>
+      </a-radio-group>
+    </a-form-item>
+    <a-form-item name="provisioningtype" ref="provisioningtype">
+      <template #label>
+        <tooltip-label :title="$t('label.provisioningtype')" 
:tooltip="apiParams.provisioningtype.description"/>
+      </template>
+      <a-radio-group
+        v-model:value="form.provisioningtype"
+        buttonStyle="solid">
+        <a-radio-button value="thin">
+          {{ $t('label.provisioningtype.thin') }}
+        </a-radio-button>
+        <a-radio-button value="sparse">
+          {{ $t('label.provisioningtype.sparse') }}
+        </a-radio-button>
+        <a-radio-button value="fat">
+          {{ $t('label.provisioningtype.fat') }}
+        </a-radio-button>
+      </a-radio-group>
+    </a-form-item>
+    <a-form-item name="encryptdisk" ref="encryptdisk">
+      <template #label>
+        <tooltip-label :title="$t('label.encrypt')" 
:tooltip="apiParams.encrypt.description" />
+      </template>
+      <a-switch v-model:checked="form.encryptdisk" :checked="encryptdisk" 
@change="val => { encryptdisk = val }" />
+    </a-form-item>
+    <a-form-item name="disksizestrictness" ref="disksizestrictness">
+      <template #label>
+        <tooltip-label :title="$t('label.disksizestrictness')" 
:tooltip="apiParams.disksizestrictness.description" />
+      </template>
+      <a-switch v-model:checked="form.disksizestrictness" 
:checked="disksizestrictness" @change="val => { disksizestrictness = val }" />
+    </a-form-item>
+    <a-form-item name="customdisksize" ref="customdisksize">
+      <template #label>
+        <tooltip-label :title="$t('label.customdisksize')" 
:tooltip="apiParams.customized.description"/>
+      </template>
+      <a-switch v-model:checked="form.customdisksize" />
+    </a-form-item>
+    <a-form-item v-if="!form.customdisksize" name="disksize" ref="disksize">
+      <template #label>
+        <tooltip-label :title="$t('label.disksize')" 
:tooltip="apiParams.disksize.description"/>
+      </template>
+      <a-input
+        v-model:value="form.disksize"
+        :placeholder="apiParams.disksize.description"/>
+    </a-form-item>
+    <a-form-item name="qostype" ref="qostype" :label="$t('label.qostype')">
+      <a-radio-group
+        v-model:value="form.qostype"
+        buttonStyle="solid">
+        <a-radio-button value="">
+          {{ $t('label.none') }}
+        </a-radio-button>
+        <a-radio-button value="hypervisor">
+          {{ $t('label.hypervisor') }}
+        </a-radio-button>
+        <a-radio-button value="storage">
+          {{ $t('label.storage') }}
+        </a-radio-button>
+      </a-radio-group>
+    </a-form-item>
+    <a-form-item v-if="form.qostype === 'hypervisor'" name="diskbytesreadrate" 
ref="diskbytesreadrate">
+      <template #label>
+        <tooltip-label :title="$t('label.diskbytesreadrate')" 
:tooltip="apiParams.bytesreadrate.description"/>
+      </template>
+      <a-input
+        v-model:value="form.diskbytesreadrate"
+        :placeholder="apiParams.bytesreadrate.description"/>
+    </a-form-item>
+    <a-form-item v-if="form.qostype === 'hypervisor'" 
name="diskbytesreadratemax" ref="diskbytesreadratemax">
+      <template #label>
+        <tooltip-label :title="$t('label.diskbytesreadratemax')" 
:tooltip="apiParams.bytesreadratemax.description"/>
+      </template>
+      <a-input
+        v-model:value="form.diskbytesreadratemax"
+        :placeholder="apiParams.bytesreadratemax.description"/>
+    </a-form-item>
+    <a-form-item v-if="form.qostype === 'hypervisor'" 
name="diskbyteswriterate" ref="diskbyteswriterate">
+      <template #label>
+        <tooltip-label :title="$t('label.diskbyteswriterate')" 
:tooltip="apiParams.byteswriterate.description"/>
+      </template>
+      <a-input
+        v-model:value="form.diskbyteswriterate"
+        :placeholder="apiParams.byteswriterate.description"/>
+    </a-form-item>
+    <a-form-item v-if="form.qostype === 'hypervisor'" 
name="diskbyteswriteratemax" ref="diskbyteswriteratemax">
+      <template #label>
+        <tooltip-label :title="$t('label.diskbyteswriteratemax')" 
:tooltip="apiParams.byteswriteratemax.description"/>
+      </template>
+      <a-input
+        v-model:value="form.diskbyteswriteratemax"
+        :placeholder="apiParams.byteswriteratemax.description"/>
+    </a-form-item>
+    <a-form-item v-if="form.qostype === 'hypervisor'" name="diskiopsreadrate" 
ref="diskiopsreadrate">
+      <template #label>
+        <tooltip-label :title="$t('label.diskiopsreadrate')" 
:tooltip="apiParams.iopsreadrate.description"/>
+      </template>
+      <a-input
+        v-model:value="form.diskiopsreadrate"
+        :placeholder="apiParams.iopsreadrate.description"/>
+    </a-form-item>
+    <a-form-item v-if="form.qostype === 'hypervisor'" name="diskiopswriterate" 
ref="diskiopswriterate">
+      <template #label>
+        <tooltip-label :title="$t('label.diskiopswriterate')" 
:tooltip="apiParams.iopswriterate.description"/>
+      </template>
+      <a-input
+        v-model:value="form.diskiopswriterate"
+        :placeholder="apiParams.iopswriterate.description"/>
+    </a-form-item>
+    <a-form-item v-if="form.qostype === 'storage'" name="iscustomizeddiskiops" 
ref="iscustomizeddiskiops">
+      <template #label>
+        <tooltip-label :title="$t('label.iscustomizeddiskiops')" 
:tooltip="apiParams.customizediops.description"/>
+      </template>
+      <a-switch v-model:checked="form.iscustomizeddiskiops" />
+    </a-form-item>
+    <a-form-item v-if="form.qostype === 'storage' && 
!form.iscustomizeddiskiops" name="diskiopsmin" ref="diskiopsmin">
+      <template #label>
+        <tooltip-label :title="$t('label.diskiopsmin')" 
:tooltip="apiParams.miniops.description"/>
+      </template>
+      <a-input
+        v-model:value="form.diskiopsmin"
+        :placeholder="apiParams.miniops.description"/>
+    </a-form-item>
+    <a-form-item v-if="form.qostype === 'storage' && 
!form.iscustomizeddiskiops" name="diskiopsmax" ref="diskiopsmax">
+      <template #label>
+        <tooltip-label :title="$t('label.diskiopsmax')" 
:tooltip="apiParams.maxiops.description"/>
+      </template>
+      <a-input
+        v-model:value="form.diskiopsmax"
+        :placeholder="apiParams.maxiops.description"/>
+    </a-form-item>
+    <a-form-item v-if="form.qostype === 'storage'" 
name="hypervisorsnapshotreserve" ref="hypervisorsnapshotreserve">
+      <template #label>
+        <tooltip-label :title="$t('label.hypervisorsnapshotreserve')" 
:tooltip="apiParams.hypervisorsnapshotreserve.description"/>
+      </template>
+      <a-input
+        v-model:value="form.hypervisorsnapshotreserve"
+        :placeholder="apiParams.hypervisorsnapshotreserve.description"/>
+    </a-form-item>
+    <a-form-item name="writecachetype" ref="writecachetype">
+      <template #label>
+        <tooltip-label :title="$t('label.writecachetype')" 
:tooltip="apiParams.cachemode.description"/>
+      </template>
+      <a-radio-group
+        v-model:value="form.writecachetype"
+        buttonStyle="solid"
+        @change="selected => { 
handleWriteCacheTypeChange(selected.target.value) }">
+        <a-radio-button value="none">
+          {{ $t('label.nodiskcache') }}
+        </a-radio-button>
+        <a-radio-button value="writeback">
+          {{ $t('label.writeback') }}
+        </a-radio-button>
+        <a-radio-button value="writethrough">
+          {{ $t('label.writethrough') }}
+        </a-radio-button>
+        <a-radio-button value="hypervisor_default">
+          {{ $t('label.hypervisor.default') }}
+        </a-radio-button>
+      </a-radio-group>
+    </a-form-item>
+    <a-form-item v-if="isAdmin() || isDomainAdminAllowedToInformTags" 
name="tags" ref="tags">
+      <template #label>
+        <tooltip-label :title="$t('label.storagetags')" 
:tooltip="apiParams.tags.description"/>
+      </template>
+      <a-select
+        :getPopupContainer="(trigger) => trigger.parentNode"
+        mode="tags"
+        v-model:value="form.tags"
+        showSearch
+        optionFilterProp="value"
+        :filterOption="(input, option) => {
+          return option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0
+        }"
+        :loading="storageTagLoading"
+        :placeholder="apiParams.tags.description"
+        v-if="isAdmin() || isDomainAdminAllowedToInformTags">
+        <a-select-option v-for="(opt) in storageTags" :key="opt">
+          {{ opt }}
+        </a-select-option>
+      </a-select>
+    </a-form-item>
+    <a-form-item :label="$t('label.ispublic')" v-show="isAdmin()" 
name="ispublic" ref="ispublic">
+      <a-switch v-model:checked="form.ispublic" @change="val => { isPublic = 
val }" />
+    </a-form-item>
+    <a-form-item v-if="!isPublic" name="domainid" ref="domainid">
+      <template #label>
+        <tooltip-label :title="$t('label.domainid')" 
:tooltip="apiParams.domainid.description"/>
+      </template>
+      <a-select
+        mode="multiple"
+        :getPopupContainer="(trigger) => trigger.parentNode"
+        v-model:value="form.domainid"
+        showSearch
+        optionFilterProp="label"
+        :filterOption="(input, option) => {
+          return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
+        }"
+        :loading="domainLoading"
+        :placeholder="apiParams.domainid.description">
+        <a-select-option v-for="(opt, optIndex) in domains" :key="optIndex" 
:label="opt.path || opt.name || opt.description">
+          <span>
+            <resource-icon v-if="opt && opt.icon" 
:image="opt.icon.base64image" size="1x" style="margin-right: 5px"/>
+            <block-outlined v-else style="margin-right: 5px" />
+            {{ opt.path || opt.name || opt.description }}
+          </span>
+        </a-select-option>
+      </a-select>
+    </a-form-item>
+    <a-form-item name="zoneid" ref="zoneid">
+      <template #label>
+        <tooltip-label :title="$t('label.zoneid')" 
:tooltip="apiParams.zoneid.description"/>
+      </template>
+      <a-select
+        id="zone-selection"
+        mode="multiple"
+        :getPopupContainer="(trigger) => trigger.parentNode"
+        v-model:value="form.zoneid"
+        showSearch
+        optionFilterProp="label"
+        :filterOption="(input, option) => {
+          return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
+        }"
+        @select="val => fetchvSphereStoragePolicies(val)"
+        :loading="zoneLoading"
+        :placeholder="apiParams.zoneid.description">
+        <a-select-option v-for="(opt, optIndex) in zones" :key="optIndex" 
:label="opt.name || opt.description">
+          <span>
+            <resource-icon v-if="opt.icon" :image="opt.icon.base64image" 
size="1x" style="margin-right: 5px"/>
+            <global-outlined v-else style="margin-right: 5px"/>
+            {{ opt.name || opt.description }}
+          </span>
+        </a-select-option>
+      </a-select>
+    </a-form-item>
+    <a-form-item v-if="'listVsphereStoragePolicies' in $store.getters.apis && 
storagePolicies !== null" name="storagepolicy" ref="storagepolicy">
+      <template #label>
+        <tooltip-label :title="$t('label.vmware.storage.policy')" 
:tooltip="apiParams.storagepolicy.description"/>
+      </template>
+      <a-select
+        :getPopupContainer="(trigger) => trigger.parentNode"
+        v-model:value="form.storagepolicy"
+        :placeholder="apiParams.storagepolicy.description"
+        showSearch
+        optionFilterProp="label"
+        :filterOption="(input, option) => {
+          return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
+        }">
+        <a-select-option v-for="policy in storagePolicies" :key="policy.id" 
:label="policy.name || policy.id || ''">
+          {{ policy.name || policy.id }}
+        </a-select-option>
+      </a-select>
+    </a-form-item>
+    <slot name="form-actions"></slot>
+  </a-form>
+</template>
+
+<script>
+import { reactive, toRaw } from 'vue'
+import { getAPI } from '@/api'
+import { isAdmin } from '@/role'
+import { mixinForm } from '@/utils/mixin'
+import ResourceIcon from '@/components/view/ResourceIcon'
+import TooltipLabel from '@/components/widgets/TooltipLabel'
+import store from '@/store'
+import { BlockOutlined, GlobalOutlined } from '@ant-design/icons-vue'
+
+export default {
+  name: 'DiskOfferingForm',
+  mixins: [mixinForm],
+  components: {
+    ResourceIcon,
+    TooltipLabel,
+    BlockOutlined,
+    GlobalOutlined
+  },
+  props: {
+    initialValues: {
+      type: Object,
+      default: () => ({})
+    },
+    apiParams: {
+      type: Object,
+      default: () => ({})
+    },
+    isAdmin: {
+      type: Function,
+      default: () => false
+    }
+  },
+  data () {
+    return {
+      internalFormRef: null,
+      form: reactive(Object.assign({
+        storagetype: 'shared',
+        provisioningtype: 'thin',
+        customdisksize: true,
+        writecachetype: 'none',
+        qostype: '',
+        ispublic: true,
+        disksizestrictness: false,
+        encryptdisk: false
+      }, this.initialValues || {})),
+      rules: reactive({}),
+      storageTags: [],
+      storagePolicies: null,
+      storageTagLoading: false,
+      isPublic: true,
+      domains: [],
+      domainLoading: false,
+      zones: [],
+      zoneLoading: false,
+      disksizestrictness: false,
+      encryptdisk: false,
+      isDomainAdminAllowedToInformTags: false
+    }
+  },
+  created () {
+    this.zones = [{ id: null, name: this.$t('label.all.zone') }]
+    this.initForm()
+    this.fetchData()
+    this.isPublic = isAdmin()
+    this.form.ispublic = this.isPublic
+  },
+  methods: {
+    initForm () {
+      this.formRef = this.$refs.internalFormRef
+      this.rules = reactive({
+        name: [{ required: true, message: 
this.$t('message.error.required.input') }],
+        disksize: [
+          { required: true, message: this.$t('message.error.required.input') },
+          { type: 'number', validator: this.validateNumber }
+        ],
+        diskbytesreadrate: [{ type: 'number', validator: this.validateNumber 
}],
+        diskbytesreadratemax: [{ type: 'number', validator: 
this.validateNumber }],
+        diskbyteswriterate: [{ type: 'number', validator: this.validateNumber 
}],
+        diskbyteswriteratemax: [{ type: 'number', validator: 
this.validateNumber }],
+        diskiopsreadrate: [{ type: 'number', validator: this.validateNumber }],
+        diskiopswriterate: [{ type: 'number', validator: this.validateNumber 
}],
+        diskiopsmin: [{ type: 'number', validator: this.validateNumber }],
+        diskiopsmax: [{ type: 'number', validator: this.validateNumber }],
+        hypervisorsnapshotreserve: [{ type: 'number', validator: 
this.validateNumber }],
+        domainid: [{ type: 'array', required: true, message: 
this.$t('message.error.select') }],
+        zoneid: [{
+          type: 'array',
+          validator: async (rule, value) => {
+            if (value && value.length > 1 && value.indexOf(0) !== -1) {
+              return Promise.reject(this.$t('message.error.zone.combined'))
+            }
+            return Promise.resolve()
+          }
+        }]
+      })
+    },
+    fetchData () {
+      this.fetchDomainData()
+      this.fetchZoneData()
+      if (isAdmin()) {
+        this.fetchStorageTagData()
+      }
+      if (this.isDomainAdmin()) {
+        this.checkIfDomainAdminIsAllowedToInformTag()
+        if (this.isDomainAdminAllowedToInformTags) {
+          this.fetchStorageTagData()
+        }
+      }
+    },
+    handleWriteCacheTypeChange (val) {
+      this.form.writeCacheType = val

Review Comment:
   `handleWriteCacheTypeChange` writes to `form.writeCacheType`, but the form 
field is `writecachetype` (lowercase). As written, this handler doesn't update 
the value that gets validated/submitted. Either remove the handler (since 
`v-model` already updates `form.writecachetype`) or update the correct property 
name.
   ```suggestion
         this.form.writecachetype = val
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to