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

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


The following commit(s) were added to refs/heads/4.20 by this push:
     new 8627c60b951 ui: option to migrate vm with volumes to same pool (#11703)
8627c60b951 is described below

commit 8627c60b9517e53510c612104fc5925c79a4797d
Author: Abhishek Kumar <[email protected]>
AuthorDate: Mon Jan 12 18:57:04 2026 +0530

    ui: option to migrate vm with volumes to same pool (#11703)
    
    Signed-off-by: Abhishek Kumar <[email protected]>
---
 ui/public/locales/en.json                          |  2 +
 .../InstanceVolumesStoragePoolSelectListView.vue   | 12 +++--
 .../view/VolumeStoragePoolSelectForm.vue           | 16 +++++-
 ui/src/views/compute/MigrateWizard.vue             | 60 ++++++++++++++++++----
 ui/tests/unit/views/compute/MigrateWizard.spec.js  | 52 +++++++++----------
 5 files changed, 101 insertions(+), 41 deletions(-)

diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 624a13d1e21..791091e8e2a 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -380,6 +380,7 @@
 "label.app.name": "CloudStack",
 "label.application.policy.set": "Application Policy Set",
 "label.apply": "Apply",
+"label.apply.to.all": "Apply to all",
 "label.apply.tungsten.firewall.policy": "Apply Firewall Policy",
 "label.apply.tungsten.network.policy": "Apply Network Policy",
 "label.apply.tungsten.tag": "Apply tag",
@@ -3692,6 +3693,7 @@
 "message.vnf.nic.move.down.fail": "Failed to move down this NIC",
 "message.vnf.no.credentials": "No credentials found for the VNF appliance.",
 "message.vnf.select.networks": "Please select the relevant network for each 
VNF NIC.",
+"message.volume.pool.apply.to.all": "Selected storage pool will be applied to 
all existing volumes of the instance.",
 "message.volume.state.allocated": "The volume is allocated but has not been 
created yet.",
 "message.volume.state.attaching": "The volume is attaching to a volume from 
Ready state.",
 "message.volume.state.copying": "The volume is being copied from the image 
store to primary storage, in case it's an uploaded volume.",
diff --git 
a/ui/src/components/view/InstanceVolumesStoragePoolSelectListView.vue 
b/ui/src/components/view/InstanceVolumesStoragePoolSelectListView.vue
index 77f3e8f91f4..67a2bceb23e 100644
--- a/ui/src/components/view/InstanceVolumesStoragePoolSelectListView.vue
+++ b/ui/src/components/view/InstanceVolumesStoragePoolSelectListView.vue
@@ -206,13 +206,19 @@ export default {
     closeVolumeStoragePoolSelector () {
       this.selectedVolumeForStoragePoolSelection = {}
     },
-    handleVolumeStoragePoolSelection (volumeId, storagePool) {
+    handleVolumeStoragePoolSelection (volumeId, storagePool, applyToAll) {
       for (const volume of this.volumes) {
-        if (volume.id === volumeId) {
+        if (applyToAll) {
           volume.selectedstorageid = storagePool.id
           volume.selectedstoragename = storagePool.name
           volume.selectedstorageclusterid = storagePool.clusterid
-          break
+        } else {
+          if (volume.id === volumeId) {
+            volume.selectedstorageid = storagePool.id
+            volume.selectedstoragename = storagePool.name
+            volume.selectedstorageclusterid = storagePool.clusterid
+            break
+          }
         }
       }
       this.updateVolumeToStoragePoolSelection()
diff --git a/ui/src/components/view/VolumeStoragePoolSelectForm.vue 
b/ui/src/components/view/VolumeStoragePoolSelectForm.vue
index eea416faa1a..9981418ee14 100644
--- a/ui/src/components/view/VolumeStoragePoolSelectForm.vue
+++ b/ui/src/components/view/VolumeStoragePoolSelectForm.vue
@@ -25,6 +25,15 @@
       :autoAssignAllowed="autoAssignAllowed"
       @select="handleSelect" />
 
+    <a-form-item
+      class="top-spaced">
+      <template #label>
+        <tooltip-label :title="$t('label.apply.to.all')" 
:tooltip="$t('message.volume.pool.apply.to.all')"/>
+      </template>
+      <a-switch
+        v-model:checked="applyToAll" />
+    </a-form-item>
+
     <a-divider />
 
     <div class="actions">
@@ -36,11 +45,13 @@
 </template>
 
 <script>
+import TooltipLabel from '@/components/widgets/TooltipLabel'
 import StoragePoolSelectView from '@/components/view/StoragePoolSelectView'
 
 export default {
   name: 'VolumeStoragePoolSelectionForm',
   components: {
+    TooltipLabel,
     StoragePoolSelectView
   },
   props: {
@@ -70,7 +81,8 @@ export default {
   },
   data () {
     return {
-      selectedStoragePool: null
+      selectedStoragePool: null,
+      applyToAll: false
     }
   },
   watch: {
@@ -95,7 +107,7 @@ export default {
       }
     },
     submitForm () {
-      this.$emit('select', this.resource.id, this.selectedStoragePool)
+      this.$emit('select', this.resource.id, this.selectedStoragePool, 
this.applyToAll)
       this.closeModal()
     }
   }
diff --git a/ui/src/views/compute/MigrateWizard.vue 
b/ui/src/views/compute/MigrateWizard.vue
index 70f40f7433a..eee29845ead 100644
--- a/ui/src/views/compute/MigrateWizard.vue
+++ b/ui/src/views/compute/MigrateWizard.vue
@@ -26,7 +26,7 @@
       class="top-spaced"
       :placeholder="$t('label.search')"
       v-model:value="searchQuery"
-      @search="fetchData"
+      @search="fetchHostsForMigration"
       v-focus="true" />
     <a-table
       class="top-spaced"
@@ -97,7 +97,7 @@
     </a-pagination>
 
     <a-form-item
-      v-if="isUserVm"
+      v-if="isUserVm && hasVolumes"
       class="top-spaced">
       <template #label>
         <tooltip-label :title="$t('label.migrate.with.storage')" 
:tooltip="$t('message.migrate.with.storage')"/>
@@ -106,9 +106,29 @@
         v-model:checked="migrateWithStorage"
         :disabled="!selectedHost || !selectedHost.id || selectedHost.id === 
-1" />
     </a-form-item>
+
+    <a-radio-group
+      v-if="migrateWithStorage"
+      v-model:value="migrateMode"
+      @change="e => { handleMigrateModeChange(e.target.value) }">
+      <a-radio class="radio-style" :value="1">
+        {{ $t('label.migrate.instance.single.storage') }}
+      </a-radio>
+      <a-radio class="radio-style" :value="2">
+        {{ $t('label.migrate.instance.specific.storages') }}
+      </a-radio>
+    </a-radio-group>
+
+    <div v-if="migrateWithStorage && migrateMode == 1">
+      <storage-pool-select-view
+        ref="storagePoolSelection"
+        :autoAssignAllowed="false"
+        :resource="resource"
+        @select="handleStoragePoolChange" />
+    </div>
     <instance-volumes-storage-pool-select-list-view
       ref="volumeToPoolSelect"
-      v-if="migrateWithStorage"
+      v-if="migrateWithStorage && migrateMode !== 1"
       class="top-spaced"
       :resource="resource"
       :clusterId="selectedHost.id ? selectedHost.clusterid : null"
@@ -118,7 +138,7 @@
 
     <div class="actions">
       <a-button @click="closeModal">{{ $t('label.cancel') }}</a-button>
-      <a-button type="primary" ref="submit" :disabled="!selectedHost.id" 
@click="submitForm">{{ $t('label.ok') }}</a-button>
+      <a-button type="primary" ref="submit" :disabled="!selectedHost.id || 
(migrateWithStorage && migrateMode === 1 && !volumeToPoolSelection.length)" 
@click="submitForm">{{ $t('label.ok') }}</a-button>
     </div>
   </div>
 </template>
@@ -126,12 +146,14 @@
 <script>
 import { api } from '@/api'
 import TooltipLabel from '@/components/widgets/TooltipLabel'
+import StoragePoolSelectView from '@/components/view/StoragePoolSelectView'
 import InstanceVolumesStoragePoolSelectListView from 
'@/components/view/InstanceVolumesStoragePoolSelectListView'
 
 export default {
   name: 'VMMigrateWizard',
   components: {
     TooltipLabel,
+    StoragePoolSelectView,
     InstanceVolumesStoragePoolSelectListView
   },
   props: {
@@ -188,6 +210,7 @@ export default {
         }
       ],
       migrateWithStorage: false,
+      migrateMode: 1,
       volumeToPoolSelection: [],
       volumes: []
     }
@@ -198,6 +221,9 @@ export default {
   computed: {
     isUserVm () {
       return this.$route.meta.resourceType === 'UserVm'
+    },
+    hasVolumes () {
+      return this.volumes && this.volumes.length > 0
     }
   },
   watch: {
@@ -212,6 +238,10 @@ export default {
       return array !== null && array !== undefined && Array.isArray(array) && 
array.length > 0
     },
     fetchData () {
+      this.fetchHostsForMigration()
+      this.fetchVolumes()
+    },
+    fetchHostsForMigration () {
       this.loading = true
       api('findHostsForMigration', {
         virtualmachineid: this.resource.id,
@@ -239,17 +269,16 @@ export default {
     handleChangePage (page, pageSize) {
       this.page = page
       this.pageSize = pageSize
-      this.fetchData()
+      this.fetchHostsForMigration()
     },
     handleChangePageSize (currentPage, pageSize) {
       this.page = currentPage
       this.pageSize = pageSize
-      this.fetchData()
+      this.fetchHostsForMigration()
     },
     handleSelectedHostChange (host) {
       if (host.id === -1) {
         this.migrateWithStorage = false
-        this.fetchVolumes()
       }
       this.selectedHost = host
       this.selectedVolumeForStoragePoolSelection = {}
@@ -258,6 +287,17 @@ export default {
         this.$refs.volumeToPoolSelect.resetSelection()
       }
     },
+    handleMigrateModeChange () {
+      this.volumeToPoolSelection = []
+    },
+    handleStoragePoolChange (storagePool) {
+      this.volumeToPoolSelection = []
+      for (const volume of this.volumes) {
+        if (storagePool && storagePool.id && storagePool.id !== -1) {
+          this.volumeToPoolSelection.push({ volume: volume.id, pool: 
storagePool.id })
+        }
+      }
+    },
     handleVolumeToPoolChange (volumeToPool) {
       this.volumeToPoolSelection = volumeToPool
     },
@@ -268,7 +308,7 @@ export default {
         listAll: true,
         virtualmachineid: this.resource.id
       }).then(response => {
-        this.volumes = response.listvolumesresponse.volume
+        this.volumes = response?.listvolumesresponse?.volume || []
       }).finally(() => {
         this.loading = false
       })
@@ -277,7 +317,7 @@ export default {
       if (this.selectedHost.requiresStorageMotion || 
this.volumeToPoolSelection.length > 0) {
         return true
       }
-      if (this.selectedHost.id === -1 && this.volumes && this.volumes.length > 
0) {
+      if (this.selectedHost.id === -1 && this.hasVolumes) {
         for (var volume of this.volumes) {
           if (volume.storagetype === 'local') {
             return true
@@ -305,7 +345,7 @@ export default {
       var params = this.selectedHost.id === -1
         ? { autoselect: true, virtualmachineid: this.resource.id }
         : { hostid: this.selectedHost.id, virtualmachineid: this.resource.id }
-      if (this.migrateWithStorage) {
+      if (this.migrateWithStorage && this.volumeToPoolSelection && 
this.volumeToPoolSelection.length > 0) {
         for (var i = 0; i < this.volumeToPoolSelection.length; i++) {
           const mapping = this.volumeToPoolSelection[i]
           params['migrateto[' + i + '].volume'] = mapping.volume
diff --git a/ui/tests/unit/views/compute/MigrateWizard.spec.js 
b/ui/tests/unit/views/compute/MigrateWizard.spec.js
index d3ee49426dc..2404fda6c8c 100644
--- a/ui/tests/unit/views/compute/MigrateWizard.spec.js
+++ b/ui/tests/unit/views/compute/MigrateWizard.spec.js
@@ -126,8 +126,8 @@ describe('Views > compute > MigrateWizard.vue', () => {
     if (Object.keys(originalFunc).length > 0) {
       Object.keys(originalFunc).forEach(key => {
         switch (key) {
-          case 'fetchData':
-            wrapper.vm.fetchData = originalFunc[key]
+          case 'fetchHostsForMigration':
+            wrapper.vm.fetchHostsForMigration = originalFunc[key]
             break
           default:
             break
@@ -137,11 +137,11 @@ describe('Views > compute > MigrateWizard.vue', () => {
   })
 
   describe('Methods', () => {
-    describe('fetchData()', () => {
+    describe('fetchHostsForMigration()', () => {
       it('API should be called with resource is empty and searchQuery is 
empty', async (done) => {
         await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { 
count: 0, host: [] } })
         await wrapper.setProps({ resource: {} })
-        await wrapper.vm.fetchData()
+        await wrapper.vm.fetchHostsForMigration()
         await flushPromises()
 
         expect(mockAxios).toHaveBeenCalled()
@@ -164,7 +164,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
       it('API should be called with resource.id is empty and searchQuery is 
empty', async (done) => {
         await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { 
count: 0, host: [] } })
         await wrapper.setProps({ resource: { id: null } })
-        await wrapper.vm.fetchData()
+        await wrapper.vm.fetchHostsForMigration()
         await flushPromises()
 
         expect(mockAxios).toHaveBeenCalled()
@@ -187,7 +187,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
       it('API should be called with resource.id is not empty and searchQuery 
is empty', async (done) => {
         await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { 
count: 0, host: [] } })
         await wrapper.setProps({ resource: { id: 'test-id-value' } })
-        await wrapper.vm.fetchData()
+        await wrapper.vm.fetchHostsForMigration()
         await flushPromises()
 
         expect(mockAxios).toHaveBeenCalled()
@@ -211,7 +211,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
         await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { 
count: 0, host: [] } })
         await wrapper.setProps({ resource: { id: 'test-id-value' } })
         await wrapper.setData({ searchQuery: 'test-query-value' })
-        await wrapper.vm.fetchData()
+        await wrapper.vm.fetchHostsForMigration()
         await flushPromises()
 
         expect(mockAxios).toHaveBeenCalled()
@@ -239,7 +239,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
           page: 2,
           pageSize: 20
         })
-        await wrapper.vm.fetchData()
+        await wrapper.vm.fetchHostsForMigration()
         await flushPromises()
 
         expect(mockAxios).toHaveBeenCalled()
@@ -262,7 +262,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
       it('check hosts, totalCount when api is called with response result is 
empty', async (done) => {
         await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { 
count: 0, host: [] } })
         await wrapper.setProps({ resource: {} })
-        await wrapper.vm.fetchData()
+        await wrapper.vm.fetchHostsForMigration()
         await flushPromises()
 
         expect(wrapper.vm.hosts).toEqual([])
@@ -285,7 +285,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
           }
         })
         await wrapper.setProps({ resource: {} })
-        await wrapper.vm.fetchData()
+        await wrapper.vm.fetchHostsForMigration()
         await flushPromises()
 
         expect(wrapper.vm.hosts).toEqual([{
@@ -305,7 +305,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
 
         await mockAxios.mockRejectedValue(mockError)
         await wrapper.setProps({ resource: {} })
-        await wrapper.vm.fetchData()
+        await wrapper.vm.fetchHostsForMigration()
         await flushPromises()
         await flushPromises()
 
@@ -543,14 +543,14 @@ describe('Views > compute > MigrateWizard.vue', () => {
         await mockAxios.mockResolvedValue(mockData)
         await wrapper.setProps({
           resource: {
-            id: 'test-resource-id',
+            id: 'test-resource-id-err',
             name: 'test-resource-name'
           }
         })
         await wrapper.setData({
           selectedHost: {
             requiresStorageMotion: true,
-            id: 'test-host-id',
+            id: 'test-host-id-err',
             name: 'test-host-name'
           }
         })
@@ -572,14 +572,14 @@ describe('Views > compute > MigrateWizard.vue', () => {
         await mockAxios.mockResolvedValue(mockData)
         await wrapper.setProps({
           resource: {
-            id: 'test-resource-id',
+            id: 'test-resource-id-catch',
             name: 'test-resource-name'
           }
         })
         await wrapper.setData({
           selectedHost: {
             requiresStorageMotion: true,
-            id: 'test-host-id',
+            id: 'test-host-id-catch',
             name: 'test-host-name'
           }
         })
@@ -599,7 +599,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
         await wrapper.setData({
           selectedHost: {
             requiresStorageMotion: true,
-            id: 'test-host-id',
+            id: 'test-host-id-no-res',
             name: 'test-host-name'
           }
         })
@@ -617,11 +617,11 @@ describe('Views > compute > MigrateWizard.vue', () => {
     })
 
     describe('handleChangePage()', () => {
-      it('check page, pageSize and fetchData() when handleChangePage() is 
called', async (done) => {
-        originalFunc.fetchData = wrapper.vm.fetchData
-        wrapper.vm.fetchData = jest.fn()
+      it('check page, pageSize and fetchHostsForMigration() when 
handleChangePage() is called', async (done) => {
+        originalFunc.fetchHostsForMigration = wrapper.vm.fetchHostsForMigration
+        wrapper.vm.fetchHostsForMigration = jest.fn()
 
-        const fetchData = jest.spyOn(wrapper.vm, 
'fetchData').mockImplementation(() => {})
+        const fetchHostsForMigration = jest.spyOn(wrapper.vm, 
'fetchHostsForMigration').mockImplementation(() => {})
         await wrapper.setProps({ resource: {} })
         await wrapper.setData({
           page: 1,
@@ -632,17 +632,17 @@ describe('Views > compute > MigrateWizard.vue', () => {
 
         expect(wrapper.vm.page).toEqual(2)
         expect(wrapper.vm.pageSize).toEqual(20)
-        expect(fetchData).toBeCalled()
+        expect(fetchHostsForMigration).toBeCalled()
         done()
       })
     })
 
     describe('handleChangePageSize()', () => {
-      it('check page, pageSize and fetchData() when handleChangePageSize() is 
called', async (done) => {
-        originalFunc.fetchData = wrapper.vm.fetchData
-        wrapper.vm.fetchData = jest.fn()
+      it('check page, pageSize and fetchHostsForMigration() when 
handleChangePageSize() is called', async (done) => {
+        originalFunc.fetchHostsForMigration = wrapper.vm.fetchHostsForMigration
+        wrapper.vm.fetchHostsForMigration = jest.fn()
 
-        const fetchData = jest.spyOn(wrapper.vm, 
'fetchData').mockImplementation(() => {})
+        const fetchHostsForMigration = jest.spyOn(wrapper.vm, 
'fetchHostsForMigration').mockImplementation(() => {})
         await wrapper.setProps({ resource: {} })
         await wrapper.setData({
           page: 1,
@@ -653,7 +653,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
 
         expect(wrapper.vm.page).toEqual(2)
         expect(wrapper.vm.pageSize).toEqual(20)
-        expect(fetchData).toBeCalled()
+        expect(fetchHostsForMigration).toBeCalled()
         done()
       })
     })

Reply via email to