ui: option to migrate vm with volumes to same pool (#11703)

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>
This commit is contained in:
Abhishek Kumar 2026-01-12 18:57:04 +05:30 committed by GitHub
parent 8dcfc7c767
commit 8627c60b95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 101 additions and 41 deletions

View File

@ -380,6 +380,7 @@
"label.app.name": "CloudStack", "label.app.name": "CloudStack",
"label.application.policy.set": "Application Policy Set", "label.application.policy.set": "Application Policy Set",
"label.apply": "Apply", "label.apply": "Apply",
"label.apply.to.all": "Apply to all",
"label.apply.tungsten.firewall.policy": "Apply Firewall Policy", "label.apply.tungsten.firewall.policy": "Apply Firewall Policy",
"label.apply.tungsten.network.policy": "Apply Network Policy", "label.apply.tungsten.network.policy": "Apply Network Policy",
"label.apply.tungsten.tag": "Apply tag", "label.apply.tungsten.tag": "Apply tag",
@ -3692,6 +3693,7 @@
"message.vnf.nic.move.down.fail": "Failed to move down this NIC", "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.no.credentials": "No credentials found for the VNF appliance.",
"message.vnf.select.networks": "Please select the relevant network for each VNF NIC.", "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.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.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.", "message.volume.state.copying": "The volume is being copied from the image store to primary storage, in case it's an uploaded volume.",

View File

@ -206,8 +206,13 @@ export default {
closeVolumeStoragePoolSelector () { closeVolumeStoragePoolSelector () {
this.selectedVolumeForStoragePoolSelection = {} this.selectedVolumeForStoragePoolSelection = {}
}, },
handleVolumeStoragePoolSelection (volumeId, storagePool) { handleVolumeStoragePoolSelection (volumeId, storagePool, applyToAll) {
for (const volume of this.volumes) { for (const volume of this.volumes) {
if (applyToAll) {
volume.selectedstorageid = storagePool.id
volume.selectedstoragename = storagePool.name
volume.selectedstorageclusterid = storagePool.clusterid
} else {
if (volume.id === volumeId) { if (volume.id === volumeId) {
volume.selectedstorageid = storagePool.id volume.selectedstorageid = storagePool.id
volume.selectedstoragename = storagePool.name volume.selectedstoragename = storagePool.name
@ -215,6 +220,7 @@ export default {
break break
} }
} }
}
this.updateVolumeToStoragePoolSelection() this.updateVolumeToStoragePoolSelection()
}, },
updateVolumeToStoragePoolSelection () { updateVolumeToStoragePoolSelection () {

View File

@ -25,6 +25,15 @@
:autoAssignAllowed="autoAssignAllowed" :autoAssignAllowed="autoAssignAllowed"
@select="handleSelect" /> @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 /> <a-divider />
<div class="actions"> <div class="actions">
@ -36,11 +45,13 @@
</template> </template>
<script> <script>
import TooltipLabel from '@/components/widgets/TooltipLabel'
import StoragePoolSelectView from '@/components/view/StoragePoolSelectView' import StoragePoolSelectView from '@/components/view/StoragePoolSelectView'
export default { export default {
name: 'VolumeStoragePoolSelectionForm', name: 'VolumeStoragePoolSelectionForm',
components: { components: {
TooltipLabel,
StoragePoolSelectView StoragePoolSelectView
}, },
props: { props: {
@ -70,7 +81,8 @@ export default {
}, },
data () { data () {
return { return {
selectedStoragePool: null selectedStoragePool: null,
applyToAll: false
} }
}, },
watch: { watch: {
@ -95,7 +107,7 @@ export default {
} }
}, },
submitForm () { submitForm () {
this.$emit('select', this.resource.id, this.selectedStoragePool) this.$emit('select', this.resource.id, this.selectedStoragePool, this.applyToAll)
this.closeModal() this.closeModal()
} }
} }

View File

@ -26,7 +26,7 @@
class="top-spaced" class="top-spaced"
:placeholder="$t('label.search')" :placeholder="$t('label.search')"
v-model:value="searchQuery" v-model:value="searchQuery"
@search="fetchData" @search="fetchHostsForMigration"
v-focus="true" /> v-focus="true" />
<a-table <a-table
class="top-spaced" class="top-spaced"
@ -97,7 +97,7 @@
</a-pagination> </a-pagination>
<a-form-item <a-form-item
v-if="isUserVm" v-if="isUserVm && hasVolumes"
class="top-spaced"> class="top-spaced">
<template #label> <template #label>
<tooltip-label :title="$t('label.migrate.with.storage')" :tooltip="$t('message.migrate.with.storage')"/> <tooltip-label :title="$t('label.migrate.with.storage')" :tooltip="$t('message.migrate.with.storage')"/>
@ -106,9 +106,29 @@
v-model:checked="migrateWithStorage" v-model:checked="migrateWithStorage"
:disabled="!selectedHost || !selectedHost.id || selectedHost.id === -1" /> :disabled="!selectedHost || !selectedHost.id || selectedHost.id === -1" />
</a-form-item> </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 <instance-volumes-storage-pool-select-list-view
ref="volumeToPoolSelect" ref="volumeToPoolSelect"
v-if="migrateWithStorage" v-if="migrateWithStorage && migrateMode !== 1"
class="top-spaced" class="top-spaced"
:resource="resource" :resource="resource"
:clusterId="selectedHost.id ? selectedHost.clusterid : null" :clusterId="selectedHost.id ? selectedHost.clusterid : null"
@ -118,7 +138,7 @@
<div class="actions"> <div class="actions">
<a-button @click="closeModal">{{ $t('label.cancel') }}</a-button> <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>
</div> </div>
</template> </template>
@ -126,12 +146,14 @@
<script> <script>
import { api } from '@/api' import { api } from '@/api'
import TooltipLabel from '@/components/widgets/TooltipLabel' import TooltipLabel from '@/components/widgets/TooltipLabel'
import StoragePoolSelectView from '@/components/view/StoragePoolSelectView'
import InstanceVolumesStoragePoolSelectListView from '@/components/view/InstanceVolumesStoragePoolSelectListView' import InstanceVolumesStoragePoolSelectListView from '@/components/view/InstanceVolumesStoragePoolSelectListView'
export default { export default {
name: 'VMMigrateWizard', name: 'VMMigrateWizard',
components: { components: {
TooltipLabel, TooltipLabel,
StoragePoolSelectView,
InstanceVolumesStoragePoolSelectListView InstanceVolumesStoragePoolSelectListView
}, },
props: { props: {
@ -188,6 +210,7 @@ export default {
} }
], ],
migrateWithStorage: false, migrateWithStorage: false,
migrateMode: 1,
volumeToPoolSelection: [], volumeToPoolSelection: [],
volumes: [] volumes: []
} }
@ -198,6 +221,9 @@ export default {
computed: { computed: {
isUserVm () { isUserVm () {
return this.$route.meta.resourceType === 'UserVm' return this.$route.meta.resourceType === 'UserVm'
},
hasVolumes () {
return this.volumes && this.volumes.length > 0
} }
}, },
watch: { watch: {
@ -212,6 +238,10 @@ export default {
return array !== null && array !== undefined && Array.isArray(array) && array.length > 0 return array !== null && array !== undefined && Array.isArray(array) && array.length > 0
}, },
fetchData () { fetchData () {
this.fetchHostsForMigration()
this.fetchVolumes()
},
fetchHostsForMigration () {
this.loading = true this.loading = true
api('findHostsForMigration', { api('findHostsForMigration', {
virtualmachineid: this.resource.id, virtualmachineid: this.resource.id,
@ -239,17 +269,16 @@ export default {
handleChangePage (page, pageSize) { handleChangePage (page, pageSize) {
this.page = page this.page = page
this.pageSize = pageSize this.pageSize = pageSize
this.fetchData() this.fetchHostsForMigration()
}, },
handleChangePageSize (currentPage, pageSize) { handleChangePageSize (currentPage, pageSize) {
this.page = currentPage this.page = currentPage
this.pageSize = pageSize this.pageSize = pageSize
this.fetchData() this.fetchHostsForMigration()
}, },
handleSelectedHostChange (host) { handleSelectedHostChange (host) {
if (host.id === -1) { if (host.id === -1) {
this.migrateWithStorage = false this.migrateWithStorage = false
this.fetchVolumes()
} }
this.selectedHost = host this.selectedHost = host
this.selectedVolumeForStoragePoolSelection = {} this.selectedVolumeForStoragePoolSelection = {}
@ -258,6 +287,17 @@ export default {
this.$refs.volumeToPoolSelect.resetSelection() 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) { handleVolumeToPoolChange (volumeToPool) {
this.volumeToPoolSelection = volumeToPool this.volumeToPoolSelection = volumeToPool
}, },
@ -268,7 +308,7 @@ export default {
listAll: true, listAll: true,
virtualmachineid: this.resource.id virtualmachineid: this.resource.id
}).then(response => { }).then(response => {
this.volumes = response.listvolumesresponse.volume this.volumes = response?.listvolumesresponse?.volume || []
}).finally(() => { }).finally(() => {
this.loading = false this.loading = false
}) })
@ -277,7 +317,7 @@ export default {
if (this.selectedHost.requiresStorageMotion || this.volumeToPoolSelection.length > 0) { if (this.selectedHost.requiresStorageMotion || this.volumeToPoolSelection.length > 0) {
return true 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) { for (var volume of this.volumes) {
if (volume.storagetype === 'local') { if (volume.storagetype === 'local') {
return true return true
@ -305,7 +345,7 @@ export default {
var params = this.selectedHost.id === -1 var params = this.selectedHost.id === -1
? { autoselect: true, virtualmachineid: this.resource.id } ? { autoselect: true, virtualmachineid: this.resource.id }
: { hostid: this.selectedHost.id, 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++) { for (var i = 0; i < this.volumeToPoolSelection.length; i++) {
const mapping = this.volumeToPoolSelection[i] const mapping = this.volumeToPoolSelection[i]
params['migrateto[' + i + '].volume'] = mapping.volume params['migrateto[' + i + '].volume'] = mapping.volume

View File

@ -126,8 +126,8 @@ describe('Views > compute > MigrateWizard.vue', () => {
if (Object.keys(originalFunc).length > 0) { if (Object.keys(originalFunc).length > 0) {
Object.keys(originalFunc).forEach(key => { Object.keys(originalFunc).forEach(key => {
switch (key) { switch (key) {
case 'fetchData': case 'fetchHostsForMigration':
wrapper.vm.fetchData = originalFunc[key] wrapper.vm.fetchHostsForMigration = originalFunc[key]
break break
default: default:
break break
@ -137,11 +137,11 @@ describe('Views > compute > MigrateWizard.vue', () => {
}) })
describe('Methods', () => { describe('Methods', () => {
describe('fetchData()', () => { describe('fetchHostsForMigration()', () => {
it('API should be called with resource is empty and searchQuery is empty', async (done) => { it('API should be called with resource is empty and searchQuery is empty', async (done) => {
await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { count: 0, host: [] } }) await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { count: 0, host: [] } })
await wrapper.setProps({ resource: {} }) await wrapper.setProps({ resource: {} })
await wrapper.vm.fetchData() await wrapper.vm.fetchHostsForMigration()
await flushPromises() await flushPromises()
expect(mockAxios).toHaveBeenCalled() 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) => { it('API should be called with resource.id is empty and searchQuery is empty', async (done) => {
await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { count: 0, host: [] } }) await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { count: 0, host: [] } })
await wrapper.setProps({ resource: { id: null } }) await wrapper.setProps({ resource: { id: null } })
await wrapper.vm.fetchData() await wrapper.vm.fetchHostsForMigration()
await flushPromises() await flushPromises()
expect(mockAxios).toHaveBeenCalled() 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) => { 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 mockAxios.mockResolvedValue({ findhostsformigrationresponse: { count: 0, host: [] } })
await wrapper.setProps({ resource: { id: 'test-id-value' } }) await wrapper.setProps({ resource: { id: 'test-id-value' } })
await wrapper.vm.fetchData() await wrapper.vm.fetchHostsForMigration()
await flushPromises() await flushPromises()
expect(mockAxios).toHaveBeenCalled() expect(mockAxios).toHaveBeenCalled()
@ -211,7 +211,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { count: 0, host: [] } }) await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { count: 0, host: [] } })
await wrapper.setProps({ resource: { id: 'test-id-value' } }) await wrapper.setProps({ resource: { id: 'test-id-value' } })
await wrapper.setData({ searchQuery: 'test-query-value' }) await wrapper.setData({ searchQuery: 'test-query-value' })
await wrapper.vm.fetchData() await wrapper.vm.fetchHostsForMigration()
await flushPromises() await flushPromises()
expect(mockAxios).toHaveBeenCalled() expect(mockAxios).toHaveBeenCalled()
@ -239,7 +239,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
page: 2, page: 2,
pageSize: 20 pageSize: 20
}) })
await wrapper.vm.fetchData() await wrapper.vm.fetchHostsForMigration()
await flushPromises() await flushPromises()
expect(mockAxios).toHaveBeenCalled() 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) => { it('check hosts, totalCount when api is called with response result is empty', async (done) => {
await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { count: 0, host: [] } }) await mockAxios.mockResolvedValue({ findhostsformigrationresponse: { count: 0, host: [] } })
await wrapper.setProps({ resource: {} }) await wrapper.setProps({ resource: {} })
await wrapper.vm.fetchData() await wrapper.vm.fetchHostsForMigration()
await flushPromises() await flushPromises()
expect(wrapper.vm.hosts).toEqual([]) expect(wrapper.vm.hosts).toEqual([])
@ -285,7 +285,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
} }
}) })
await wrapper.setProps({ resource: {} }) await wrapper.setProps({ resource: {} })
await wrapper.vm.fetchData() await wrapper.vm.fetchHostsForMigration()
await flushPromises() await flushPromises()
expect(wrapper.vm.hosts).toEqual([{ expect(wrapper.vm.hosts).toEqual([{
@ -305,7 +305,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
await mockAxios.mockRejectedValue(mockError) await mockAxios.mockRejectedValue(mockError)
await wrapper.setProps({ resource: {} }) await wrapper.setProps({ resource: {} })
await wrapper.vm.fetchData() await wrapper.vm.fetchHostsForMigration()
await flushPromises() await flushPromises()
await flushPromises() await flushPromises()
@ -543,14 +543,14 @@ describe('Views > compute > MigrateWizard.vue', () => {
await mockAxios.mockResolvedValue(mockData) await mockAxios.mockResolvedValue(mockData)
await wrapper.setProps({ await wrapper.setProps({
resource: { resource: {
id: 'test-resource-id', id: 'test-resource-id-err',
name: 'test-resource-name' name: 'test-resource-name'
} }
}) })
await wrapper.setData({ await wrapper.setData({
selectedHost: { selectedHost: {
requiresStorageMotion: true, requiresStorageMotion: true,
id: 'test-host-id', id: 'test-host-id-err',
name: 'test-host-name' name: 'test-host-name'
} }
}) })
@ -572,14 +572,14 @@ describe('Views > compute > MigrateWizard.vue', () => {
await mockAxios.mockResolvedValue(mockData) await mockAxios.mockResolvedValue(mockData)
await wrapper.setProps({ await wrapper.setProps({
resource: { resource: {
id: 'test-resource-id', id: 'test-resource-id-catch',
name: 'test-resource-name' name: 'test-resource-name'
} }
}) })
await wrapper.setData({ await wrapper.setData({
selectedHost: { selectedHost: {
requiresStorageMotion: true, requiresStorageMotion: true,
id: 'test-host-id', id: 'test-host-id-catch',
name: 'test-host-name' name: 'test-host-name'
} }
}) })
@ -599,7 +599,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
await wrapper.setData({ await wrapper.setData({
selectedHost: { selectedHost: {
requiresStorageMotion: true, requiresStorageMotion: true,
id: 'test-host-id', id: 'test-host-id-no-res',
name: 'test-host-name' name: 'test-host-name'
} }
}) })
@ -617,11 +617,11 @@ describe('Views > compute > MigrateWizard.vue', () => {
}) })
describe('handleChangePage()', () => { describe('handleChangePage()', () => {
it('check page, pageSize and fetchData() when handleChangePage() is called', async (done) => { it('check page, pageSize and fetchHostsForMigration() when handleChangePage() is called', async (done) => {
originalFunc.fetchData = wrapper.vm.fetchData originalFunc.fetchHostsForMigration = wrapper.vm.fetchHostsForMigration
wrapper.vm.fetchData = jest.fn() 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.setProps({ resource: {} })
await wrapper.setData({ await wrapper.setData({
page: 1, page: 1,
@ -632,17 +632,17 @@ describe('Views > compute > MigrateWizard.vue', () => {
expect(wrapper.vm.page).toEqual(2) expect(wrapper.vm.page).toEqual(2)
expect(wrapper.vm.pageSize).toEqual(20) expect(wrapper.vm.pageSize).toEqual(20)
expect(fetchData).toBeCalled() expect(fetchHostsForMigration).toBeCalled()
done() done()
}) })
}) })
describe('handleChangePageSize()', () => { describe('handleChangePageSize()', () => {
it('check page, pageSize and fetchData() when handleChangePageSize() is called', async (done) => { it('check page, pageSize and fetchHostsForMigration() when handleChangePageSize() is called', async (done) => {
originalFunc.fetchData = wrapper.vm.fetchData originalFunc.fetchHostsForMigration = wrapper.vm.fetchHostsForMigration
wrapper.vm.fetchData = jest.fn() 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.setProps({ resource: {} })
await wrapper.setData({ await wrapper.setData({
page: 1, page: 1,
@ -653,7 +653,7 @@ describe('Views > compute > MigrateWizard.vue', () => {
expect(wrapper.vm.page).toEqual(2) expect(wrapper.vm.page).toEqual(2)
expect(wrapper.vm.pageSize).toEqual(20) expect(wrapper.vm.pageSize).toEqual(20)
expect(fetchData).toBeCalled() expect(fetchHostsForMigration).toBeCalled()
done() done()
}) })
}) })