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()
})
})