ui: changes in migrate vm storage and migrate volume form (#5145)

Better forms in UI for migrating VMs and volumes.

- Show option to migrate with storage while live migrating a VM
- For VM storage migration (stopped VM), allow migrating volumes to specific primary storages
- Show primary storage details in migrate volume form

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>
This commit is contained in:
Abhishek Kumar 2021-11-30 17:07:48 +05:30 committed by GitHub
parent 14f3b24975
commit 2df82d8188
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1066 additions and 302 deletions

View File

@ -2565,13 +2565,21 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q
sc.setParameters("dataCenterId", zoneId);
}
if (pod != null) {
sc.setParameters("podId", pod);
SearchCriteria<StoragePoolJoinVO> ssc = _poolJoinDao.createSearchCriteria();
ssc.addOr("podId", Op.EQ, pod);
ssc.addOr("podId", Op.NULL);
sc.addAnd("podId", SearchCriteria.Op.SC, ssc);
}
if (address != null) {
sc.setParameters("hostAddress", address);
}
if (cluster != null) {
sc.setParameters("clusterId", cluster);
SearchCriteria<StoragePoolJoinVO> ssc = _poolJoinDao.createSearchCriteria();
ssc.addOr("clusterId", Op.EQ, cluster);
ssc.addOr("clusterId", Op.NULL);
sc.addAnd("clusterId", SearchCriteria.Op.SC, ssc);
}
if (scopeType != null) {
sc.setParameters("scope", scopeType.toString());

View File

@ -470,6 +470,7 @@
"label.asyncbackup": "Async Backup",
"label.author.email": "Author e-mail",
"label.author.name": "Author name",
"label.auto.assign": "Automatically assign",
"label.auto.assign.diskoffering.disk.size": "Automatically assign offering matching the disk size",
"label.auto.assign.random.ip": "Automatically assign a random IP address",
"label.autoscale": "AutoScale",
@ -550,6 +551,7 @@
"label.certificate.upload.failed": "Certificate Upload Failed",
"label.certificate.upload.failed.description": "Failed to update SSL Certificate. Failed to pass certificate validation check",
"label.certificateid": "Certificate ID",
"label.change": "Change",
"label.change.affinity": "Change Affinity",
"label.change.ip.addess": "Change IP Address",
"label.change.ipaddress": "Change IP address for NIC",
@ -818,6 +820,7 @@
"label.disksize": "Disk Size (in GB)",
"label.disksizeallocated": "Disk Allocated",
"label.disksizeallocatedgb": "Allocated",
"label.disksizefree": "Disk Free",
"label.disksizetotal": "Disk Total",
"label.disksizetotalgb": "Total",
"label.disksizeunallocatedgb": "Unallocated",
@ -1425,6 +1428,8 @@
"label.migrate.instance.to": "Migrate instance to",
"label.migrate.instance.to.host": "Migrate instance to another host",
"label.migrate.instance.to.ps": "Migrate instance to another primary storage",
"label.migrate.instance.single.storage": "Migrate all volume(s) of the instance to a single primary storage",
"label.migrate.instance.specific.storages": "Migrate volume(s) of the instance to specific primary storages",
"label.migrate.lb.vm": "Migrate LB VM",
"label.migrate.lb.vm.to.ps": "Migrate LB VM to another primary storage",
"label.migrate.router.to": "Migrate Router to",
@ -1434,6 +1439,7 @@
"label.migrate.volume": "Migrate Volume",
"label.migrate.volume.newdiskoffering.desc": "This option allows administrators to replace the old disk offering, using one that better suits the new placement of the volume.",
"label.migrate.volume.to.primary.storage": "Migrate volume to another primary storage",
"label.migrate.with.storage": "Migrate with storage",
"label.migrating": "Migrating",
"label.migrating.data": "Migrating Data",
"label.min.balance": "Min Balance",
@ -1977,6 +1983,7 @@
"label.select.offering": "Select offering",
"label.select.project": "Select Project",
"label.select.projects": "Select Projects",
"label.select.ps": "Select Primary Storage",
"label.select.region": "Select region",
"label.select.tier": "Select Tier",
"label.select.vm.for.static.nat": "Select VM for static NAT",
@ -3068,17 +3075,20 @@
"message.lock.account": "Please confirm that you want to lock this account. By locking the account, all users for this account will no longer be able to manage their cloud resources. Existing resources can still be accessed.",
"message.login.failed": "Login Failed",
"message.migrate.instance.confirm": "Please confirm the host you wish to migrate the virtual instance to.",
"message.migrate.instance.host.auto.assign": "Host for the instance will be automatically chosen based on the suitability within the same cluster",
"message.migrate.instance.select.host": "Please select a host for migration",
"message.migrate.instance.to.host": "Please confirm that you want to migrate instance to another host.",
"message.migrate.instance.to.ps": "Please confirm that you want to migrate instance to another primary storage.",
"message.migrate.instance.to.host": "Please confirm that you want to migrate this instance to another host. When migration is between hosts of different clusters volume(s) of the instance may get migrated to suitable storage pools.",
"message.migrate.instance.to.ps": "Please confirm that you want to migrate this instance to another primary storage.",
"message.migrate.lb.vm.to.ps": "Please confirm that you want to migrate LB VM to another primary storage.",
"message.migrate.router.confirm": "Please confirm the host you wish to migrate the router to:",
"message.migrate.router.to.ps": "Please confirm that you want to migrate router to another primary storage.",
"message.migrate.system.vm.to.ps": "Please confirm that you want to migrate system VM to another primary storage.",
"message.migrate.systemvm.confirm": "Please confirm the host you wish to migrate the system VM to:",
"message.migrate.volume": "Please confirm that you want to migrate volume to another primary storage.",
"message.migrate.volume": "Please confirm that you want to migrate this volume to another primary storage.",
"message.migrate.volume.failed": "Migrating volume failed",
"message.migrate.volume.pool.auto.assign": "Primary storage for the volume will be automatically chosen based on the suitability and VM destination",
"message.migrate.volume.processing": "Migrating volume...",
"message.migrate.with.storage": "Specify storage pool for volumes of the instance.",
"message.migrating.failed": "Migration failed",
"message.migrating.processing": "Migration in progress for",
"message.migrating.vm.to.host.failed": "Failed to migrate VM to host",
@ -3144,6 +3154,7 @@
"message.pod.dedicated": "Pod Dedicated",
"message.pod.dedication.released": "Pod dedication released",
"message.portable.ip.delete.confirm": "Please confirm you want to delete Portable IP Range",
"message.primary.storage.invalid.state": "Primary storage is not in Up state",
"message.processing.complete": "Processing complete!",
"message.project.invite.sent": "Invite sent to user; they will be added to the project once they accept the invitation",
"message.protocol.description": "For XenServer, choose NFS, iSCSI, or PreSetup. For KVM, choose NFS, SharedMountPoint, RDB, CLVM or Gluster. For vSphere, choose NFS, PreSetup (VMFS or iSCSI or FiberChannel or vSAN or vVols) or DatastoreCluster. For Hyper-V, choose SMB/CIFS. For LXC, choose NFS or SharedMountPoint. For OVM, choose NFS or ocfs2.",
@ -3419,6 +3430,7 @@
"message.volume.state.uploaderror": "Volume upload encountered some error",
"message.volume.state.uploadinprogress": "Volume upload is in progress",
"message.volume.state.uploadop": "The volume upload operation is in progress or in short the volume is on secondary storage",
"message.volume.state.primary.storage.suitability": "The suitability of a primary storage for a volume depends on the disk offering of the volume and on the virtual machine allocations if the volume is attached to a virtual machine.",
"message.waiting.for.builtin.templates.to.load": "Waiting for builtin templates to load...",
"message.warn.filetype": "jpg, jpeg, png, bmp and svg are the only supported image formats",
"message.xstools61plus.update.failed": "Failed to update Original XS Version is 6.1+ field. Error:",

View File

@ -0,0 +1,248 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
<template>
<div>
<a-table
class="top-spaced"
size="small"
style="max-height: 250px; overflow-y: auto"
:loading="volumesLoading"
:columns="volumeColumns"
:dataSource="volumes"
:pagination="false"
:rowKey="record => record.id">
<div slot="size" slot-scope="record">
<span v-if="record.size">
{{ $bytesToHumanReadableSize(record.size) }}
</span>
</div>
<template slot="selectedstorage" slot-scope="record">
<span>{{ record.selectedstoragename || '' }}</span>
</template>
<template slot="select" slot-scope="record">
<div style="display: flex; justify-content: flex-end;"><a-button @click="openVolumeStoragePoolSelector(record)">{{ record.selectedstorageid ? $t('label.change') : $t('label.select') }}</a-button></div>
</template>
</a-table>
<a-modal
:visible="!(!selectedVolumeForStoragePoolSelection.id)"
:title="$t('label.select.ps')"
:closable="true"
:maskClosable="false"
:footer="null"
:cancelText="$t('label.cancel')"
@cancel="closeVolumeStoragePoolSelector()"
centered
width="auto">
<volume-storage-pool-select-form
:resource="selectedVolumeForStoragePoolSelection"
:clusterId="storagePoolsClusterId"
:autoAssignAllowed="storagePoolsClusterId != null"
:isOpen="!(!selectedVolumeForStoragePoolSelection.id)"
@close-action="closeVolumeStoragePoolSelector()"
@select="handleVolumeStoragePoolSelection" />
</a-modal>
</div>
</template>
<script>
import { api } from '@/api'
import VolumeStoragePoolSelectForm from '@/components/view/VolumeStoragePoolSelectForm'
export default {
name: 'InstanceVolumesStoragePoolSelectListView',
components: {
VolumeStoragePoolSelectForm
},
props: {
resource: {
type: Object,
required: true
},
clusterId: {
type: String,
required: false,
default: null
}
},
data () {
return {
volumes: [],
volumesLoading: false,
volumeColumns: [
{
title: this.$t('label.volumeid'),
dataIndex: 'name'
},
{
title: this.$t('label.type'),
dataIndex: 'type'
},
{
title: this.$t('label.size'),
scopedSlots: { customRender: 'size' }
},
{
title: this.$t('label.storage'),
scopedSlots: { customRender: 'selectedstorage' }
},
{
title: '',
scopedSlots: { customRender: 'select' }
}
],
selectedVolumeForStoragePoolSelection: {},
selectedClusterId: null,
volumesWithClusterStoragePool: []
}
},
beforeCreate () {
this.form = this.$form.createForm(this)
this.apiParams = {}
if (this.$route.meta.name === 'vm') {
this.apiConfig = this.$store.getters.apis.migrateVirtualMachineWithVolume || {}
this.apiConfig.params.forEach(param => {
this.apiParams[param.name] = param
})
this.apiConfig = this.$store.getters.apis.migrateVirtualMachine || {}
this.apiConfig.params.forEach(param => {
if (!(param.name in this.apiParams)) {
this.apiParams[param.name] = param
}
})
} else {
this.apiConfig = this.$store.getters.apis.migrateSystemVm || {}
this.apiConfig.params.forEach(param => {
if (!(param.name in this.apiParams)) {
this.apiParams[param.name] = param
}
})
}
},
created () {
this.fetchVolumes()
},
computed: {
isSelectedVolumeOnlyClusterStoragePoolVolume () {
if (this.volumesWithClusterStoragePool.length !== 1) {
return false
}
for (const volume of this.volumesWithClusterStoragePool) {
if (volume.id === this.selectedVolumeForStoragePoolSelection.id) {
return true
}
}
return false
},
storagePoolsClusterId () {
if (this.clusterId) {
return this.clusterId
}
return this.isSelectedVolumeOnlyClusterStoragePoolVolume ? null : this.selectedClusterId
}
},
methods: {
fetchVolumes () {
this.volumesLoading = true
this.volumes = []
api('listVolumes', {
listAll: true,
virtualmachineid: this.resource.id
}).then(response => {
var volumes = response.listvolumesresponse.volume
if (volumes && volumes.length > 0) {
volumes.sort((a, b) => {
return b.type.localeCompare(a.type)
})
this.volumes = volumes
}
}).finally(() => {
this.resetSelection()
this.volumesLoading = false
})
},
resetSelection () {
var volumes = this.volumes
this.volumes = []
for (var volume of volumes) {
if (this.clusterId) {
volume.selectedstorageid = -1
volume.selectedstoragename = this.$t('label.auto.assign')
} else {
volume.selectedstorageid = null
volume.selectedstoragename = ''
}
delete volume.selectedstorageclusterid
}
this.volumes = volumes
this.updateVolumeToStoragePoolSelection()
},
openVolumeStoragePoolSelector (volume) {
this.selectedVolumeForStoragePoolSelection = volume
},
closeVolumeStoragePoolSelector () {
this.selectedVolumeForStoragePoolSelection = {}
},
handleVolumeStoragePoolSelection (volumeId, storagePool) {
for (const volume of this.volumes) {
if (volume.id === volumeId) {
volume.selectedstorageid = storagePool.id
volume.selectedstoragename = storagePool.name
volume.selectedstorageclusterid = storagePool.clusterid
break
}
}
this.updateVolumeToStoragePoolSelection()
},
updateVolumeToStoragePoolSelection () {
var clusterId = null
this.volumeToPoolSelection = []
this.volumesWithClusterStoragePool = []
for (const volume of this.volumes) {
if (volume.selectedstorageid && volume.selectedstorageid !== -1) {
this.volumeToPoolSelection.push({ volume: volume.id, pool: volume.selectedstorageid })
}
if (!this.clusterId && volume.selectedstorageclusterid) {
clusterId = volume.selectedstorageclusterid
this.volumesWithClusterStoragePool.push(volume)
}
}
if (!this.clusterId) {
this.selectedClusterId = clusterId
for (const volume of this.volumes) {
if (this.selectedClusterId == null && volume.selectedstorageid === -1) {
volume.selectedstorageid = null
volume.selectedstoragename = ''
}
if (this.selectedClusterId && volume.selectedstorageid == null) {
volume.selectedstorageid = -1
volume.selectedstoragename = this.$t('label.auto.assign')
}
}
}
this.$emit('select', this.volumeToPoolSelection)
}
}
}
</script>
<style scoped lang="less">
.top-spaced {
margin-top: 20px;
}
</style>

View File

@ -0,0 +1,274 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
<template>
<div>
<a-input-search
class="top-spaced"
:placeholder="$t('label.search')"
v-model="searchQuery"
style="margin-bottom: 10px;"
@search="fetchStoragePools"
autoFocus />
<a-table
size="small"
style="overflow-y: auto"
:loading="loading"
:columns="columns"
:dataSource="storagePools"
:pagination="false"
:rowKey="record => record.id">
<span slot="suitabilityCustomTitle">
{{ $t('label.suitability') }}
<a-tooltip :title="$t('message.volume.state.primary.storage.suitability')" placement="top">
<a-icon type="info-circle" class="table-tooltip-icon" />
</a-tooltip>
</span>
<div slot="name" slot-scope="record">
{{ record.name }}
<a-tooltip v-if="record.name === $t('label.auto.assign')" :title="$t('message.migrate.volume.pool.auto.assign')" placement="top">
<a-icon type="info-circle" class="table-tooltip-icon" />
</a-tooltip>
</div>
<div slot="suitability" slot-scope="record">
<a-icon
class="host-item__suitability-icon"
type="check-circle"
theme="twoTone"
twoToneColor="#52c41a"
v-if="record.suitableformigration" />
<a-icon
class="host-item__suitability-icon"
type="close-circle"
theme="twoTone"
twoToneColor="#f5222d"
v-else />
</div>
<div slot="disksizetotal" slot-scope="record">
<span v-if="record.disksizetotal">{{ $bytesToHumanReadableSize(record.disksizetotal) }}</span>
</div>
<div slot="disksizeused" slot-scope="record">
<span v-if="record.disksizeused">{{ $bytesToHumanReadableSize(record.disksizeused) }}</span>
</div>
<div slot="disksizefree" slot-scope="record">
<span v-if="record.disksizetotal && record.disksizeused">{{ $bytesToHumanReadableSize(record.disksizetotal * 1 - record.disksizeused * 1) }}</span>
</div>
<template slot="select" slot-scope="record">
<a-tooltip placement="top" :title="record.state !== 'Up' ? $t('message.primary.storage.invalid.state') : ''">
<a-radio
:disabled="record.id !== -1 && record.state !== 'Up'"
@click="updateSelection(record)"
:checked="selectedStoragePool != null && record.id === selectedStoragePool.id">
</a-radio>
</a-tooltip>
</template>
</a-table>
<a-pagination
class="top-spaced"
size="small"
:current="page"
:pageSize="pageSize"
:total="totalCount"
:showTotal="total => `${$t('label.total')} ${total} ${$t('label.items')}`"
:pageSizeOptions="['10', '20', '40', '80', '100']"
@change="handleChangePage"
@showSizeChange="handleChangePageSize"
showSizeChanger>
<template slot="buildOptionText" slot-scope="props">
<span>{{ props.value }} / {{ $t('label.page') }}</span>
</template>
</a-pagination>
</div>
</template>
<script>
import { api } from '@/api'
export default {
name: 'VolumeStoragePoolSelector',
props: {
resource: {
type: Object,
required: true
},
clusterId: {
type: String,
required: false,
default: null
},
suitabilityEnabled: {
type: Boolean,
required: false,
default: false
},
autoAssignAllowed: {
type: Boolean,
required: false,
default: false
},
isOpen: {
type: Boolean,
required: false
}
},
data () {
return {
loading: false,
storagePools: [],
searchQuery: '',
totalCount: 0,
page: 1,
pageSize: 10,
selectedStoragePool: null,
columns: [
{
title: this.$t('label.storageid'),
scopedSlots: { customRender: 'name' }
},
{
title: this.$t('label.clusterid'),
dataIndex: 'clustername'
},
{
title: this.$t('label.podid'),
dataIndex: 'podname'
},
{
title: this.$t('label.disksizetotal'),
scopedSlots: { customRender: 'disksizetotal' }
},
{
title: this.$t('label.disksizeused'),
scopedSlots: { customRender: 'disksizeused' }
},
{
title: this.$t('label.disksizefree'),
scopedSlots: { customRender: 'disksizefree' }
},
{
title: this.$t('label.select'),
scopedSlots: { customRender: 'select' }
}
]
}
},
created () {
if (this.suitabilityEnabled) {
this.columns.splice(1, 0, { slots: { title: 'suitabilityCustomTitle' }, scopedSlots: { customRender: 'suitability' } }
)
}
this.preselectStoragePool()
this.fetchStoragePools()
},
watch: {
searchQuery (newValue, oldValue) {
if (newValue !== oldValue) {
this.page = 1
}
}
},
methods: {
fetchStoragePools () {
this.loading = true
if (this.suitabilityEnabled) {
api('findStoragePoolsForMigration', {
id: this.resource.id,
keyword: this.searchQuery,
page: this.page,
pagesize: this.pageSize
}).then(response => {
this.storagePools = response.findstoragepoolsformigrationresponse.storagepool || []
this.totalCount = response.findstoragepoolsformigrationresponse.count
}).catch(error => {
this.$notifyError(error)
}).finally(() => {
this.handleStoragePoolsFetchComplete()
})
} else {
var params = {
zoneid: this.resource.zoneid,
keyword: this.searchQuery,
page: this.page,
pagesize: this.pageSize
}
if (this.clusterId) {
params.clusterid = this.clusterId
}
api('listStoragePools', params).then(response => {
this.storagePools = response.liststoragepoolsresponse.storagepool || []
this.totalCount = response.liststoragepoolsresponse.count
}).catch(error => {
this.$notifyError(error)
}).finally(() => {
this.handleStoragePoolsFetchComplete()
})
}
},
handleStoragePoolsFetchComplete () {
this.$emit('storagePoolsUpdated', this.storagePools)
this.addAutoAssignOption()
this.loading = false
},
addAutoAssignOption () {
if (this.autoAssignAllowed && this.page === 1) {
this.storagePools.unshift({ id: -1, name: this.$t('label.auto.assign'), clustername: '', podname: '' })
}
},
handleChangePage (page, pageSize) {
this.page = page
this.pageSize = pageSize
this.fetchStoragePools()
},
handleChangePageSize (currentPage, pageSize) {
this.page = currentPage
this.pageSize = pageSize
this.fetchStoragePools()
},
preselectStoragePool () {
if (this.resource && 'selectedstorageid' in this.resource) {
this.selectedStoragePool = { id: this.resource.selectedstorageid }
}
},
clearView () {
this.storagePools = []
this.searchQuery = ''
this.totalCount = 0
this.page = 1
this.pageSize = 10
this.selectedStoragePool = null
},
reset () {
this.clearView()
this.preselectStoragePool()
this.fetchStoragePools()
},
updateSelection (storagePool) {
this.selectedStoragePool = storagePool
this.$emit('select', this.selectedStoragePool)
}
}
}
</script>
<style scoped lang="scss">
.top-spaced {
margin-top: 20px;
}
.table-tooltip-icon {
color: rgba(0,0,0,.45);
}
</style>

View File

@ -0,0 +1,129 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
<template>
<div class="form" v-ctrl-enter="handleKeyboardSubmit">
<storage-pool-select-view
ref="selectionView"
:resource="resource"
:clusterId="clusterId"
:suitabilityEnabled="suitabilityEnabled"
:autoAssignAllowed="autoAssignAllowed"
@select="handleSelect" />
<a-divider />
<div class="actions">
<a-button @click="closeModal">{{ $t('label.cancel') }}</a-button>
<a-button type="primary" ref="submit" :disabled="!selectedStoragePool" @click="submitForm">{{ $t('label.ok') }}</a-button>
</div>
</div>
</template>
<script>
import StoragePoolSelectView from '@/components/view/StoragePoolSelectView'
export default {
name: 'VolumeStoragePoolSelectionForm',
components: {
StoragePoolSelectView
},
props: {
resource: {
type: Object,
required: true
},
clusterId: {
type: String,
required: false,
default: null
},
suitabilityEnabled: {
type: Boolean,
required: false,
default: false
},
autoAssignAllowed: {
type: Boolean,
required: false,
default: false
},
isOpen: {
type: Boolean,
required: false
}
},
data () {
return {
selectedStoragePool: null
}
},
watch: {
isOpen (newValue) {
if (newValue) {
setTimeout(() => {
this.$refs.selectionView.reset()
}, 50)
}
}
},
methods: {
handleSelect (storagePool) {
this.selectedStoragePool = storagePool
},
closeModal () {
this.$emit('close-action')
},
handleKeyboardSubmit () {
if (this.selectedStoragePool != null) {
this.submitForm()
}
},
submitForm () {
this.$emit('select', this.resource.id, this.selectedStoragePool)
this.closeModal()
}
}
}
</script>
<style scoped lang="scss">
.form {
width: 80vw;
@media (min-width: 900px) {
width: 850px;
}
}
.top-spaced {
margin-top: 20px;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
button {
&:not(:last-child) {
margin-right: 10px;
}
}
}
</style>

View File

@ -26,7 +26,7 @@ import './core/lazy_use'
import './core/ext'
import './permission' // permission control
import './utils/filter' // global filter
import { pollJobPlugin, notifierPlugin, toLocaleDatePlugin, configUtilPlugin, apiMetaUtilPlugin, showIconPlugin, resourceTypePlugin } from './utils/plugins'
import { pollJobPlugin, notifierPlugin, toLocaleDatePlugin, configUtilPlugin, apiMetaUtilPlugin, showIconPlugin, resourceTypePlugin, fileSizeUtilPlugin } from './utils/plugins'
import { VueAxios } from './utils/request'
import './utils/directives'
@ -60,3 +60,4 @@ fetch('config.json').then(response => response.json()).then(config => {
Vue.use(configUtilPlugin)
Vue.use(apiMetaUtilPlugin)
Vue.use(fileSizeUtilPlugin)

View File

@ -299,3 +299,30 @@ export const apiMetaUtilPlugin = {
}
}
}
const KB = 1024
const MB = 1024 * KB
const GB = 1024 * MB
const TB = 1024 * GB
export const fileSizeUtilPlugin = {
install (Vue) {
Vue.prototype.$bytesToHumanReadableSize = function (bytes) {
if (bytes == null) {
return ''
}
if (bytes < KB && bytes >= 0) {
return bytes + ' bytes'
}
if (bytes < MB) {
return (bytes / KB).toFixed(2) + ' KB'
} else if (bytes < GB) {
return (bytes / MB).toFixed(2) + ' MB'
} else if (bytes < TB) {
return (bytes / GB).toFixed(2) + ' GB'
} else {
return (bytes / TB).toFixed(2) + ' TB'
}
}
}
}

View File

@ -16,47 +16,53 @@
// under the License.
<template>
<div class="form-layout">
<a-spin :spinning="loading">
<a-form
:form="form"
@submit="handleSubmit"
layout="vertical">
<a-form-item>
<tooltip-label slot="label" :title="$t('label.storageid')" :tooltip="apiParams.storageid ? apiParams.storageid.description : ''"/>
<a-select
:loading="loading"
v-decorator="['storageid', {
rules: [{ required: true, message: `${this.$t('message.error.required.input')}` }]
}]"
showSearch
optionFilterProp="children"
:filterOption="(input, option) => {
return option.componentOptions.children[0].text.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
<a-select-option v-for="storagePool in storagePools" :key="storagePool.id">
{{ storagePool.name || storagePool.id }}
</a-select-option>
</a-select>
</a-form-item>
<div class="form-layout" v-ctrl-enter="handleKeyboardSubmit">
<a-alert type="warning">
<span slot="message" v-html="$t('message.migrate.instance.to.ps')" />
</a-alert>
<a-radio-group
v-if="migrateVmWithVolumeAllowed"
:defaultValue="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="migrateMode == 1">
<storage-pool-select-view
ref="storagePoolSelection"
:resource="resource"
@select="handleStoragePoolChange" />
</div>
<instance-volumes-storage-pool-select-list-view
v-else
:resource="resource"
@select="handleVolumeToPoolChange" />
<div :span="24" class="action-button">
<a-button @click="closeAction">{{ this.$t('label.cancel') }}</a-button>
<a-button :loading="loading" type="primary" @click="handleSubmit">{{ this.$t('label.ok') }}</a-button>
</div>
</a-form>
</a-spin>
<a-divider />
<div class="actions">
<a-button @click="closeModal">{{ $t('label.cancel') }}</a-button>
<a-button type="primary" :disabled="!formSubmitAllowed" @click="submitForm">{{ $t('label.ok') }}</a-button>
</div>
</div>
</template>
<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: 'MigrateVMStorage',
components: {
TooltipLabel
TooltipLabel,
StoragePoolSelectView,
InstanceVolumesStoragePoolSelectListView
},
props: {
resource: {
@ -66,50 +72,44 @@ export default {
},
data () {
return {
loading: false,
storagePools: []
migrateMode: 1,
selectedPool: {},
volumeToPoolSelection: []
}
},
beforeCreate () {
this.form = this.$form.createForm(this)
this.apiParams = {}
if (this.$route.meta.name === 'vm') {
this.apiConfig = this.$store.getters.apis.migrateVirtualMachineWithVolume || {}
this.apiConfig.params.forEach(param => {
this.apiParams[param.name] = param
})
this.apiConfig = this.$store.getters.apis.migrateVirtualMachine || {}
this.apiConfig.params.forEach(param => {
if (!(param.name in this.apiParams)) {
this.apiParams[param.name] = param
this.migrateVmWithVolumeApiParams = this.$getApiParams('migrateVirtualMachineWithVolume')
},
computed: {
migrateVmWithVolumeAllowed () {
return this.$route.meta.name === 'vm' && this.migrateVmWithVolumeApiParams.hostid && this.migrateVmWithVolumeApiParams.hostid.required === false
},
formSubmitAllowed () {
return this.migrateMode === 2 ? this.volumeToPoolSelection.length > 0 : this.selectedPool.id
},
isSelectedVolumeOnlyClusterStoragePoolVolume () {
if (this.volumesWithClusterStoragePool.length !== 1) {
return false
}
for (const volume of this.volumesWithClusterStoragePool) {
if (volume.id === this.selectedVolumeForStoragePoolSelection.id) {
return true
}
})
} else {
this.apiConfig = this.$store.getters.apis.migrateSystemVm || {}
this.apiConfig.params.forEach(param => {
if (!(param.name in this.apiParams)) {
this.apiParams[param.name] = param
}
})
}
return false
}
},
created () {
},
mounted () {
this.fetchData()
},
methods: {
fetchData () {
this.loading = true
api('listStoragePools', {
zoneid: this.resource.zoneid
}).then(response => {
if (this.arrayHasItems(response.liststoragepoolsresponse.storagepool)) {
this.storagePools = response.liststoragepoolsresponse.storagepool
}
}).finally(() => {
this.loading = false
})
if (this.migrateMode === 2) {
this.fetchVolumes()
}
},
handleMigrateModeChange (value) {
this.migrateMode = value
this.selectedPool = {}
this.volumeToPoolSelection = []
},
isValidValueForKey (obj, key) {
return key in obj && obj[key] != null
@ -120,89 +120,70 @@ export default {
isObjectEmpty (obj) {
return !(obj !== null && obj !== undefined && Object.keys(obj).length > 0 && obj.constructor === Object)
},
handleSubmit (e) {
e.preventDefault()
this.form.validateFieldsAndScroll((err, values) => {
if (err) {
return
}
this.loading = true
var isUserVm = true
if (this.$route.meta.name !== 'vm') {
isUserVm = false
}
var migrateApi = isUserVm ? 'migrateVirtualMachine' : 'migrateSystemVm'
if (isUserVm && this.apiParams.hostid && this.apiParams.hostid.required === false) {
migrateApi = 'migrateVirtualMachineWithVolume'
var rootVolume = null
api('listVolumes', {
listAll: true,
virtualmachineid: this.resource.id
}).then(response => {
var volumes = response.listvolumesresponse.volume
if (volumes && volumes.length > 0) {
volumes = volumes.filter(item => item.type === 'ROOT')
if (volumes && volumes.length > 0) {
rootVolume = volumes[0]
}
if (rootVolume == null) {
this.$message.error('Failed to find ROOT volume for the VM ' + this.resource.id)
this.closeAction()
}
this.migrateVm(migrateApi, values.storageid, rootVolume.id)
}
})
return
}
this.migrateVm(migrateApi, values.storageid, null)
})
handleStoragePoolChange (storagePool) {
this.selectedPool = storagePool
},
migrateVm (migrateApi, storageId, rootVolumeId) {
var params = {
virtualmachineid: this.resource.id,
storageid: storageId
handleVolumeToPoolChange (volumeToPool) {
this.volumeToPoolSelection = volumeToPool
},
handleKeyboardSubmit () {
if (this.formSubmitAllowed) {
this.submitForm()
}
if (rootVolumeId !== null) {
params = {
virtualmachineid: this.resource.id,
'migrateto[0].volume': rootVolumeId,
'migrateto[0].pool': storageId
},
submitForm () {
var isUserVm = true
if (this.$route.meta.name !== 'vm') {
isUserVm = false
}
var migrateApi = isUserVm ? 'migrateVirtualMachine' : 'migrateSystemVm'
if (isUserVm && this.migrateMode === 2) {
migrateApi = 'migrateVirtualMachineWithVolume'
this.migrateVm(migrateApi, null, this.volumeToPoolSelection)
return
}
this.migrateVm(migrateApi, this.selectedPool.id, null)
},
migrateVm (migrateApi, storageId, volumeToPool) {
var params = {
virtualmachineid: this.resource.id
}
if (this.migrateMode === 2) {
for (var i = 0; i < volumeToPool.length; i++) {
const mapping = volumeToPool[i]
params['migrateto[' + i + '].volume'] = mapping.volume
params['migrateto[' + i + '].pool'] = mapping.pool
}
} else {
params.storageid = storageId
}
api(migrateApi, params).then(response => {
var jobId = ''
if (migrateApi === 'migrateVirtualMachineWithVolume') {
jobId = response.migratevirtualmachinewithvolumeresponse.jobid
} else if (migrateApi === 'migrateSystemVm') {
jobId = response.migratesystemvmresponse.jobid
} else {
jobId = response.migratevirtualmachineresponse.jobid
}
const jobId = response[migrateApi.toLowerCase() + 'response'].jobid
this.$pollJob({
title: `${this.$t('label.migrating')} ${this.resource.name}`,
description: this.resource.name,
jobId: jobId,
successMessage: `${this.$t('message.success.migrating')} ${this.resource.name}`,
successMethod: () => {
this.$parent.$parent.close()
this.closeModal()
},
errorMessage: this.$t('message.migrating.failed'),
errorMethod: () => {
this.$parent.$parent.close()
this.closeModal()
},
loadingMessage: `${this.$t('message.migrating.processing')} ${this.resource.name}`,
catchMessage: this.$t('error.fetching.async.job.result'),
catchMethod: () => {
this.$parent.$parent.close()
this.closeModal()
}
})
this.$parent.$parent.close()
this.closeModal()
}).catch(error => {
console.error(error)
this.$message.error(`${this.$t('message.migrating.vm.to.storage.failed')} ${storageId}`)
})
},
closeAction () {
closeModal () {
this.$emit('close-action')
}
}
@ -211,18 +192,33 @@ export default {
<style scoped lang="less">
.form-layout {
width: 60vw;
width: 80vw;
@media (min-width: 500px) {
width: 450px;
@media (min-width: 900px) {
width: 850px;
}
}
.action-button {
text-align: right;
.top-spaced {
margin-top: 20px;
}
.radio-style {
display: block;
margin-left: 10px;
height: 40px;
line-height: 40px;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
button {
margin-right: 5px;
&:not(:last-child) {
margin-right: 10px;
}
}
}
</style>

View File

@ -16,14 +16,18 @@
// under the License.
<template>
<div class="form" v-ctrl-enter="submitForm">
<div class="form" v-ctrl-enter="handleKeyboardSubmit">
<a-alert type="warning">
<span slot="message" v-html="$t('message.migrate.instance.to.host')" />
</a-alert>
<a-input-search
class="top-spaced"
:placeholder="$t('label.search')"
v-model="searchQuery"
style="margin-bottom: 10px;"
@search="fetchData"
autoFocus />
<a-table
class="top-spaced"
size="small"
style="overflow-y: auto"
:loading="loading"
@ -31,6 +35,12 @@
:dataSource="hosts"
:pagination="false"
:rowKey="record => record.id">
<div slot="name" slot-scope="record">
{{ record.name }}
<a-tooltip v-if="record.name === $t('label.auto.assign')" :title="$t('message.migrate.instance.host.auto.assign')" placement="top">
<a-icon type="info-circle" class="table-tooltip-icon" />
</a-tooltip>
</div>
<div slot="suitability" slot-scope="record">
<a-icon
class="host-item__suitability-icon"
@ -47,7 +57,7 @@
</div>
<div slot="memused" slot-scope="record">
<span v-if="record.memoryused">
{{ record.memoryused | byteToGigabyte }} GB
{{ $bytesToHumanReadableSize(record.memoryused) }}
</span>
</div>
<div slot="memoryallocatedpercentage" slot-scope="record">
@ -65,7 +75,7 @@
<template slot="select" slot-scope="record">
<a-radio
class="host-item__radio"
@click="selectedHost = record"
@click="handleSelectedHostChange(record)"
:checked="record.id === selectedHost.id"
:disabled="!record.suitableformigration"></a-radio>
</template>
@ -86,10 +96,27 @@
</template>
</a-pagination>
<div style="margin-top: 20px; display: flex; justify-content:flex-end;">
<a-button type="primary" ref="submit" :disabled="!selectedHost.id" @click="submitForm">
{{ $t('label.ok') }}
</a-button>
<a-form-item
v-if="isUserVm"
class="top-spaced">
<tooltip-label slot="label" :title="$t('label.migrate.with.storage')" :tooltip="$t('message.migrate.with.storage')"/>
<a-switch
v-model="migrateWithStorage"
:disabled="!selectedHost || !selectedHost.id || selectedHost.id === -1" />
</a-form-item>
<instance-volumes-storage-pool-select-list-view
ref="volumeToPoolSelect"
v-if="migrateWithStorage"
class="top-spaced"
:resource="resource"
:clusterId="selectedHost.id ? selectedHost.clusterid : null"
@select="handleVolumeToPoolChange" />
<a-divider />
<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>
</div>
</div>
@ -97,9 +124,15 @@
<script>
import { api } from '@/api'
import TooltipLabel from '@/components/widgets/TooltipLabel'
import InstanceVolumesStoragePoolSelectListView from '@/components/view/InstanceVolumesStoragePoolSelectListView'
export default {
name: 'VMMigrateWizard',
components: {
TooltipLabel,
InstanceVolumesStoragePoolSelectListView
},
props: {
resource: {
type: Object,
@ -117,8 +150,8 @@ export default {
pageSize: 10,
columns: [
{
title: this.$t('label.name'),
dataIndex: 'name'
title: this.$t('label.hostid'),
scopedSlots: { customRender: 'name' }
},
{
title: this.$t('label.suitability'),
@ -152,13 +185,30 @@ export default {
title: this.$t('label.select'),
scopedSlots: { customRender: 'select' }
}
]
],
migrateWithStorage: false,
volumeToPoolSelection: []
}
},
created () {
this.fetchData()
},
computed: {
isUserVm () {
return this.$route.meta.name === 'vm'
}
},
watch: {
searchQuery (newValue, oldValue) {
if (newValue !== oldValue) {
this.page = 1
}
}
},
methods: {
arrayHasItems (array) {
return array !== null && array !== undefined && Array.isArray(array) && array.length > 0
},
fetchData () {
this.loading = true
api('findHostsForMigration', {
@ -173,7 +223,7 @@ export default {
})
for (const key in this.hosts) {
if (this.hosts[key].suitableformigration && !this.hosts[key].requiresstoragemigration) {
this.hosts.unshift({ id: -1, name: this.$t('label.migrate.auto.select'), suitableformigration: true, requiresstoragemigration: false })
this.hosts.unshift({ id: -1, name: this.$t('label.auto.assign'), suitableformigration: true, requiresstoragemigration: false })
break
}
}
@ -184,51 +234,6 @@ export default {
this.loading = false
})
},
submitForm () {
if (this.loading) return
this.loading = true
var isUserVm = true
if (this.$route.meta.name !== 'vm') {
isUserVm = false
}
var migrateApi = isUserVm
? this.selectedHost.requiresStorageMotion ? 'migrateVirtualMachineWithVolume' : 'migrateVirtualMachine'
: 'migrateSystemVm'
var migrateParams = this.selectedHost.id === -1 ? { autoselect: true, virtualmachineid: this.resource.id }
: { hostid: this.selectedHost.id, virtualmachineid: this.resource.id }
api(migrateApi, migrateParams).then(response => {
const jobid = isUserVm
? this.selectedHost.requiresStorageMotion ? response.migratevirtualmachinewithvolumeresponse.jobid : response.migratevirtualmachineresponse.jobid
: response.migratesystemvmresponse.jobid
this.$pollJob({
jobId: jobid,
title: `${this.$t('label.migrating')} ${this.resource.name}`,
description: this.resource.name,
successMessage: `${this.$t('message.success.migrating')} ${this.resource.name}`,
successMethod: () => {
this.$emit('close-action')
},
errorMessage: this.$t('message.migrating.failed'),
errorMethod: () => {
this.$emit('close-action')
},
loadingMessage: `${this.$t('message.migrating.processing')} ${this.resource.name}`,
catchMessage: this.$t('error.fetching.async.job.result'),
catchMethod: () => {
this.$emit('close-action')
}
})
this.$emit('close-action')
}).catch(error => {
this.$notification.error({
message: this.$t('message.request.failed'),
description: (error.response && error.response.headers && error.response.headers['x-description']) || error.message,
duration: 0
})
}).finally(() => {
this.loading = false
})
},
handleChangePage (page, pageSize) {
this.page = page
this.pageSize = pageSize
@ -238,11 +243,77 @@ export default {
this.page = currentPage
this.pageSize = pageSize
this.fetchData()
}
},
filters: {
byteToGigabyte: value => {
return (value / Math.pow(10, 9)).toFixed(2)
},
handleSelectedHostChange (host) {
if (host.id === -1) {
this.migrateWithStorage = false
}
this.selectedHost = host
this.selectedVolumeForStoragePoolSelection = {}
this.volumeToPoolSelection = []
if (this.migrateWithStorage) {
this.$refs.volumeToPoolSelect.resetSelection()
}
},
handleVolumeToPoolChange (volumeToPool) {
this.volumeToPoolSelection = volumeToPool
},
handleKeyboardSubmit () {
if (this.selectedHost.id) {
this.submitForm()
}
},
closeModal () {
this.$emit('close-action')
},
submitForm () {
if (this.loading) return
this.loading = true
const migrateApi = this.isUserVm
? (this.selectedHost.requiresStorageMotion || this.volumeToPoolSelection.length > 0)
? 'migrateVirtualMachineWithVolume'
: 'migrateVirtualMachine'
: 'migrateSystemVm'
var params = this.selectedHost.id === -1
? { autoselect: true, virtualmachineid: this.resource.id }
: { hostid: this.selectedHost.id, virtualmachineid: this.resource.id }
if (this.migrateWithStorage) {
for (var i = 0; i < this.volumeToPoolSelection.length; i++) {
const mapping = this.volumeToPoolSelection[i]
params['migrateto[' + i + '].volume'] = mapping.volume
params['migrateto[' + i + '].pool'] = mapping.pool
}
}
api(migrateApi, params).then(response => {
const jobId = response[migrateApi.toLowerCase() + 'response'].jobid
this.$pollJob({
jobId: jobId,
title: `${this.$t('label.migrating')} ${this.resource.name}`,
description: this.resource.name,
successMessage: `${this.$t('message.success.migrating')} ${this.resource.name}`,
successMethod: () => {
this.closeModal()
},
errorMessage: this.$t('message.migrating.failed'),
errorMethod: () => {
this.closeModal()
},
loadingMessage: `${this.$t('message.migrating.processing')} ${this.resource.name}`,
catchMessage: this.$t('error.fetching.async.job.result'),
catchMethod: () => {
this.closeModal()
}
})
this.closeModal()
}).catch(error => {
this.$notification.error({
message: this.$t('message.request.failed'),
description: (error.response && error.response.headers && error.response.headers['x-description']) || error.message,
duration: 0
})
}).finally(() => {
this.loading = false
})
}
}
}
@ -308,7 +379,26 @@ export default {
}
.top-spaced {
margin-top: 20px;
}
.pagination {
margin-top: 20px;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 20px;
button {
&:not(:last-child) {
margin-right: 10px;
}
}
}
.table-tooltip-icon {
color: rgba(0,0,0,.45);
}
</style>

View File

@ -16,63 +16,47 @@
// under the License.
<template>
<div class="migrate-volume-container" v-ctrl-enter="submitMigrateVolume">
<div class="modal-form">
<div v-if="storagePools.length > 0">
<a-alert type="warning">
<span slot="message" v-html="$t('message.migrate.volume')" />
</a-alert>
<p class="modal-form__label">{{ $t('label.storagepool') }}</p>
<a-select
v-model="selectedStoragePool"
style="width: 100%;"
:autoFocus="storagePools.length > 0"
showSearch
optionFilterProp="children"
:filterOption="(input, option) => {
return option.componentOptions.children[0].text.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
<a-select-option v-for="(storagePool, index) in storagePools" :value="storagePool.id" :key="index">
{{ storagePool.name }} <span v-if="resource.virtualmachineid">{{ storagePool.suitableformigration ? `(${$t('label.suitable')})` : `(${$t('label.not.suitable')})` }}</span>
</a-select-option>
</a-select>
<template v-if="this.resource.virtualmachineid">
<p class="modal-form__label" @click="replaceDiskOffering = !replaceDiskOffering" style="cursor:pointer;">
{{ $t('label.usenewdiskoffering') }}
</p>
<a-checkbox v-model="replaceDiskOffering" />
<div class="form" v-ctrl-enter="handleKeyboardSubmit">
<a-alert class="top-spaced" type="warning">
<span slot="message" v-html="$t('message.migrate.volume')" />
</a-alert>
<storage-pool-select-view
ref="storagePoolSelection"
:resource="resource"
:suitabilityEnabled="true"
@storagePoolsUpdated="handleStoragePoolsChange"
@select="handleStoragePoolSelect" />
<div class="top-spaced" v-if="storagePools.length > 0">
<template v-if="this.resource.virtualmachineid">
<p class="modal-form__label" @click="replaceDiskOffering = !replaceDiskOffering" style="cursor:pointer;">
{{ $t('label.usenewdiskoffering') }}
</p>
<a-checkbox v-model="replaceDiskOffering" />
<template v-if="replaceDiskOffering">
<p class="modal-form__label">{{ $t('label.newdiskoffering') }}</p>
<a-select
v-model="selectedDiskOffering"
style="width: 100%;"
showSearch
optionFilterProp="children"
:filterOption="(input, option) => {
return option.componentOptions.children[0].text.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
<a-select-option v-for="(diskOffering, index) in diskOfferings" :value="diskOffering.id" :key="index">
{{ diskOffering.displaytext }}
</a-select-option>
</a-select>
</template>
<template v-if="replaceDiskOffering">
<p class="modal-form__label">{{ $t('label.newdiskoffering') }}</p>
<a-select
:loading="diskOfferingLoading"
v-model="selectedDiskOffering"
style="width: 100%;"
showSearch
optionFilterProp="children"
:filterOption="(input, option) => {
return option.componentOptions.children[0].text.toLowerCase().indexOf(input.toLowerCase()) >= 0
}" >
<a-select-option v-for="(diskOffering, index) in diskOfferings" :value="diskOffering.id" :key="index">
{{ diskOffering.displaytext }}
</a-select-option>
</a-select>
</template>
</div>
<a-alert style="margin-top: 15px" type="warning" v-else>
<span slot="message" v-html="$t('message.no.primary.stores')" />
</a-alert>
</template>
</div>
<a-divider />
<div class="actions">
<a-button @click="closeModal">
{{ $t('label.cancel') }}
</a-button>
<a-button type="primary" ref="submit" @click="submitMigrateVolume">
{{ $t('label.ok') }}
</a-button>
<a-button @click="closeModal">{{ $t('label.cancel') }}</a-button>
<a-button type="primary" ref="submit" :disabled="!selectedStoragePool" @click="submitForm">{{ $t('label.ok') }}</a-button>
</div>
</div>
@ -80,9 +64,13 @@
<script>
import { api } from '@/api'
import StoragePoolSelectView from '@/components/view/StoragePoolSelectView'
export default {
name: 'MigrateVolume',
components: {
StoragePoolSelectView
},
props: {
resource: {
type: Object,
@ -95,71 +83,67 @@ export default {
storagePools: [],
selectedStoragePool: null,
diskOfferings: [],
diskOfferingLoading: false,
replaceDiskOffering: false,
selectedDiskOffering: null,
isSubmitted: false
}
},
created () {
this.fetchStoragePools()
this.resource.virtualmachineid && this.fetchDiskOfferings()
watch: {
replaceDiskOffering (newValue) {
if (newValue) {
this.fetchDiskOfferings()
}
}
},
methods: {
fetchStoragePools () {
if (this.resource.virtualmachineid) {
api('findStoragePoolsForMigration', {
id: this.resource.id
}).then(response => {
this.storagePools = response.findstoragepoolsformigrationresponse.storagepool || []
if (Array.isArray(this.storagePools) && this.storagePools.length) {
this.selectedStoragePool = this.storagePools[0].id || ''
}
}).catch(error => {
this.$notifyError(error)
this.closeModal()
})
} else {
api('listStoragePools', {
zoneid: this.resource.zoneid
}).then(response => {
this.storagePools = response.liststoragepoolsresponse.storagepool || []
this.storagePools = this.storagePools.filter(pool => { return pool.id !== this.resource.storageid })
if (Array.isArray(this.storagePools) && this.storagePools.length) {
this.selectedStoragePool = this.storagePools[0].id || ''
}
}).catch(error => {
this.$notifyError(error)
this.closeModal()
})
}
},
fetchDiskOfferings () {
this.diskOfferingLoading = true
api('listDiskOfferings', {
listall: true
}).then(response => {
this.diskOfferings = response.listdiskofferingsresponse.diskoffering
this.selectedDiskOffering = this.diskOfferings[0].id
}).catch(error => {
this.$notifyError(error)
this.closeModal()
}).finally(() => {
this.diskOfferingLoading = false
if (this.diskOfferings.length > 0) {
this.selectedDiskOffering = this.diskOfferings[0].id
}
})
},
closeModal () {
this.$parent.$parent.close()
handleStoragePoolsChange (storagePools) {
this.storagePools = storagePools
},
submitMigrateVolume () {
handleStoragePoolSelect (storagePool) {
this.selectedStoragePool = storagePool
},
handleKeyboardSubmit () {
if (!this.selectedStoragePool) {
return
}
this.submitForm()
},
closeModal () {
this.$emit('close-action')
},
submitForm () {
if (this.isSubmitted) return
if (this.storagePools.length === 0) {
this.closeModal()
return
}
this.isSubmitted = true
api('migrateVolume', {
var params = {
livemigrate: this.resource.vmstate === 'Running',
storageid: this.selectedStoragePool,
volumeid: this.resource.id,
newdiskofferingid: this.replaceDiskOffering ? this.selectedDiskOffering : null
}).then(response => {
storageid: this.selectedStoragePool.id,
volumeid: this.resource.id
}
if (this.replaceDiskOffering) {
params.newdiskofferingid = this.selectedDiskOffering
}
api('migrateVolume', params).then(response => {
this.$pollJob({
jobId: response.migratevolumeresponse.jobid,
successMessage: this.$t('message.success.migrate.volume'),
@ -185,14 +169,18 @@ export default {
</script>
<style scoped lang="scss">
.migrate-volume-container {
width: 85vw;
.form {
width: 80vw;
@media (min-width: 760px) {
width: 500px;
@media (min-width: 900px) {
width: 850px;
}
}
.top-spaced {
margin-top: 20px;
}
.actions {
display: flex;
justify-content: flex-end;
@ -204,14 +192,4 @@ export default {
}
}
}
.modal-form {
margin-top: -20px;
&__label {
margin-top: 10px;
margin-bottom: 5px;
}
}
</style>

View File

@ -21,7 +21,7 @@ import mockRouter from '../mock/mockRouter'
import localVue from '../setup'
import { mount } from '@vue/test-utils'
import { pollJobPlugin, notifierPlugin, configUtilPlugin, apiMetaUtilPlugin, toLocaleDatePlugin, showIconPlugin, resourceTypePlugin } from '@/utils/plugins'
import { pollJobPlugin, notifierPlugin, configUtilPlugin, apiMetaUtilPlugin, toLocaleDatePlugin, showIconPlugin, resourceTypePlugin, fileSizeUtilPlugin } from '@/utils/plugins'
localVue.use(pollJobPlugin)
localVue.use(notifierPlugin)
@ -30,6 +30,7 @@ localVue.use(apiMetaUtilPlugin)
localVue.use(toLocaleDatePlugin)
localVue.use(showIconPlugin)
localVue.use(resourceTypePlugin)
localVue.use(fileSizeUtilPlugin)
function createMockRouter (newRoutes = []) {
let routes = []