This is an automated email from the ASF dual-hosted git repository.

nvazquez pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new a7be64af7ad Resize Volume form does not show current size and Custom 
IOPS (#14262)
a7be64af7ad is described below

commit a7be64af7ad8fdefe1948efa175e87d4852d3453
Author: Surya Gupta <[email protected]>
AuthorDate: Wed Nov 11 03:21:25 2026 +0530

    Resize Volume form does not show current size and Custom IOPS (#14262)
    
    * [CCS-5] Volume Resize Fix for IOPS and Size (#106)
    
    ### Description
    
    On Resize Volume, the form did not show the volume’s current size and
    IOPS. It also used the first disk offering in the list, not the offering
    already attached to the volume.
    
    So the dialog often opened empty or with the wrong IOPS fields, and a
    resize could send the wrong values.
    
    Root cause
    Three UI issues in fetchData():
    
    Size unit — the API stores size in bytes. The form label is GB. Size was
    never copied onto the form, so the GB field stayed empty.
    Wrong offering — the code always used this.offerings[0] (first offering
    returned by listDiskOfferings), not this.resource.diskofferingid (the
    volume’s offering). Custom size / custom IOPS flags could be wrong.
    IOPS not filled — even when min/max IOPS existed on the volume, they
    were not copied into form.miniops / form.maxiops.
    Steps to reproduce
    Create a volume with a custom IOPS disk offering (for example min 2000,
    max 5000) and a known size (for example 20 GB).
    Open Storage → Volumes → Resize.
    Check the Size (GB) field and the Min/Max IOPS fields.
    <!--- Describe your changes in DETAIL - And how has behaviour
    functionally changed. -->
    
    <!-- For new features, provide link to FS, dev ML discussion etc. -->
    <!-- In case of bug fix, the expected and actual behaviours, steps to
    reproduce. -->
    
    <!-- When "Fixes: #<id>" is specified, the issue/PR will automatically
    be closed when this PR gets merged -->
    <!-- For addressing multiple issues/PRs, use multiple "Fixes: #<id>" -->
    <!-- Fixes: # -->
    
    <!---
    
*******************************************************************************
    -->
    <!--- NOTE: AUTOMATION USES THE DESCRIPTIONS TO SET LABELS AND PRODUCE
    DOCUMENTATION. -->
    <!--- PLEASE PUT AN 'X' in only **ONE** box -->
    <!---
    
*******************************************************************************
    -->
    
    ### Types of changes
    
    - [ ] Breaking change (fix or feature that would cause existing
    functionality to change)
    - [ ] New feature (non-breaking change which adds functionality)
    - [ ] Bug fix (non-breaking change which fixes an issue)
    - [ ] Enhancement (improves an existing feature and functionality)
    - [ ] Cleanup (Code refactoring and cleanup, that may add test cases)
    - [ ] Build/CI
    - [ ] Test (unit or integration test code)
    
    ### Feature/Enhancement Scale or Bug Severity
    
    #### Feature/Enhancement Scale
    
    - [ ] Major
    - [ ] Minor
    
    #### Bug Severity
    
    - [ ] BLOCKER
    - [ ] Critical
    - [ ] Major
    - [ ] Minor
    - [ ] Trivial
    
    ### Screenshots (if appropriate):
    Before Fix:
    <img width="533" height="353" alt="image"
    
src="https://github.com/user-attachments/assets/ccfc698e-501c-442e-a7b2-9582dc2a53ba";
    />
    
    After Fix:
    <img width="528" height="472" alt="image"
    
src="https://github.com/user-attachments/assets/5125bb21-9b9b-4e48-a9bc-8d5eaef07118";
    />
    
    
    ### How Has This Been Tested?
    
    Tested with both Fix and Custom IOPS:
    1- Resize Shows IOPS value for custom IOPS not for fix, along with size
    2- Exact 20 GB volume → shows as 20
    float 20.1 -> shows on UI as 20
    
    #### How did you try to break this feature and the system with this
    change?
    
    <!-- see how your change affects other areas of the code, etc. -->
    
    <!-- Please read the
    
[CONTRIBUTING](https://github.com/apache/cloudstack/blob/main/CONTRIBUTING.md)
    document -->
    
    ---------
    
    Co-authored-by: Gupta, Surya <[email protected]>
    
    * Resize Upstream PR Review Comment (#124)
    
    Root Volume created with custom IOPS and not overridden by the custom
    disk offering should also show the min and max IOPS on form
    
    <!---
    
*******************************************************************************
    -->
    <!--- NOTE: AUTOMATION USES THE DESCRIPTIONS TO SET LABELS AND PRODUCE
    DOCUMENTATION. -->
    <!--- PLEASE PUT AN 'X' in only **ONE** box -->
    <!---
    
*******************************************************************************
    -->
    
    - [ ] Breaking change (fix or feature that would cause existing
    functionality to change)
    - [ ] New feature (non-breaking change which adds functionality)
    - [x] Bug fix (non-breaking change which fixes an issue)
    - [ ] Enhancement (improves an existing feature and functionality)
    - [ ] Cleanup (Code refactoring and cleanup, that may add test cases)
    - [ ] Build/CI
    - [ ] Test (unit or integration test code)
    
    - [x] Major
    - [ ] Minor
    
    - [ ] BLOCKER
    - [ ] Critical
    - [x] Major
    - [ ] Minor
    - [ ] Trivial
    
    <img width="655" height="529" alt="image"
    
src="https://github.com/user-attachments/assets/335b1c6c-e979-4976-a7a3-1c1b52f535e7";
    />
    
    <img width="526" height="407" alt="image"
    
src="https://github.com/user-attachments/assets/76a5e0e5-ec23-4b0f-96ea-ee43c81cfa44";
    />
    
    <img width="599" height="460" alt="image"
    
src="https://github.com/user-attachments/assets/923c44a5-b53f-452f-944c-73ef3c75d804";
    />
    
    1- Created a Fix IOPS Offering of compute/disk and use to create
    VM/Volume, resize should not show min max IOPS - success
    2- Created a Custom IOPS Offering of compute/disk and use to create
    VM/Volume, resize should show min max IOPS - success
    
    change?
    
    <!-- see how your change affects other areas of the code, etc. -->
    
    <!-- Please read the
    
[CONTRIBUTING](https://github.com/apache/cloudstack/blob/main/CONTRIBUTING.md)
    document -->
    
    ---------
    
    Co-authored-by: Gupta, Surya <[email protected]>
    
    * [CSTACKEX-368] Resize Form Size issue fix
    
    ---------
    
    Co-authored-by: Gupta, Surya <[email protected]>
---
 .../cloudstack/api/response/VolumeResponse.java    |  16 ++
 .../com/cloud/api/query/dao/VolumeJoinDaoImpl.java |   6 +
 .../cloud/api/query/dao/VolumeJoinDaoImplTest.java | 173 ++++++++++++++++++++-
 ui/src/views/storage/ResizeVolume.vue              |  31 ++--
 4 files changed, 208 insertions(+), 18 deletions(-)

diff --git 
a/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java 
b/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java
index 031d9315cd0..8cde09af121 100644
--- a/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java
+++ b/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java
@@ -117,6 +117,10 @@ public class VolumeResponse extends 
BaseResponseWithTagInformation implements Co
     @Param(description = "Max IOPS of the disk volume")
     private Long maxIops;
 
+    @SerializedName("iscustomizediops")
+    @Param(description = "True if the volume's disk offering uses custom IOPS, 
false otherwise")
+    private Boolean customizedIops;
+
     @SerializedName(ApiConstants.CREATED)
     @Param(description = "The date the disk volume was created")
     private Date created;
@@ -410,6 +414,10 @@ public class VolumeResponse extends 
BaseResponseWithTagInformation implements Co
         this.maxIops = maxIops;
     }
 
+    public void setCustomizedIops(Boolean customizedIops) {
+        this.customizedIops = customizedIops;
+    }
+
     public void setCreated(Date created) {
         this.created = created;
     }
@@ -712,6 +720,14 @@ public class VolumeResponse extends 
BaseResponseWithTagInformation implements Co
         return maxIops;
     }
 
+    public Boolean isCustomizedIops() {
+        return customizedIops;
+    }
+
+    public Boolean getCustomizedIops() {
+        return customizedIops;
+    }
+
     public Date getCreated() {
         return created;
     }
diff --git 
a/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java 
b/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java
index 4a462a61dae..675738cbedc 100644
--- a/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java
+++ b/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java
@@ -227,6 +227,12 @@ public class VolumeJoinDaoImpl extends 
GenericDaoBaseWithTagInformation<VolumeJo
                 volResponse.setDiskOfferingName(volume.getDiskOfferingName());
                 
volResponse.setDiskOfferingDisplayText(volume.getDiskOfferingDisplayText());
             }
+            DiskOffering diskOffering = computeOnlyDiskOffering != null
+                    ? computeOnlyDiskOffering
+                    : 
ApiDBUtils.findDiskOfferingById(volume.getDiskOfferingId());
+            if (diskOffering != null) {
+                volResponse.setCustomizedIops(diskOffering.isCustomizedIops());
+            }
 
             if (view == ResponseView.Full) {
                 volResponse.setStorageType(volume.isUseLocalStorage() ? 
ServiceOffering.StorageType.local.toString() : 
ServiceOffering.StorageType.shared.toString());
diff --git 
a/server/src/test/java/com/cloud/api/query/dao/VolumeJoinDaoImplTest.java 
b/server/src/test/java/com/cloud/api/query/dao/VolumeJoinDaoImplTest.java
index e4e0b69de7d..a6bf851798d 100755
--- a/server/src/test/java/com/cloud/api/query/dao/VolumeJoinDaoImplTest.java
+++ b/server/src/test/java/com/cloud/api/query/dao/VolumeJoinDaoImplTest.java
@@ -16,32 +16,70 @@
 // under the License.
 package com.cloud.api.query.dao;
 
-import com.cloud.api.query.vo.VolumeJoinVO;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import java.util.function.Consumer;
+
+import org.apache.cloudstack.annotation.dao.AnnotationDao;
+import org.apache.cloudstack.api.ResponseObject.ResponseView;
 import org.apache.cloudstack.api.response.VolumeResponse;
 import org.junit.Assert;
+import org.apache.cloudstack.context.CallContext;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.InjectMocks;
 import org.mockito.Mockito;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
 import org.mockito.junit.MockitoJUnitRunner;
 
+import com.cloud.api.ApiDBUtils;
+import com.cloud.api.ApiResponseHelper;
+import com.cloud.api.query.vo.VolumeJoinVO;
+import com.cloud.service.ServiceOfferingVO;
+import com.cloud.storage.DiskOfferingVO;
+import com.cloud.storage.Volume;
+import com.cloud.user.Account;
+import com.cloud.user.AccountManager;
+
 @RunWith(MockitoJUnitRunner.class)
 public class VolumeJoinDaoImplTest extends 
GenericDaoBaseWithTagInformationBaseTest<VolumeJoinVO, VolumeResponse> {
 
+    private static final long DISK_OFFERING_ID = 10L;
+
     @InjectMocks
     private VolumeJoinDaoImpl _volumeJoinDaoImpl;
 
+    @Mock
+    private AnnotationDao annotationDao;
+
+    @Mock
+    private AccountManager _accountMgr;
+
     private VolumeJoinVO volume = new VolumeJoinVO();
     private VolumeResponse volumeResponse = new VolumeResponse();
 
     @Before
     public void setup() {
-        prepareSetup();
+        lenient().when(annotationDao.hasAnnotations(anyString(), anyString(), 
anyBoolean())).thenReturn(false);
+        lenient().when(_accountMgr.isRootAdmin(eq(1L))).thenReturn(true);
     }
 
     @Test
     public void testUpdateVolumeTagInfo(){
+        prepareSetup();
         testUpdateTagInformation(_volumeJoinDaoImpl, volume, volumeResponse);
     }
 
@@ -62,4 +100,135 @@ public class VolumeJoinDaoImplTest extends 
GenericDaoBaseWithTagInformationBaseT
         Assert.assertEquals(Long.valueOf(400L), response.getIopsWriteRate());
     }
 
+    @Test
+    public void 
testNewVolumeResponseSetsCustomizedIopsForComputeOfferingRoot() {
+        VolumeJoinVO volumeJoin = mockVolumeWithDiskOffering();
+        DiskOfferingVO computeOnlyOffering = mock(DiskOfferingVO.class);
+        when(computeOnlyOffering.isCustomizedIops()).thenReturn(true);
+        ServiceOfferingVO serviceOffering = mock(ServiceOfferingVO.class);
+        when(serviceOffering.getId()).thenReturn(5L);
+        when(serviceOffering.getName()).thenReturn("FullCustom_Compute");
+        
when(serviceOffering.getDisplayText()).thenReturn("FullCustom_Compute");
+
+        VolumeResponse response = newVolumeResponse(volumeJoin, apiDBUtils -> {
+            apiDBUtils.when(() -> 
ApiDBUtils.findComputeOnlyDiskOfferingById(DISK_OFFERING_ID))
+                    .thenReturn(computeOnlyOffering);
+            apiDBUtils.when(() -> 
ApiDBUtils.findServiceOfferingByComputeOnlyDiskOffering(DISK_OFFERING_ID, 
false))
+                    .thenReturn(serviceOffering);
+        });
+
+        assertTrue(response.isCustomizedIops());
+        assertEquals("5", response.getServiceOfferingId());
+        assertEquals("FullCustom_Compute", response.getServiceOfferingName());
+        assertNull(response.getDiskOfferingId());
+    }
+
+    @Test
+    public void testNewVolumeResponseSetsCustomizedIopsForDiskOffering() {
+        VolumeJoinVO volumeJoin = mockVolumeWithDiskOffering();
+        
when(volumeJoin.getDiskOfferingUuid()).thenReturn("disk-offering-uuid");
+        when(volumeJoin.getDiskOfferingName()).thenReturn("NFS_Disk_Custom75");
+        
when(volumeJoin.getDiskOfferingDisplayText()).thenReturn("NFS_Disk_Custom75");
+        DiskOfferingVO diskOffering = mock(DiskOfferingVO.class);
+        when(diskOffering.isCustomizedIops()).thenReturn(true);
+
+        VolumeResponse response = newVolumeResponse(volumeJoin, apiDBUtils -> {
+            apiDBUtils.when(() -> 
ApiDBUtils.findComputeOnlyDiskOfferingById(DISK_OFFERING_ID)).thenReturn(null);
+            apiDBUtils.when(() -> 
ApiDBUtils.findDiskOfferingById(DISK_OFFERING_ID)).thenReturn(diskOffering);
+        });
+
+        assertTrue(response.isCustomizedIops());
+        assertEquals("disk-offering-uuid", response.getDiskOfferingId());
+        assertNull(response.getServiceOfferingId());
+    }
+
+    @Test
+    public void 
testNewVolumeResponseSetsCustomizedIopsFalseWhenOfferingIsFixed() {
+        VolumeJoinVO volumeJoin = mockVolumeWithDiskOffering();
+        DiskOfferingVO diskOffering = mock(DiskOfferingVO.class);
+        when(diskOffering.isCustomizedIops()).thenReturn(false);
+
+        VolumeResponse response = newVolumeResponse(volumeJoin, apiDBUtils -> {
+            apiDBUtils.when(() -> 
ApiDBUtils.findComputeOnlyDiskOfferingById(DISK_OFFERING_ID)).thenReturn(null);
+            apiDBUtils.when(() -> 
ApiDBUtils.findDiskOfferingById(DISK_OFFERING_ID)).thenReturn(diskOffering);
+        });
+
+        assertFalse(response.isCustomizedIops());
+    }
+
+    @Test
+    public void 
testNewVolumeResponseSetsCustomizedIopsFalseForFixedComputeOffering() {
+        VolumeJoinVO volumeJoin = mockVolumeWithDiskOffering();
+        DiskOfferingVO computeOnlyOffering = mock(DiskOfferingVO.class);
+        when(computeOnlyOffering.isCustomizedIops()).thenReturn(false);
+        ServiceOfferingVO serviceOffering = mock(ServiceOfferingVO.class);
+        when(serviceOffering.getId()).thenReturn(5L);
+        when(serviceOffering.getName()).thenReturn("Fixed_Compute");
+        when(serviceOffering.getDisplayText()).thenReturn("Fixed_Compute");
+
+        VolumeResponse response = newVolumeResponse(volumeJoin, apiDBUtils -> {
+            apiDBUtils.when(() -> 
ApiDBUtils.findComputeOnlyDiskOfferingById(DISK_OFFERING_ID))
+                    .thenReturn(computeOnlyOffering);
+            apiDBUtils.when(() -> 
ApiDBUtils.findServiceOfferingByComputeOnlyDiskOffering(DISK_OFFERING_ID, 
false))
+                    .thenReturn(serviceOffering);
+        });
+
+        assertFalse(response.isCustomizedIops());
+        assertEquals("5", response.getServiceOfferingId());
+        assertEquals("Fixed_Compute", response.getServiceOfferingName());
+        assertNull(response.getDiskOfferingId());
+    }
+
+    @Test
+    public void 
testNewVolumeResponseLeavesCustomizedIopsNullWhenOfferingValueIsNull() {
+        VolumeJoinVO volumeJoin = mockVolumeWithDiskOffering();
+        
when(volumeJoin.getDiskOfferingUuid()).thenReturn("disk-offering-uuid");
+        DiskOfferingVO diskOffering = mock(DiskOfferingVO.class);
+        when(diskOffering.isCustomizedIops()).thenReturn(null);
+
+        VolumeResponse response = newVolumeResponse(volumeJoin, apiDBUtils -> {
+            apiDBUtils.when(() -> 
ApiDBUtils.findComputeOnlyDiskOfferingById(DISK_OFFERING_ID)).thenReturn(null);
+            apiDBUtils.when(() -> 
ApiDBUtils.findDiskOfferingById(DISK_OFFERING_ID)).thenReturn(diskOffering);
+        });
+
+        assertNull(response.isCustomizedIops());
+        assertNull(response.getCustomizedIops());
+    }
+
+    private VolumeJoinVO mockVolumeWithDiskOffering() {
+        VolumeJoinVO volumeJoin = mock(VolumeJoinVO.class);
+        when(volumeJoin.getUuid()).thenReturn("volume-uuid");
+        when(volumeJoin.getName()).thenReturn("ROOT-67");
+        when(volumeJoin.getDiskOfferingId()).thenReturn(DISK_OFFERING_ID);
+        when(volumeJoin.getVmId()).thenReturn(0L);
+        when(volumeJoin.getState()).thenReturn(Volume.State.Ready);
+        when(volumeJoin.getVolumeType()).thenReturn(Volume.Type.ROOT);
+        when(volumeJoin.getTemplateId()).thenReturn(0L);
+        when(volumeJoin.getTagId()).thenReturn(0L);
+        when(volumeJoin.getMinIops()).thenReturn(0L);
+        when(volumeJoin.getMaxIops()).thenReturn(1000L);
+        when(volumeJoin.getDeleteProtection()).thenReturn(false);
+        when(volumeJoin.isDisplayVolume()).thenReturn(true);
+        when(volumeJoin.getKmsWrappedKeyId()).thenReturn(null);
+        when(volumeJoin.getJobId()).thenReturn(null);
+        return volumeJoin;
+    }
+
+    private VolumeResponse newVolumeResponse(VolumeJoinVO volumeJoin, 
Consumer<MockedStatic<ApiDBUtils>> apiStubs) {
+        Account callingAccount = mock(Account.class);
+        when(callingAccount.getId()).thenReturn(1L);
+        CallContext callContext = mock(CallContext.class);
+        when(callContext.getCallingAccount()).thenReturn(callingAccount);
+
+        try (MockedStatic<ApiDBUtils> apiDBUtils = 
mockStatic(ApiDBUtils.class);
+             MockedStatic<CallContext> callContextMocked = 
mockStatic(CallContext.class);
+             MockedStatic<ApiResponseHelper> apiResponseHelperMocked = 
mockStatic(ApiResponseHelper.class)) {
+            apiStubs.accept(apiDBUtils);
+            
callContextMocked.when(CallContext::current).thenReturn(callContext);
+            apiResponseHelperMocked.when(() -> 
ApiResponseHelper.populateOwner(any(), any()))
+                    .thenAnswer(invocation -> null);
+            return 
_volumeJoinDaoImpl.newVolumeResponse(ResponseView.Restricted, volumeJoin);
+        }
+    }
+
 }
diff --git a/ui/src/views/storage/ResizeVolume.vue 
b/ui/src/views/storage/ResizeVolume.vue
index 25b7c3ff6b3..c017816f100 100644
--- a/ui/src/views/storage/ResizeVolume.vue
+++ b/ui/src/views/storage/ResizeVolume.vue
@@ -65,7 +65,7 @@
 </template>
 <script>
 import { ref, reactive, toRaw } from 'vue'
-import { getAPI, postAPI } from '@/api'
+import { postAPI } from '@/api'
 import { mixinForm } from '@/utils/mixin'
 import TooltipLabel from '@/components/widgets/TooltipLabel'
 
@@ -83,10 +83,9 @@ export default {
   },
   data () {
     return {
-      offerings: [],
-      customDiskOffering: false,
       loading: false,
-      customDiskOfferingIops: false
+      customDiskOfferingIops: false,
+      initialSize: null
     }
   },
   created () {
@@ -104,24 +103,24 @@ export default {
       })
     },
     fetchData () {
-      this.loading = true
-      getAPI('listDiskOfferings', {
-        zoneid: this.resource.zoneid,
-        listall: true
-      }).then(json => {
-        this.offerings = json.listdiskofferingsresponse.diskoffering || []
-        this.form.diskofferingid = this.offerings[0].id || ''
-        this.customDiskOffering = this.offerings[0].iscustomized || false
-        this.customDiskOfferingIops = this.offerings[0].iscustomizediops || 
false
-      }).finally(() => {
-        this.loading = false
-      })
+      if (this.resource.size != null) {
+        this.form.size = this.resource.size / (1024 * 1024 * 1024)
+        this.initialSize = this.form.size
+      }
+      this.customDiskOfferingIops = this.resource.iscustomizediops
+      if (this.customDiskOfferingIops) {
+        this.form.miniops = this.resource.miniops
+        this.form.maxiops = this.resource.maxiops
+      }
     },
     handleSubmit (e) {
       if (this.loading) return
       this.formRef.value.validate().then(() => {
         const formRaw = toRaw(this.form)
         const values = this.handleRemoveFields(formRaw)
+        if (this.initialSize != null && values.size != null && 
Number(values.size) === Number(this.initialSize)) {
+          delete values.size
+        }
         this.loading = true
         values.id = this.resource.id
         postAPI('resizeVolume', values).then(response => {

Reply via email to