feat: add migration queue management and improve VM IP display
Build and Deploy Vue3 / build (push) Successful in 1m45s
Build and Deploy Vue3 / deploy (push) Successful in 55s

This commit is contained in:
shiran
2026-08-04 16:26:25 +08:00
parent f10c8fc920
commit 57fc4a3fcb
6 changed files with 474 additions and 42 deletions
+12
View File
@@ -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 })
+67 -19
View File
@@ -91,8 +91,28 @@
<span v-else class="vm-secondary-text">-</span>
</template>
</el-table-column>
<el-table-column label="IP 地址" min-width="200" show-overflow-tooltip>
<template #default="{ row }">{{ formatVmIps(getRowVm(row)) || '-' }}</template>
<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 }">
@@ -953,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'
@@ -991,27 +1011,48 @@ const formatMemory = (kb) => {
return kb + ' KB'
}
const getRowVm = (row) => row?._vm || row?.vm?.data || row?.vm || row?.user_vm || null
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 formatVmIps = (vm) => {
if (!vm) return ''
if (Array.isArray(vm.ips)) return vm.ips.filter(Boolean).join(', ')
if (typeof vm.ips === 'string' && vm.ips.trim()) return vm.ips.split(',').map(ip => ip.trim()).filter(Boolean).join(', ')
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)
}).filter(Boolean).join(', ')
}).map(ip => String(ip).trim()).filter(Boolean).reverse()
}
const loadVmSummaries = async (rows) => {
const targets = rows.filter(row => row?.id && (row.itemId || row.item_id) && !getRowVm(row))
await Promise.allSettled(targets.map(async row => {
const res = await getUserVmDetail({ user_goods_id: row.id })
if (res?.data?.code !== 200 || !res?.data?.data) return
const detail = res.data.data
row._vm = detail.vm?.data ?? detail.vm ?? null
}))
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) => {
@@ -1047,9 +1088,9 @@ 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
await loadVmSummaries(list.value)
} else {
list.value = []
total.value = 0
@@ -1902,6 +1943,13 @@ onMounted(() => {
.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>
+29 -15
View File
@@ -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) => {
+50 -8
View File
@@ -68,9 +68,26 @@
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="IP 地址" min-width="190" show-overflow-tooltip>
<el-table-column label="IP 地址" min-width="230">
<template #default="{ row }">
<span v-if="formatVmIps(row)" class="vm-ip-text">{{ formatVmIps(row) }}</span>
<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>
@@ -519,7 +536,7 @@
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, Promotion } 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,
@@ -906,15 +923,33 @@ const formatMemKB = (kb) => {
}
const formatMemory = formatMemKB
const formatVmIps = (vm) => {
if (!vm) return ''
if (Array.isArray(vm.ips)) return vm.ips.filter(Boolean).join(', ')
if (typeof vm.ips === 'string' && vm.ips.trim()) return vm.ips.split(',').map(ip => ip.trim()).filter(Boolean).join(', ')
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)
}).filter(Boolean).join(', ')
}).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) => {
@@ -1351,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; }
@@ -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">允许范围 1100</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>