fix: 修复镜像选择器分页总数 #31
@@ -557,6 +557,18 @@ export const abortDataMigrate = (data) => {
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取跨服务数据迁移并发及队列状态 */
|
||||
export const getDataMigrateConcurrency = () => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/vm/data_migrate/concurrency')
|
||||
}
|
||||
|
||||
/** 设置跨服务数据迁移并发数 */
|
||||
export const updateDataMigrateConcurrency = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/data_migrate/concurrency', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取虚拟机迁移记录列表 */
|
||||
export const getVmMigrationRecordList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/vm/migration_record/list', { params })
|
||||
|
||||
@@ -98,7 +98,7 @@ const loadList = async () => {
|
||||
items = items.map(item => item.image || item).filter(Boolean)
|
||||
}
|
||||
list.value = items
|
||||
total.value = inner.total ?? inner.all_count ?? list.value.length
|
||||
total.value = inner.meta?.count ?? inner.total ?? inner.all_count ?? list.value.length
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { loading.value = false }
|
||||
|
||||
@@ -75,6 +75,45 @@
|
||||
<span style="color:#909399;font-size:12px"> (ID:{{ row.userId || row.user_id || '-' }})</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主机名" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ getRowVm(row)?.hostname || getRowVm(row)?.name || row.itemName || '-' }}</span>
|
||||
<span v-if="row.itemId" class="vm-secondary-text"> #{{ row.itemId }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配置" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div v-if="getRowVm(row)" class="vm-spec-list">
|
||||
<el-tag size="small" type="info">{{ getRowVm(row).vcpu || 0 }} 核</el-tag>
|
||||
<el-tag size="small" type="info">{{ formatMemory(getRowVm(row).memory) }}</el-tag>
|
||||
<el-tag v-if="getRowVm(row).system_size" size="small" type="info">{{ getRowVm(row).system_size }} GB 盘</el-tag>
|
||||
</div>
|
||||
<span v-else class="vm-secondary-text">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP 地址" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<div v-if="getVmIps(getRowVm(row)).length" class="vm-ip-cell">
|
||||
<span class="vm-ip-text">{{ getVmIps(getRowVm(row))[0] }}</span>
|
||||
<el-button link class="vm-ip-copy" title="复制 IP" @click.stop="copyIp(getVmIps(getRowVm(row))[0])">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
<el-popover v-if="getVmIps(getRowVm(row)).length > 1" placement="top" trigger="hover" :width="360">
|
||||
<template #reference>
|
||||
<el-tag size="small" type="info" class="vm-ip-more">+{{ getVmIps(getRowVm(row)).length - 1 }}</el-tag>
|
||||
</template>
|
||||
<div class="vm-ip-popover-title">全部 IP</div>
|
||||
<div v-for="(ip, index) in getVmIps(getRowVm(row))" :key="`${ip}-${index}`" class="vm-ip-popover-row">
|
||||
<code>{{ ip }}</code>
|
||||
<el-button link title="复制 IP" @click.stop="copyIp(ip)">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
<span v-else class="vm-secondary-text">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="绑定状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.itemId ? 'success' : 'info'" size="small">
|
||||
@@ -934,7 +973,7 @@
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Search, ArrowDown, CopyDocument } from '@element-plus/icons-vue'
|
||||
import { getUserVmList, getUserVmDetail, createUserVm, updateUserVm, deleteUserVm, getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, bindUserVm, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
|
||||
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
|
||||
import { hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit, toBaseUnit, fromBaseUnit } from '@/utils/dynamicUnit'
|
||||
@@ -972,6 +1011,50 @@ const formatMemory = (kb) => {
|
||||
return kb + ' KB'
|
||||
}
|
||||
|
||||
const parseItemArg = (row) => {
|
||||
const raw = row?.itemArg ?? row?.ItemArg ?? row?.item_arg
|
||||
if (!raw) return null
|
||||
if (typeof raw === 'object' && !Array.isArray(raw)) return raw
|
||||
if (typeof raw !== 'string') return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getRowVm = (row) => row?._itemArgVm || null
|
||||
|
||||
const getVmIps = (vm) => {
|
||||
if (!vm) return []
|
||||
if (Array.isArray(vm.ips)) return vm.ips.map(ip => String(ip).trim()).filter(Boolean).reverse()
|
||||
if (typeof vm.ips === 'string' && vm.ips.trim()) return vm.ips.split(',').map(ip => ip.trim()).filter(Boolean).reverse()
|
||||
const networks = Array.isArray(vm.networks) ? vm.networks : []
|
||||
return networks.flatMap(network => {
|
||||
if (Array.isArray(network.ips)) return network.ips
|
||||
return [network.ip, network.address].filter(Boolean)
|
||||
}).map(ip => String(ip).trim()).filter(Boolean).reverse()
|
||||
}
|
||||
|
||||
const copyIp = async (ip) => {
|
||||
if (!ip) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
ElMessage.success('IP 已复制')
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = ip
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
copied ? ElMessage.success('IP 已复制') : ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const formatExpireTime = (t) => {
|
||||
if (!t) return '-'
|
||||
const d = dayjs(t)
|
||||
@@ -1005,7 +1088,8 @@ const loadList = async () => {
|
||||
const res = await getUserGoodsList(params)
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
list.value = d.data || (Array.isArray(d) ? d : [])
|
||||
const rows = d.data || (Array.isArray(d) ? d : [])
|
||||
list.value = rows.map(row => ({ ...row, _itemArgVm: parseItemArg(row) }))
|
||||
total.value = d.meta?.count ?? d.all_count ?? d.total ?? list.value.length
|
||||
} else {
|
||||
list.value = []
|
||||
@@ -1857,6 +1941,15 @@ onMounted(() => {
|
||||
.action-bar { display: flex; gap: 12px; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.table-section { padding: 0; }
|
||||
.vm-spec-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.vm-secondary-text { color: #909399; font-size: 12px; }
|
||||
.vm-ip-cell { display: flex; align-items: center; min-width: 0; }
|
||||
.vm-ip-text { overflow: hidden; color: #303133; font-family: Consolas, Monaco, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vm-ip-copy { flex-shrink: 0; margin-left: 2px; padding: 4px; }
|
||||
.vm-ip-more { flex-shrink: 0; margin-left: 4px; cursor: default; }
|
||||
:global(.vm-ip-popover-title) { margin-bottom: 6px; color: #909399; font-size: 12px; }
|
||||
:global(.vm-ip-popover-row) { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 30px; }
|
||||
:global(.vm-ip-popover-row code) { overflow-wrap: anywhere; color: #303133; font-family: Consolas, Monaco, monospace; }
|
||||
|
||||
.pagination { padding: 16px 20px; border-top: 1px solid #e1e8ed; background: #fafbfc; justify-content: flex-end; }
|
||||
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
<el-tab-pane label="用户组网" name="networking">
|
||||
<UserNetworkingManage v-if="tabLoaded['networking']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="迁移队列" name="migration_queue">
|
||||
<VmMigrationQueue v-if="tabLoaded['migration_queue']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="迁移记录" name="migration">
|
||||
<VmMigrationRecord v-if="tabLoaded['migration']" />
|
||||
</el-tab-pane>
|
||||
@@ -164,6 +167,7 @@ const VncNodeManage = defineAsyncComponent(() => import('./VncNodeManage.vue'))
|
||||
const SnapshotManage = defineAsyncComponent(() => import('./SnapshotManage.vue'))
|
||||
const BackupManage = defineAsyncComponent(() => import('./BackupManage.vue'))
|
||||
const UserNetworkingManage = defineAsyncComponent(() => import('./UserNetworkingManage.vue'))
|
||||
const VmMigrationQueue = defineAsyncComponent(() => import('./VmMigrationQueue.vue'))
|
||||
const VmMigrationRecord = defineAsyncComponent(() => import('./VmMigrationRecord.vue'))
|
||||
|
||||
// 引入tagsViewStore
|
||||
@@ -198,6 +202,7 @@ const tabLoaded = reactive({
|
||||
'snapshot': false,
|
||||
'backup': false,
|
||||
'networking': false,
|
||||
'migration_queue': false,
|
||||
'migration': false
|
||||
})
|
||||
|
||||
@@ -595,4 +600,8 @@ onMounted(() => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.custom-tabs :deep(.migration-queue-page) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -106,7 +106,8 @@
|
||||
<span class="migrate-banner-title">数据迁移进行中</span>
|
||||
<template v-if="dataMigrateProgressData">
|
||||
<span class="migrate-divider"></span>
|
||||
<el-tag :type="migrateStageType(dataMigrateProgressData.stage)" size="small" effect="dark">{{ migrateStageLabel(dataMigrateProgressData.stage) }}</el-tag>
|
||||
<el-tag :type="migrateStageType(currentMigrateStage)" size="small" effect="dark">{{ migrateStageLabel(currentMigrateStage) }}</el-tag>
|
||||
<span v-if="dataMigrateProgressData.queue_position > 0" class="migrate-msg">队列 #{{ dataMigrateProgressData.queue_position }}</span>
|
||||
<span v-if="dataMigrateProgressData.progress != null" class="migrate-progress-text">{{ dataMigrateProgressData.progress }}%</span>
|
||||
<span v-if="dataMigrateProgressData.speed" class="migrate-speed">{{ dataMigrateProgressData.speed }}</span>
|
||||
<span v-if="dataMigrateProgressData.message" class="migrate-msg">{{ dataMigrateProgressData.message }}</span>
|
||||
@@ -1305,7 +1306,7 @@
|
||||
<div v-loading="dataMigrateProgressLoading">
|
||||
<el-descriptions :column="2" border size="small" v-if="dataMigrateProgressData">
|
||||
<el-descriptions-item label="阶段" :span="2">
|
||||
<el-tag :type="migrateStageType(dataMigrateProgressData.stage)" size="small">{{ migrateStageLabel(dataMigrateProgressData.stage) }}</el-tag>
|
||||
<el-tag :type="migrateStageType(currentMigrateStage)" size="small">{{ migrateStageLabel(currentMigrateStage) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="迁移记录" v-if="dataMigrateProgressData.migration_record_id">
|
||||
<el-link type="primary" @click="goMigrationRecords({ id: dataMigrateProgressData.migration_record_id })">#{{ dataMigrateProgressData.migration_record_id }}</el-link>
|
||||
@@ -1313,6 +1314,7 @@
|
||||
<el-descriptions-item label="迁移链" v-if="dataMigrateProgressData.chain_id">
|
||||
<el-link type="primary" class="mono-text" @click="goMigrationRecords({ chain_id: dataMigrateProgressData.chain_id })">{{ dataMigrateProgressData.chain_id }}</el-link>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="队列位置" v-if="dataMigrateProgressData.queue_position > 0">第 {{ dataMigrateProgressData.queue_position }} 位</el-descriptions-item>
|
||||
<el-descriptions-item label="导出任务ID" :span="2" v-if="dataMigrateProgressData.export_task_id">
|
||||
<span style="font-family:monospace;font-size:12px">{{ dataMigrateProgressData.export_task_id }}</span>
|
||||
</el-descriptions-item>
|
||||
@@ -1326,11 +1328,11 @@
|
||||
<el-descriptions-item label="源虚拟机" v-if="dataMigrateProgressData.source_vm_id">
|
||||
<el-link type="primary" @click="goVmById(dataMigrateProgressData.source_service_id, dataMigrateProgressData.source_vm_id)">VM #{{ dataMigrateProgressData.source_vm_id }}</el-link>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="导入虚拟机" v-if="dataMigrateProgressData.imported_vm_id">
|
||||
<el-link type="primary" @click="goVmById(dataMigrateProgressData.target_service_id, dataMigrateProgressData.imported_vm_id)">VM #{{ dataMigrateProgressData.imported_vm_id }}</el-link>
|
||||
<el-descriptions-item label="导入虚拟机" v-if="dataMigrateProgressData.target_vm_id || dataMigrateProgressData.imported_vm_id">
|
||||
<el-link type="primary" @click="goVmById(dataMigrateProgressData.target_service_id, dataMigrateProgressData.target_vm_id || dataMigrateProgressData.imported_vm_id)">VM #{{ dataMigrateProgressData.target_vm_id || dataMigrateProgressData.imported_vm_id }}</el-link>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="进度" :span="2" v-if="dataMigrateProgressData.progress != null">
|
||||
<el-progress :percentage="dataMigrateProgressData.progress" :status="migrateProgressBarStatus(dataMigrateProgressData.stage)" style="width:100%" />
|
||||
<el-progress :percentage="dataMigrateProgressData.progress" :status="migrateProgressBarStatus(currentMigrateStage)" style="width:100%" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="速度" v-if="dataMigrateProgressData.speed">{{ dataMigrateProgressData.speed }}</el-descriptions-item>
|
||||
<el-descriptions-item label="信息" :span="dataMigrateProgressData.speed ? 1 : 2" v-if="dataMigrateProgressData.message">{{ dataMigrateProgressData.message }}</el-descriptions-item>
|
||||
@@ -2840,12 +2842,21 @@ const submitDataMigrate = async () => {
|
||||
}
|
||||
const res = await dataMigrateVm(fd)
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('数据迁移已发起')
|
||||
const d = res.data.data
|
||||
dataMigrationId.value = d?.migration_id || ''
|
||||
dataMigrateTaskId.value = d?.export_task?.task_id || ''
|
||||
dataMigrationRecordId.value = d?.migration_record_id || null
|
||||
dataMigrationChainId.value = d?.chain_id || ''
|
||||
dataMigrateProgressData.value = d ? {
|
||||
source_service_id: serviceId.value,
|
||||
source_host_id: vmHostId.value,
|
||||
source_vm_id: vmId.value,
|
||||
target_service_id: dataMigrateForm.target_service_id,
|
||||
target_host_id: dataMigrateForm.target_host_id,
|
||||
...d
|
||||
} : null
|
||||
if (d?.queue_position > 0) ElMessage.success(`迁移已加入队列,当前位置 #${d.queue_position}`)
|
||||
else ElMessage.success('数据迁移已发起')
|
||||
dataMigrateVisible.value = false
|
||||
dataMigrateProgressVisible.value = true
|
||||
loadDataMigrateProgress()
|
||||
@@ -2857,28 +2868,29 @@ const submitDataMigrate = async () => {
|
||||
const MIGRATE_DONE_STAGES = ['completed', 'done', 'success', 'warning', 'failed', 'aborted', 'cancelled', 'error']
|
||||
|
||||
const loadDataMigrateProgress = async () => {
|
||||
if (!dataMigrationId.value && !dataMigrateTaskId.value) return
|
||||
if (!dataMigrationRecordId.value && !dataMigrationId.value && !dataMigrateTaskId.value) return
|
||||
const params = { service_id: serviceId.value, host_id: vmHostId.value }
|
||||
if (dataMigrationId.value) params.migration_id = dataMigrationId.value
|
||||
if (dataMigrateTaskId.value) params.task_id = dataMigrateTaskId.value
|
||||
if (dataMigrationRecordId.value) params.migration_record_id = dataMigrationRecordId.value
|
||||
else if (dataMigrationId.value) params.migration_id = dataMigrationId.value
|
||||
else if (dataMigrateTaskId.value) params.task_id = dataMigrateTaskId.value
|
||||
dataMigrateProgressLoading.value = true
|
||||
try {
|
||||
const res = await getDataMigrateProgress(params)
|
||||
if (res?.data?.code === 200 && res.data.data) {
|
||||
dataMigrateProgressData.value = res.data.data
|
||||
const d = res.data.data
|
||||
dataMigrateProgressData.value = { ...(dataMigrateProgressData.value || {}), ...d }
|
||||
if (d.migration_record_id) dataMigrationRecordId.value = d.migration_record_id
|
||||
if (d.chain_id) dataMigrationChainId.value = d.chain_id
|
||||
if (!dataMigrationId.value && d.migration_id) dataMigrationId.value = d.migration_id
|
||||
if (!dataMigrateTaskId.value) {
|
||||
dataMigrateTaskId.value = d.export_task_id || d.import_task_id || ''
|
||||
}
|
||||
const stage = d.stage
|
||||
const stage = d.status || d.stage
|
||||
if (MIGRATE_DONE_STAGES.includes(stage)) {
|
||||
stopMigratePolling()
|
||||
if (stage === 'completed' || stage === 'done' || stage === 'success') ElMessage.success('数据迁移已完成')
|
||||
else if (stage === 'warning') ElMessage.warning('数据迁移已完成,但存在警告')
|
||||
else if (stage === 'failed' || stage === 'error') ElMessage.error(d.error || '数据迁移失败')
|
||||
else if (stage === 'failed' || stage === 'error') ElMessage.error(d.error_message || d.error || '数据迁移失败')
|
||||
loadDetail()
|
||||
}
|
||||
} else {
|
||||
@@ -2891,9 +2903,11 @@ const loadDataMigrateProgress = async () => {
|
||||
} finally { dataMigrateProgressLoading.value = false }
|
||||
}
|
||||
|
||||
const currentMigrateStage = computed(() => dataMigrateProgressData.value?.status || dataMigrateProgressData.value?.stage || '')
|
||||
|
||||
const isMigrating = computed(() => {
|
||||
if (detail.value?.migrating) return true
|
||||
const stage = dataMigrateProgressData.value?.stage
|
||||
const stage = currentMigrateStage.value
|
||||
return stage && !MIGRATE_DONE_STAGES.includes(stage)
|
||||
})
|
||||
|
||||
@@ -2901,14 +2915,14 @@ const migrateStageLabel = (stage) => ({
|
||||
exporting: '导出中', importing: '导入中', transferring: '传输中',
|
||||
verifying: '校验中', completed: '已完成', done: '已完成', success: '成功', warning: '已完成(有警告)', running: '执行中',
|
||||
failed: '失败', error: '错误', aborted: '已中断', cancelled: '已取消',
|
||||
pending: '等待中', preparing: '准备中'
|
||||
pending: '排队中', starting: '启动中', preparing: '准备中', aborting: '中断中', recovering: '恢复中'
|
||||
}[stage] || stage || '-')
|
||||
|
||||
const migrateStageType = (stage) => ({
|
||||
exporting: 'warning', importing: 'warning', transferring: '',
|
||||
verifying: '', completed: 'success', done: 'success', success: 'success', warning: 'warning', running: 'primary',
|
||||
failed: 'danger', error: 'danger', aborted: 'info', cancelled: 'info',
|
||||
pending: 'info', preparing: 'info'
|
||||
pending: 'info', starting: 'warning', preparing: 'info', aborting: 'danger', recovering: 'warning'
|
||||
}[stage] || 'info')
|
||||
|
||||
const migrateProgressBarStatus = (stage) => {
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
<el-button type="warning" size="small" @click="handleBatchPower('stop')" :loading="batchLoading">
|
||||
<el-icon><SwitchButton /></el-icon>批量关机
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" @click="openBatchDataMigrate" :loading="batchLoading">
|
||||
<el-icon><Promotion /></el-icon>批量数据迁移
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click="handleBatchDelete" :loading="batchLoading">
|
||||
<el-icon><Delete /></el-icon>批量删除
|
||||
</el-button>
|
||||
@@ -65,6 +68,29 @@
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP 地址" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<div v-if="getVmIps(row).length" class="vm-ip-cell">
|
||||
<span class="vm-ip-text">{{ getVmIps(row)[0] }}</span>
|
||||
<el-button link class="vm-ip-copy" title="复制 IP" @click.stop="copyIp(getVmIps(row)[0])">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
<el-popover v-if="getVmIps(row).length > 1" placement="top" trigger="hover" :width="360">
|
||||
<template #reference>
|
||||
<el-tag size="small" type="info" class="vm-ip-more">+{{ getVmIps(row).length - 1 }}</el-tag>
|
||||
</template>
|
||||
<div class="vm-ip-popover-title">全部 IP</div>
|
||||
<div v-for="ip in getVmIps(row)" :key="ip" class="vm-ip-popover-row">
|
||||
<code>{{ ip }}</code>
|
||||
<el-button link title="复制 IP" @click.stop="copyIp(ip)">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="180">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.migrating">
|
||||
@@ -443,6 +469,47 @@
|
||||
<!-- 用户选择器 -->
|
||||
<UserListSelector v-model="showUserSelector" :current-user-id="createForm.user_id" @confirm="handleUserSelected" />
|
||||
|
||||
<!-- 批量数据迁移 -->
|
||||
<el-dialog v-model="batchMigrateVisible" title="批量数据迁移" width="620px" destroy-on-close class="tk-dialog">
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px">
|
||||
将 {{ batchMigrateTargets.length }} 台虚拟机的数据迁移到同一目标宿主机。迁移任务将逐台提交,已在迁移中的实例会被跳过。
|
||||
</el-alert>
|
||||
<el-form :model="batchMigrateForm" label-width="110px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">目标位置</div>
|
||||
<el-form-item label="目标主控服务" required>
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="batchMigrateForm.target_service_id ? `${batchMigrateForm._serviceName} (ID: ${batchMigrateForm.target_service_id})` : ''" readonly placeholder="请选择目标主控服务" />
|
||||
<el-button type="primary" @click="showBatchServiceSelector=true">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="batchMigrateForm.target_host_id ? `${batchMigrateForm._hostName} (ID: ${batchMigrateForm.target_host_id})` : ''" readonly placeholder="请先选择目标主控服务" />
|
||||
<el-button type="primary" :disabled="!batchMigrateForm.target_service_id" @click="showBatchHostSelector=true">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">网络配置</div>
|
||||
<div class="tk-resource-grid">
|
||||
<el-form-item label="IPv4 数量"><el-input-number v-model="batchMigrateForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个/实例</span></el-form-item>
|
||||
<el-form-item label="IPv6 数量"><el-input-number v-model="batchMigrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个/实例</span></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="目标网络">
|
||||
<div class="batch-network-field">
|
||||
<div class="batch-network-tags" v-if="batchMigrateNetworks.length"><el-tag v-for="network in batchMigrateNetworks" :key="network.id" closable @close="removeBatchMigrateNetwork(network.id)">{{ network.name || network.id }} (#{{ network.id }})</el-tag></div>
|
||||
<el-button :disabled="!batchMigrateForm.target_host_id" @click="showBatchNetworkSelector=true">选择网络</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="batchMigrateVisible=false">取消</el-button><el-button type="primary" :loading="batchMigrateSubmitting" @click="submitBatchDataMigrate">提交迁移</el-button></template>
|
||||
</el-dialog>
|
||||
<KvmServiceSelector v-model="showBatchServiceSelector" @confirm="handleBatchServiceSelected" />
|
||||
<HostSelectorPopup v-model="showBatchHostSelector" :service-id="batchMigrateForm.target_service_id || 0" :current-id="batchMigrateForm.target_host_id || 0" @confirm="handleBatchHostSelected" />
|
||||
<NetworkSelectorPopup v-model="showBatchNetworkSelector" :service-id="batchMigrateForm.target_service_id || 0" :host-id="batchMigrateForm.target_host_id || 0" filter-type="bridge" filter-used="false" @confirm="handleBatchNetworkSelected" />
|
||||
|
||||
<!-- 电源操作确认弹窗 -->
|
||||
<el-dialog v-model="powerDialogVisible" :title="`${powerLabels[powerAction] || ''}虚拟机`" width="400px" destroy-on-close>
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px; padding: 8px 0">
|
||||
@@ -469,18 +536,20 @@
|
||||
import { ref, reactive, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled, VideoPlay, SwitchButton, Delete } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled, VideoPlay, SwitchButton, Delete, Promotion, CopyDocument } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getVmList, getVmDetail, getVmStatus,
|
||||
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
|
||||
resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList, createNetwork, getMetricsHistory,
|
||||
getDataMigrateProgress
|
||||
getDataMigrateProgress, dataMigrateVm
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
||||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||||
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
|
||||
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
|
||||
import HostSelectorPopup from '@/components/admin/HostSelectorPopup.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -516,6 +585,135 @@ const selectedVms = ref([])
|
||||
const handleSelectionChange = (selection) => { selectedVms.value = selection }
|
||||
const clearSelection = () => { vmTableRef.value?.clearSelection() }
|
||||
|
||||
// Batch data migration
|
||||
const batchMigrateVisible = ref(false)
|
||||
const batchMigrateSubmitting = ref(false)
|
||||
const showBatchServiceSelector = ref(false)
|
||||
const showBatchHostSelector = ref(false)
|
||||
const showBatchNetworkSelector = ref(false)
|
||||
const batchMigrateTargets = ref([])
|
||||
const batchMigrateNetworks = ref([])
|
||||
const batchMigrateForm = reactive({
|
||||
target_service_id: 0,
|
||||
target_host_id: 0,
|
||||
ipv4_num: 0,
|
||||
ipv6_num: 0,
|
||||
network_ids: [],
|
||||
_serviceName: '',
|
||||
_hostName: ''
|
||||
})
|
||||
|
||||
const resetBatchMigrateForm = () => {
|
||||
Object.assign(batchMigrateForm, {
|
||||
target_service_id: 0,
|
||||
target_host_id: 0,
|
||||
ipv4_num: 0,
|
||||
ipv6_num: 0,
|
||||
network_ids: [],
|
||||
_serviceName: '',
|
||||
_hostName: ''
|
||||
})
|
||||
batchMigrateNetworks.value = []
|
||||
}
|
||||
|
||||
const openBatchDataMigrate = () => {
|
||||
const targets = selectedVms.value.filter(vm => !vm.migrating)
|
||||
if (!targets.length) {
|
||||
ElMessage.warning('所选虚拟机均正在迁移中,请重新选择')
|
||||
return
|
||||
}
|
||||
const skipped = selectedVms.value.length - targets.length
|
||||
if (skipped) ElMessage.warning(`已跳过 ${skipped} 台正在迁移的虚拟机`)
|
||||
batchMigrateTargets.value = [...targets]
|
||||
resetBatchMigrateForm()
|
||||
batchMigrateVisible.value = true
|
||||
}
|
||||
|
||||
const handleBatchServiceSelected = (service) => {
|
||||
if (!service?.id) return
|
||||
batchMigrateForm.target_service_id = Number(service.id)
|
||||
batchMigrateForm._serviceName = service.name || ''
|
||||
batchMigrateForm.target_host_id = 0
|
||||
batchMigrateForm._hostName = ''
|
||||
batchMigrateForm.network_ids = []
|
||||
batchMigrateNetworks.value = []
|
||||
}
|
||||
|
||||
const handleBatchHostSelected = (host) => {
|
||||
if (!host?.id) return
|
||||
batchMigrateForm.target_host_id = Number(host.id)
|
||||
batchMigrateForm._hostName = host.name || host.ip || ''
|
||||
batchMigrateForm.network_ids = []
|
||||
batchMigrateNetworks.value = []
|
||||
}
|
||||
|
||||
const handleBatchNetworkSelected = (networks) => {
|
||||
const list = Array.isArray(networks) ? networks : (networks ? [networks] : [])
|
||||
const merged = [...batchMigrateNetworks.value, ...list]
|
||||
batchMigrateNetworks.value = [...new Map(merged.filter(item => item?.id).map(item => [Number(item.id), item])).values()]
|
||||
batchMigrateForm.network_ids = batchMigrateNetworks.value.map(item => Number(item.id))
|
||||
}
|
||||
|
||||
const removeBatchMigrateNetwork = (networkId) => {
|
||||
batchMigrateNetworks.value = batchMigrateNetworks.value.filter(item => Number(item.id) !== Number(networkId))
|
||||
batchMigrateForm.network_ids = batchMigrateNetworks.value.map(item => Number(item.id))
|
||||
}
|
||||
|
||||
const submitBatchDataMigrate = async () => {
|
||||
if (!batchMigrateForm.target_service_id) {
|
||||
ElMessage.warning('请选择目标主控服务')
|
||||
return
|
||||
}
|
||||
if (!batchMigrateForm.target_host_id) {
|
||||
ElMessage.warning('请选择目标宿主机')
|
||||
return
|
||||
}
|
||||
if (!batchMigrateTargets.value.length) {
|
||||
ElMessage.warning('没有可迁移的虚拟机')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定将 ${batchMigrateTargets.value.length} 台虚拟机迁移到宿主机「${batchMigrateForm._hostName || batchMigrateForm.target_host_id}」吗?`,
|
||||
'批量数据迁移',
|
||||
{ confirmButtonText: '确定迁移', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
batchMigrateSubmitting.value = true
|
||||
batchLoading.value = true
|
||||
let success = 0
|
||||
const failed = []
|
||||
for (const vm of batchMigrateTargets.value) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('source_service_id', serviceId.value)
|
||||
formData.append('source_vm_id', vm.id)
|
||||
formData.append('target_service_id', batchMigrateForm.target_service_id)
|
||||
formData.append('target_host_id', batchMigrateForm.target_host_id)
|
||||
if (batchMigrateForm.ipv4_num > 0) formData.append('ipv4_num', batchMigrateForm.ipv4_num)
|
||||
if (batchMigrateForm.ipv6_num > 0) formData.append('ipv6_num', batchMigrateForm.ipv6_num)
|
||||
batchMigrateForm.network_ids.forEach(id => formData.append('network_ids', id))
|
||||
const res = await dataMigrateVm(formData)
|
||||
if (res?.data?.code === 200) success++
|
||||
else failed.push(vm.name || `ID ${vm.id}`)
|
||||
} catch {
|
||||
failed.push(vm.name || `ID ${vm.id}`)
|
||||
}
|
||||
}
|
||||
batchMigrateSubmitting.value = false
|
||||
batchLoading.value = false
|
||||
batchMigrateVisible.value = false
|
||||
clearSelection()
|
||||
if (failed.length) {
|
||||
ElMessage.warning(`批量迁移提交完成:成功 ${success},失败 ${failed.length}(${failed.slice(0, 3).join('、')}${failed.length > 3 ? ' 等' : ''})`)
|
||||
} else {
|
||||
ElMessage.success(`已提交 ${success} 台虚拟机的数据迁移任务`)
|
||||
}
|
||||
loadList()
|
||||
}
|
||||
|
||||
// 选择器
|
||||
const showCreateImageSelector = ref(false)
|
||||
const showRebuildImageSelector = ref(false)
|
||||
@@ -725,6 +923,35 @@ const formatMemKB = (kb) => {
|
||||
}
|
||||
const formatMemory = formatMemKB
|
||||
|
||||
const getVmIps = (vm) => {
|
||||
if (!vm) return []
|
||||
if (Array.isArray(vm.ips)) return vm.ips.map(ip => String(ip).trim()).filter(Boolean).reverse()
|
||||
if (typeof vm.ips === 'string' && vm.ips.trim()) return vm.ips.split(',').map(ip => ip.trim()).filter(Boolean).reverse()
|
||||
const networks = Array.isArray(vm.networks) ? vm.networks : []
|
||||
return networks.flatMap(network => {
|
||||
if (Array.isArray(network.ips)) return network.ips
|
||||
return [network.ip, network.address].filter(Boolean)
|
||||
}).map(ip => String(ip).trim()).filter(Boolean).reverse()
|
||||
}
|
||||
|
||||
const copyIp = async (ip) => {
|
||||
if (!ip) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
ElMessage.success('IP 已复制')
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = ip
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const copied = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
copied ? ElMessage.success('IP 已复制') : ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) {
|
||||
@@ -1159,6 +1386,13 @@ defineExpose({ loadList })
|
||||
<style scoped>
|
||||
.vm-manage-container { padding: 20px; }
|
||||
.vm-config { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.vm-ip-cell { display: flex; align-items: center; min-width: 0; }
|
||||
.vm-ip-text { overflow: hidden; color: #303133; font-family: Consolas, Monaco, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vm-ip-copy { flex-shrink: 0; margin-left: 2px; padding: 4px; }
|
||||
.vm-ip-more { flex-shrink: 0; margin-left: 4px; cursor: default; }
|
||||
:global(.vm-ip-popover-title) { margin-bottom: 6px; color: #909399; font-size: 12px; }
|
||||
:global(.vm-ip-popover-row) { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 30px; }
|
||||
:global(.vm-ip-popover-row code) { overflow-wrap: anywhere; color: #303133; font-family: Consolas, Monaco, monospace; }
|
||||
.migrate-inline-status { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
|
||||
.migrate-inline-label { color: #e6a23c; font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.migrate-inline-pct { color: #e6a23c; font-size: 12px; white-space: nowrap; }
|
||||
@@ -1177,6 +1411,8 @@ defineExpose({ loadList })
|
||||
color: #409eff;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.batch-network-field { display: flex; align-items: flex-start; gap: 8px; width: 100%; }
|
||||
.batch-network-tags { display: flex; flex: 1; flex-wrap: wrap; gap: 6px; min-width: 0; }
|
||||
.threshold-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(220px, 1fr) minmax(220px, 1fr);
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="migration-queue-page">
|
||||
<div class="queue-header">
|
||||
<div>
|
||||
<h2>迁移队列</h2>
|
||||
<p>查看当前主控服务相关的跨服务数据迁移,并管理全局迁移并发与队列任务。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-tag type="success" effect="plain">每 5 秒自动刷新</el-tag>
|
||||
<el-button :icon="Setting" @click="openConcurrencyDialog">并发设置</el-button>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadData()">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<el-card v-for="item in summaryItems" :key="item.key" shadow="never" class="summary-card">
|
||||
<div class="summary-label">{{ item.label }}</div>
|
||||
<div class="summary-value" :class="item.className">{{ item.value }}</div>
|
||||
<div class="summary-note">{{ item.note }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false" class="queue-alert" />
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>活动与排队任务</span>
|
||||
<span class="updated-at">状态更新时间:{{ formatTime(concurrency.updated_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="queueRecords" v-loading="loading" stripe>
|
||||
<el-table-column label="队列" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.queue_position > 0" type="info">#{{ row.queue_position }}</el-tag>
|
||||
<el-tag v-else type="success">执行中</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="记录 / 状态" min-width="145">
|
||||
<template #default="{ row }">
|
||||
<div class="stack-cell">
|
||||
<span>记录 #{{ row.id }}</span>
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="源端" min-width="175">
|
||||
<template #default="{ row }">
|
||||
<div class="stack-cell">
|
||||
<el-link v-if="row.source_service_id" type="primary" @click="goService(row.source_service_id)">服务 #{{ row.source_service_id }}</el-link>
|
||||
<el-link v-if="row.source_host_id" type="primary" @click="goHost(row.source_service_id, row.source_host_id)">宿主机 #{{ row.source_host_id }}</el-link>
|
||||
<el-link v-if="row.source_vm_id" type="primary" @click="goVm(row.source_service_id, row.source_vm_id)">VM #{{ row.source_vm_id }}</el-link>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标端" min-width="175">
|
||||
<template #default="{ row }">
|
||||
<div class="stack-cell">
|
||||
<el-link v-if="row.target_service_id" type="primary" @click="goService(row.target_service_id)">服务 #{{ row.target_service_id }}</el-link>
|
||||
<el-link v-if="row.target_host_id" type="primary" @click="goHost(row.target_service_id, row.target_host_id)">宿主机 #{{ row.target_host_id }}</el-link>
|
||||
<el-link v-if="row.target_vm_id" type="primary" @click="goVm(row.target_service_id, row.target_vm_id)">VM #{{ row.target_vm_id }}</el-link>
|
||||
<span v-if="!row.target_vm_id" class="muted">目标 VM 尚未创建</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联对象" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="stack-cell">
|
||||
<el-link v-if="row.user_id" type="primary" @click="goUser(row.user_id)">用户 #{{ row.user_id }}</el-link>
|
||||
<el-link v-if="row.user_goods_id" type="primary" @click="goUserGoods(row.user_goods_id)">用户商品 #{{ row.user_goods_id }}</el-link>
|
||||
<span v-if="!row.user_id && !row.user_goods_id" class="muted">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="迁移进度" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="progressValue(row.progress)" :status="progressStatus(row.status)" :stroke-width="8" />
|
||||
<div class="progress-meta">
|
||||
<span>{{ row.speed || row.message || statusLabel(row.status) }}</span>
|
||||
<span v-if="row.queue_position > 0">前方 {{ row.queue_position - 1 }} 项</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.started_at || row.CreatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" :loading="abortingId === row.id" :disabled="row.status === 'aborting'" @click="abortMigration(row)">中断</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && !queueRecords.length" description="当前服务暂无迁移中或排队中的虚拟机" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="concurrencyVisible" title="迁移队列并发设置" width="480px" destroy-on-close>
|
||||
<el-alert type="info" :closable="false" show-icon class="dialog-alert">
|
||||
调高并发后,空余名额会立即按 FIFO 顺序放行;调低并发不会中断正在执行的任务。
|
||||
</el-alert>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="当前并发数"><strong>{{ concurrency.concurrency }}</strong></el-form-item>
|
||||
<el-form-item label="新的并发数" required>
|
||||
<el-input-number v-model="concurrencyForm" :min="1" :max="100" controls-position="right" />
|
||||
<span class="form-hint">允许范围 1–100</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="concurrencyVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="concurrencySubmitting" @click="submitConcurrency">保存并立即生效</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Setting } from '@element-plus/icons-vue'
|
||||
import {
|
||||
abortDataMigrate,
|
||||
getDataMigrateConcurrency,
|
||||
getDataMigrateProgress,
|
||||
getVmMigrationRecordList,
|
||||
updateDataMigrateConcurrency
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
|
||||
const router = useRouter()
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const serviceId = computed(() => Number(injectedServiceId?.value) || 0)
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const queueRecords = ref([])
|
||||
const abortingId = ref(null)
|
||||
const concurrency = reactive({ concurrency: 1, active_count: 0, queued_count: 0, available_slots: 0, updated_at: '' })
|
||||
const activeStatuses = new Set(['pending', 'starting', 'exporting', 'importing', 'running', 'aborting', 'recovering'])
|
||||
const listQueryStatuses = ['pending', 'exporting', 'importing', 'running']
|
||||
let refreshTimer = null
|
||||
let requestInFlight = false
|
||||
|
||||
const summaryItems = computed(() => [
|
||||
{ key: 'concurrency', label: '并发上限', value: concurrency.concurrency, note: '全局同时执行数量', className: 'primary' },
|
||||
{ key: 'active', label: '执行中', value: concurrency.active_count, note: '正在占用迁移名额', className: 'success' },
|
||||
{ key: 'queued', label: '排队中', value: concurrency.queued_count, note: '按创建顺序等待', className: 'warning' },
|
||||
{ key: 'available', label: '空余名额', value: concurrency.available_slots, note: '可立即放行任务数', className: 'info' }
|
||||
])
|
||||
|
||||
const statusLabel = status => ({
|
||||
pending: '排队中', starting: '启动中', exporting: '导出中', importing: '导入中', running: '执行中',
|
||||
aborting: '中断中', recovering: '恢复中', success: '成功', warning: '警告', failed: '失败', aborted: '已中断'
|
||||
})[status] || status || '-'
|
||||
const statusType = status => ({ pending: 'info', starting: 'warning', exporting: 'warning', importing: 'primary', running: 'primary', aborting: 'danger', recovering: 'warning' })[status] || 'info'
|
||||
const progressValue = value => Math.max(0, Math.min(100, Number(value) || 0))
|
||||
const progressStatus = status => status === 'failed' ? 'exception' : status === 'success' ? 'success' : undefined
|
||||
const formatTime = value => {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
const fetchDirectionRecords = async (direction, status) => {
|
||||
const result = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
do {
|
||||
const params = { page, count: 100, migration_type: 'data_migrate', status, [`${direction}_service_id`]: serviceId.value }
|
||||
const res = await getVmMigrationRecordList(params)
|
||||
if (res?.data?.code !== 200) throw new Error(extractApiError(res?.data, '加载迁移队列失败'))
|
||||
const data = res.data.data || {}
|
||||
const rows = Array.isArray(data.data) ? data.data : []
|
||||
result.push(...rows)
|
||||
total = Number(data.all_count) || rows.length
|
||||
page += 1
|
||||
} while (result.length < total && page <= 20)
|
||||
return result
|
||||
}
|
||||
|
||||
const loadData = async ({ silent = false } = {}) => {
|
||||
if (!serviceId.value || requestInFlight) return
|
||||
requestInFlight = true
|
||||
if (!silent) loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const concurrencyRes = await getDataMigrateConcurrency()
|
||||
if (concurrencyRes?.data?.code !== 200) throw new Error(extractApiError(concurrencyRes?.data, '加载并发状态失败'))
|
||||
Object.assign(concurrency, concurrencyRes.data.data || {})
|
||||
|
||||
const recordResults = await Promise.allSettled(
|
||||
['source', 'target'].flatMap(direction => listQueryStatuses.map(status => fetchDirectionRecords(direction, status)))
|
||||
)
|
||||
const successfulGroups = recordResults.filter(result => result.status === 'fulfilled').map(result => result.value)
|
||||
if (!successfulGroups.length) throw new Error('加载迁移任务失败')
|
||||
const allRows = successfulGroups.flat()
|
||||
const uniqueRows = allRows.filter((row, index, rows) => rows.findIndex(item => item.id === row.id) === index)
|
||||
const candidates = uniqueRows.filter(row => activeStatuses.has(row.status))
|
||||
const progressResults = await Promise.allSettled(candidates.map(row => getDataMigrateProgress({ migration_record_id: row.id })))
|
||||
queueRecords.value = candidates.map((row, index) => {
|
||||
const result = progressResults[index]
|
||||
const progress = result.status === 'fulfilled' && result.value?.data?.code === 200 ? result.value.data.data : null
|
||||
return progress ? { ...row, ...progress } : row
|
||||
}).filter(row => activeStatuses.has(row.status)).sort((a, b) => {
|
||||
const aQueued = Number(a.queue_position) > 0
|
||||
const bQueued = Number(b.queue_position) > 0
|
||||
if (aQueued !== bQueued) return aQueued ? 1 : -1
|
||||
if (aQueued) return Number(a.queue_position) - Number(b.queue_position)
|
||||
return new Date(a.started_at || a.CreatedAt || 0) - new Date(b.started_at || b.CreatedAt || 0)
|
||||
})
|
||||
} catch (error) {
|
||||
loadError.value = error.message || extractApiError(error?.response?.data, '加载迁移队列失败')
|
||||
if (!silent) ElMessage.error(loadError.value)
|
||||
} finally {
|
||||
if (!silent) loading.value = false
|
||||
requestInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
const concurrencyVisible = ref(false)
|
||||
const concurrencyForm = ref(1)
|
||||
const concurrencySubmitting = ref(false)
|
||||
const openConcurrencyDialog = () => {
|
||||
concurrencyForm.value = Number(concurrency.concurrency) || 1
|
||||
concurrencyVisible.value = true
|
||||
}
|
||||
const submitConcurrency = async () => {
|
||||
concurrencySubmitting.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('concurrency', concurrencyForm.value)
|
||||
const res = await updateDataMigrateConcurrency(formData)
|
||||
if (res?.data?.code !== 200) throw new Error(extractApiError(res?.data, '更新迁移并发失败'))
|
||||
const result = res.data.data || {}
|
||||
ElMessage.success(`并发数已从 ${result.old_concurrency ?? concurrency.concurrency} 调整为 ${result.new_concurrency ?? concurrencyForm.value},放行 ${result.released_count || 0} 项任务`)
|
||||
concurrencyVisible.value = false
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || extractApiError(error?.response?.data, '更新迁移并发失败'))
|
||||
} finally {
|
||||
concurrencySubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const abortMigration = async row => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定中断 VM #${row.source_vm_id} 的迁移任务吗?排队中和执行中的任务均会被终止。`, '中断迁移', {
|
||||
confirmButtonText: '确定中断', cancelButtonText: '取消', type: 'warning'
|
||||
})
|
||||
} catch { return }
|
||||
abortingId.value = row.id
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('service_id', row.source_service_id)
|
||||
formData.append('vm_id', row.source_vm_id)
|
||||
const res = await abortDataMigrate(formData)
|
||||
if (res?.data?.code !== 200) throw new Error(extractApiError(res?.data, '中断迁移失败'))
|
||||
ElMessage.success('迁移任务已中断')
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || extractApiError(error?.response?.data, '中断迁移失败'))
|
||||
} finally {
|
||||
abortingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const goService = id => router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: id } })
|
||||
const goHost = (targetServiceId, hostId) => router.push({ path: '/virtualization/host-detail', query: { service_id: targetServiceId, host_id: hostId } })
|
||||
const goVm = (targetServiceId, vmId) => router.push({ path: '/virtualization/vm-detail', query: { service_id: targetServiceId, vm_id: vmId } })
|
||||
const goUser = userId => router.push({ path: '/user/detail', query: { user_id: userId } })
|
||||
const goUserGoods = id => router.push(`/user-goods/detail/${id}`)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
refreshTimer = window.setInterval(() => loadData({ silent: true }), 5000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshTimer) window.clearInterval(refreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.migration-queue-page { padding: 20px; }
|
||||
.queue-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.queue-header h2 { margin: 0 0 6px; color: #1d2129; font-size: 20px; }
|
||||
.queue-header p { margin: 0; color: #86909c; font-size: 13px; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.summary-card :deep(.el-card__body) { padding: 16px 18px; }
|
||||
.summary-label { color: #86909c; font-size: 13px; }
|
||||
.summary-value { margin: 5px 0; color: #303133; font-size: 28px; font-weight: 700; line-height: 1.2; }
|
||||
.summary-value.primary { color: #409eff; }
|
||||
.summary-value.success { color: #67c23a; }
|
||||
.summary-value.warning { color: #e6a23c; }
|
||||
.summary-value.info { color: #909399; }
|
||||
.summary-note, .updated-at, .muted, .progress-meta { color: #86909c; font-size: 12px; }
|
||||
.queue-alert { margin-bottom: 16px; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; font-weight: 600; }
|
||||
.updated-at { font-weight: 400; }
|
||||
.stack-cell { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||
.progress-meta { display: flex; justify-content: space-between; gap: 8px; margin-top: 3px; }
|
||||
.dialog-alert { margin-bottom: 18px; }
|
||||
.form-hint { margin-left: 10px; color: #909399; font-size: 12px; }
|
||||
@media (max-width: 900px) {
|
||||
.queue-header { flex-direction: column; }
|
||||
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user