UI support for deploy a VM from volume/snapshot (#11164)

* UI support for deploy  a virtual machine with existing volume or a disk snapshot
This commit is contained in:
slavkap 2025-07-31 16:30:11 +03:00 committed by GitHub
parent 5aa15187b6
commit adccdf2c7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 217 additions and 6 deletions

View File

@ -3711,6 +3711,7 @@
"message.shared.network.unsupported.for.nsx": "Shared networks aren't supported for NSX enabled Zones",
"message.shutdown.triggered": "A shutdown has been triggered. CloudStack will not accept new jobs",
"message.snapshot.additional.zones": "Snapshots will always be created in its native Zone - %x, here you can select additional zone(s) where it will be copied to at creation time",
"message.snapshot.desc": "Snapshot to create a ROOT disk from",
"message.sourcenatip.change.warning": "WARNING: Changing the sourcenat IP address of the network will cause connectivity downtime for the Instances with NICs in the Network.",
"message.sourcenatip.change.inhibited": "Changing the sourcenat to this IP of the Network to this address is inhibited as firewall rules are defined for it. This can include port forwarding or load balancing rules.\n - If this is an Isolated Network, please use updateNetwork/click the edit button.\n - If this is a VPC, first clear all other rules for this address.",
"message.specify.tag.key": "Please specify a tag key.",
@ -3888,7 +3889,7 @@
"message.template.arch": "Please select a Template architecture.",
"message.template.desc": "OS image that can be used to boot Instances.",
"message.template.import.vm.temporary": "If a temporary Template is used, the reset Instance operation will not work after importing it.",
"message.template.iso": "Please select a Template or ISO to continue.",
"message.template.iso": "Please select a Template, ISO, volume or a snapshot to continue.",
"message.template.type.change.warning": "WARNING: Changing the Template type to SYSTEM will disable further changes to the Template.",
"message.tooltip.reserved.system.netmask": "The Network prefix that defines the Pod subnet. Uses CIDR notation.",
"message.traffic.type.deleted": "Successfully deleted traffic type",
@ -3963,6 +3964,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.desc": "Volume to use as a ROOT disk",
"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.",

View File

@ -133,9 +133,9 @@
:guestOsCategories="options.guestOsCategories"
:guestOsCategoriesLoading="loading.guestOsCategories"
:selectedGuestOsCategoryId="form.guestoscategoryid"
:imageItems="imageType === 'isoid' ? options.isos : options.templates"
:imagesLoading="imageType === 'isoid' ? loading.isos : loading.templates"
:diskSizeSelectionAllowed="imageType !== 'isoid'"
:imageItems="imageType === 'isoid' ? options.isos : imageType === 'volumeid' ? options.volumes : imageType === 'snapshotid' ? options.snapshots : options.templates"
:imagesLoading="imageType === 'isoid' ? loading.isos : imageType === 'volumeid' ? loading.volumes : imageType === 'snapshotid' ? loading.snapshots : loading.templates"
:diskSizeSelectionAllowed="imageType !== 'isoid' && imageType !== 'volumeid' && imageType !== 'snapshotid'"
:diskSizeSelectionDeployAsIsMessageVisible="template && template.deployasis"
:rootDiskOverrideDisabled="rootDiskSizeFixed > 0 || (template && template.deployasis) || showOverrideDiskOfferingOption"
:rootDiskOverrideChecked="form.rootdisksizeitem"
@ -211,6 +211,12 @@
<a-form-item class="form-item-hidden">
<a-input v-model:value="form.isoid" />
</a-form-item>
<a-form-item class="form-item-hidden">
<a-input v-model:value="form.volumeid" />
</a-form-item>
<a-form-item class="form-item-hidden">
<a-input v-model:value="form.snapshotid" />
</a-form-item>
<a-form-item class="form-item-hidden">
<a-input v-model:value="form.rootdisksize" />
</a-form-item>
@ -997,6 +1003,8 @@ export default {
},
options: {
guestOsCategories: [],
volumes: {},
snapshots: {},
templates: {},
isos: {},
hypervisors: [],
@ -1020,6 +1028,8 @@ export default {
loading: {
deploy: false,
guestOsCategories: false,
volumes: false,
snapshots: false,
templates: false,
isos: false,
hypervisors: false,
@ -1400,6 +1410,12 @@ export default {
queryArchId () {
return this.$route.query.arch || null
},
querySnapshotId () {
return this.$route.query.snapshotid || null
},
queryVolumeId () {
return this.$route.query.volumeid || null
},
queryTemplateId () {
return this.$route.query.templateid || null
},
@ -1492,6 +1508,9 @@ export default {
return this.$config.showAllCategoryForModernImageSelection
},
guestOsCategoriesSelectionDisallowed () {
if (this.imageType === 'volumeid' || this.imageType === 'snapshotid') {
return true
}
return (!this.queryGuestOsCategoryId || this.options.guestOsCategories.length === 0) && (!!this.queryTemplateId || !!this.queryIsoId)
},
isTemplateHypervisorExternal () {
@ -1955,6 +1974,8 @@ export default {
this.imageType = 'templateid'
this.form.templateid = value
this.form.isoid = null
this.form.volumeid = null
this.form.snapshotid = null
this.resetFromTemplateConfiguration()
let template = ''
for (const entry of Object.values(this.options.templates)) {
@ -1991,6 +2012,8 @@ export default {
this.resetFromTemplateConfiguration()
this.form.isoid = value
this.form.templateid = null
this.form.volumeid = null
this.form.snapshotid = null
let iso = null
for (const entry of Object.values(this.options.isos)) {
iso = entry?.iso.find(option => option.id === value)
@ -2003,6 +2026,10 @@ export default {
this.updateTemplateLinkedUserData(this.iso.userdataid)
this.userdataDefaultOverridePolicy = this.iso.userdatapolicy
}
} else if (name === 'volumeid') {
this.updateFieldValueForVolume(value)
} else if (name === 'snapshotid') {
this.updateFieldValueForSnapshot(value)
} else if (['cpuspeed', 'cpunumber', 'memory'].includes(name)) {
this.vm[name] = value
this.form[name] = value
@ -2010,6 +2037,48 @@ export default {
this.form[name] = value
}
},
updateFieldValueForVolume (value) {
this.imageType = 'volumeid'
this.resetTemplateAssociatedResources()
this.resetFromTemplateConfiguration()
this.form.templateid = null
this.form.isoid = null
this.form.volumeid = value
this.form.snapshotid = null
let volume = null
for (const entry of Object.values(this.options.volumes)) {
volume = entry?.volume.find(option => option.id === value)
if (volume) {
this.volume = volume
break
}
}
if (volume) {
this.updateTemplateLinkedUserData(this.volume.userdataid)
this.userdataDefaultOverridePolicy = this.volume.userdatapolicy
}
},
updateFieldValueForSnapshot (value) {
this.imageType = 'snapshotid'
this.resetTemplateAssociatedResources()
this.resetFromTemplateConfiguration()
this.form.templateid = null
this.form.isoid = null
this.form.volumeid = null
this.form.snapshotid = value
let snapshot = null
for (const entry of Object.values(this.options.snapshots)) {
snapshot = entry?.snapshot.find(option => option.id === value)
if (snapshot) {
this.snapshot = snapshot
break
}
}
if (snapshot) {
this.updateTemplateLinkedUserData(this.snapshot.userdataid)
this.userdataDefaultOverridePolicy = this.snapshot.userdatapolicy
}
},
updateComputeOffering (id) {
this.form.computeofferingid = id
setTimeout(() => {
@ -2171,7 +2240,7 @@ export default {
if (this.loading.deploy) return
this.formRef.value.validate().then(async () => {
const values = toRaw(this.form)
if (!values.templateid && !values.isoid) {
if (!values.templateid && !values.isoid && !values.volumeid && !values.snapshotid) {
this.$notification.error({
message: this.$t('message.request.failed'),
description: this.$t('message.template.iso')
@ -2227,6 +2296,10 @@ export default {
if (this.imageType === 'templateid') {
deployVmData.templateid = values.templateid
values.hypervisor = null
} else if (this.imageType === 'volumeid') {
deployVmData.volumeid = values.volumeid
} else if (this.imageType === 'snapshotid') {
deployVmData.snapshotid = values.snapshotid
} else {
deployVmData.templateid = values.isoid
}
@ -2599,6 +2672,88 @@ export default {
})
})
},
fetchUnattachedVolumes (volumeFilter, params) {
const args = Object.assign({}, params)
if (args.keyword || (args.category && args.category !== volumeFilter)) {
args.page = 1
args.pageSize = args.pageSize || 10
}
args.zoneid = _.get(this.zone, 'id')
if (this.isZoneSelectedMultiArch) {
args.arch = this.selectedArchitecture
}
args.account = store.getters.project?.id ? null : this.owner.account
args.domainid = store.getters.project?.id ? null : this.owner.domainid
args.projectid = store.getters.project?.id || this.owner.projectid
args.id = this.queryVolumeId
args.state = 'Ready'
const pageSize = args.pageSize ? args.pageSize : 10
const pageStart = (args.page ? args.page - 1 : 0) * pageSize
const pageEnd = pageSize * (pageStart + 1)
delete args.category
delete args.public
delete args.featured
delete args.page
delete args.pageSize
return new Promise((resolve, reject) => {
getAPI('listVolumes', args).then((response) => {
let count = 0
const volumes = []
response.listvolumesresponse.volume.forEach(volume => {
if (!volume.virtualmachineid) {
count += 1
volumes.push({ ...volume, displaytext: volume.name })
}
})
resolve({ listvolumesresponse: { count, volume: volumes.slice(pageStart, pageEnd) } })
}).catch((reason) => {
// ToDo: Handle errors
reject(reason)
})
})
},
fetchRootSnapshots (snapshotFilter, params) {
const args = Object.assign({}, params)
if (args.keyword || (args.category && args.category !== snapshotFilter)) {
args.page = 1
args.pageSize = args.pageSize || 10
}
args.zoneid = _.get(this.zone, 'id')
if (this.isZoneSelectedMultiArch) {
args.arch = this.selectedArchitecture
}
args.account = store.getters.project?.id ? null : this.owner.account
args.domainid = store.getters.project?.id ? null : this.owner.domainid
args.projectid = store.getters.project?.id || this.owner.projectid
const pageSize = args.pageSize ? args.pageSize : 10
const pageStart = (args.page ? args.page - 1 : 0) * pageSize
const pageEnd = pageSize * (pageStart + 1)
delete args.category
delete args.public
delete args.featured
delete args.page
delete args.pageSize
return new Promise((resolve, reject) => {
getAPI('listSnapshots', args).then((response) => {
let count = 0
const snapshots = []
response.listsnapshotsresponse.snapshot.forEach(snapshot => {
if (snapshot.volumetype === 'ROOT') {
count += 1
snapshots.push({ ...snapshot, displaytext: snapshot.name })
}
})
resolve({ listsnapshotsresponse: { count, snapshot: snapshots.slice(pageStart, pageEnd) } })
}).catch((reason) => {
// ToDo: Handle errors
reject(reason)
})
})
},
fetchTemplates (templateFilter, params) {
const args = Object.assign({}, params)
if (this.isModernImageSelection && this.form.guestoscategoryid && !['-1', '0'].includes(this.form.guestoscategoryid)) {
@ -2678,6 +2833,14 @@ export default {
this.fetchAllIsos(params)
return
}
if (this.imageType === 'volumeid') {
this.fetchAllVolumes(params)
return
}
if (this.imageType === 'snapshotid') {
this.fetchAllSnapshots(params)
return
}
this.fetchAllTemplates(params)
},
fetchAllTemplates (params) {
@ -2724,6 +2887,50 @@ export default {
this.loading.isos = false
})
},
fetchAllVolumes (params) {
const promises = []
const volumes = {}
this.loading.volumes = true
this.imageSearchFilters = params
const volumeFilters = this.getImageFilters(params)
volumeFilters.forEach((filter) => {
volumes[filter] = { count: 0, volume: [] }
promises.push(this.fetchUnattachedVolumes(filter, params))
})
this.options.volumes = volumes
Promise.all(promises).then((response) => {
response.forEach((resItem, idx) => {
volumes[volumeFilters[idx]] = _.isEmpty(resItem.listvolumesresponse) ? { count: 0, volume: [] } : resItem.listvolumesresponse
this.options.volumes = { ...volumes }
})
}).catch((reason) => {
console.log(reason)
}).finally(() => {
this.loading.volumes = false
})
},
fetchAllSnapshots (params) {
const promises = []
const snapshots = {}
this.loading.snapshots = true
this.imageSearchFilters = params
const snapshotFilters = this.getImageFilters(params)
snapshotFilters.forEach((filter) => {
snapshots[filter] = { count: 0, snapshot: [] }
promises.push(this.fetchRootSnapshots(filter, params))
})
this.options.snapshots = snapshots
Promise.all(promises).then((response) => {
response.forEach((resItem, idx) => {
snapshots[snapshotFilters[idx]] = _.isEmpty(resItem.listsnapshotsresponse) ? { count: 0, snapshot: [] } : resItem.listsnapshotsresponse
this.options.snapshots = { ...snapshots }
})
}).catch((reason) => {
console.log(reason)
}).finally(() => {
this.loading.snapshots = false
})
},
filterOption (input, option) {
return option.label.toUpperCase().indexOf(input.toUpperCase()) >= 0
},
@ -2830,7 +3037,7 @@ export default {
}
},
updateImages () {
if (this.isModernImageSelection) {
if (this.isModernImageSelection && this.imageType !== 'snapshotid' && this.imageType !== 'volumeid') {
this.fetchGuestOsCategories()
return
}

View File

@ -25,6 +25,8 @@
@change="emitChangeImageType()">
<a-radio-button value="templateid">{{ $t('label.template') }}</a-radio-button>
<a-radio-button value="isoid">{{ $t('label.iso') }}</a-radio-button>
<a-radio-button value="volumeid">{{ $t('label.volume') }}</a-radio-button>
<a-radio-button value="snapshotid">{{ $t('label.snapshot') }}</a-radio-button>
</a-radio-group>
<div style="margin-top: 5px; margin-bottom: 5px;">
{{ $t('message.' + localSelectedImageType.replace('id', '') + '.desc') }}