fix: improve VM migration state and chain details #34

Merged
shiran merged 1 commits from master into deploy 2026-08-04 19:03:01 +08:00
2 changed files with 46 additions and 14 deletions
+10 -12
View File
@@ -2048,15 +2048,17 @@ const loadDetail = async () => {
const handleDetailMigrateState = () => {
const vm = detail.value
if (!vm) return
if (vm.migrating && vm.migrate_task_id) {
dataMigrateTaskId.value = vm.migrate_task_id
if (!dataMigrationId.value) dataMigrationId.value = vm.migrate_task_id
if (!migratePollingTimer) {
loadDataMigrateProgress()
startMigratePolling()
if (vm.migrating === true) {
if (vm.migrate_task_id) {
dataMigrateTaskId.value = vm.migrate_task_id
if (!dataMigrationId.value) dataMigrationId.value = vm.migrate_task_id
if (!migratePollingTimer) {
loadDataMigrateProgress()
startMigratePolling()
}
}
startDetailAutoRefresh()
} else if (!vm.migrating) {
} else {
stopDetailAutoRefresh()
if (migratePollingTimer) {
stopMigratePolling()
@@ -2905,11 +2907,7 @@ const loadDataMigrateProgress = async () => {
const currentMigrateStage = computed(() => dataMigrateProgressData.value?.status || dataMigrateProgressData.value?.stage || '')
const isMigrating = computed(() => {
if (detail.value?.migrating) return true
const stage = currentMigrateStage.value
return stage && !MIGRATE_DONE_STAGES.includes(stage)
})
const isMigrating = computed(() => detail.value?.migrating === true)
const migrateStageLabel = (stage) => ({
exporting: '导出中', importing: '导入中', transferring: '传输中',
+36 -2
View File
@@ -91,7 +91,27 @@
<div class="chain-id">链路 ID<span class="mono">{{ chainData.chain_id || '-' }}</span></div>
<el-timeline v-if="chainData.records?.length">
<el-timeline-item v-for="item in chainData.records" :key="item.id" :type="statusType(item.status)" :timestamp="formatTime(item.started_at || item.CreatedAt)" placement="top">
<el-card shadow="never"><div class="chain-card-title"><span>#{{ item.id }} · {{ item.chain_seq || 1 }} · {{ typeLabel(item.migration_type) }}</span><el-tag :type="statusType(item.status)" size="small">{{ statusLabel(item.status) }}</el-tag></div><div class="chain-route">服务 {{ item.source_service_id || '-' }} / VM {{ item.source_vm_id || '-' }} 服务 {{ item.target_service_id || '-' }} / VM {{ item.target_vm_id || '-' }}</div><el-button link type="primary" @click="chainVisible=false; openDetail(item)">查看详情</el-button></el-card>
<el-card shadow="never">
<div class="chain-card-title"><span>#{{ item.id }} · {{ item.chain_seq || 1 }} · {{ typeLabel(item.migration_type) }}</span><el-tag :type="statusType(item.status)" size="small">{{ statusLabel(item.status) }}</el-tag></div>
<div class="chain-route">
<div class="chain-endpoint">
<span class="chain-endpoint-label">源虚拟机</span>
<el-link v-if="item.source_vm_id" type="primary" @click="goVm(item.source_service_id, item.source_vm_id)">{{ chainVmName(item, 'source') || `VM #${item.source_vm_id}` }}</el-link>
<strong v-else>{{ chainVmName(item, 'source') || '-' }}</strong>
<span class="muted">服务 #{{ item.source_service_id || '-' }} · VM #{{ item.source_vm_id || '-' }}</span>
<span class="chain-ip">IP{{ chainVmIps(item, 'source') || '-' }}</span>
</div>
<span class="chain-arrow"></span>
<div class="chain-endpoint">
<span class="chain-endpoint-label">目标虚拟机</span>
<el-link v-if="item.target_vm_id" type="primary" @click="goVm(item.target_service_id, item.target_vm_id)">{{ chainVmName(item, 'target') || `VM #${item.target_vm_id}` }}</el-link>
<strong v-else>{{ chainVmName(item, 'target') || '-' }}</strong>
<span class="muted">服务 #{{ item.target_service_id || '-' }} · VM #{{ item.target_vm_id || '-' }}</span>
<span class="chain-ip">IP{{ chainVmIps(item, 'target') || '-' }}</span>
</div>
</div>
<el-button link type="primary" @click="chainVisible=false; openDetail(item)">查看详情</el-button>
</el-card>
</el-timeline-item>
</el-timeline><el-empty v-else description="暂无迁移链记录" />
</div>
@@ -138,6 +158,20 @@ const resetFilters = () => { Object.assign(filters,{ keyword:'',migration_type:'
const detailVisible=ref(false), detailLoading=ref(false), currentRecord=ref(null)
const snapshotFields=[{key:'request_snapshot',label:'请求参数'},{key:'before_snapshot',label:'迁移前快照'},{key:'after_snapshot',label:'迁移后快照'},{key:'manifest_snapshot',label:'迁移清单'},{key:'warning_snapshot',label:'警告'}]
const prettySnapshot = value => { if(!value) return '暂无数据'; try { return JSON.stringify(typeof value==='string'?JSON.parse(value):value,null,2) } catch { return String(value) } }
const parseSnapshot = value => { if (!value) return {}; if (typeof value === 'object') return value; try { return JSON.parse(value) } catch { return {} } }
const chainVmSnapshot = (record, side) => parseSnapshot(side === 'source' ? record.before_snapshot : record.after_snapshot)
const chainVmName = (record, side) => {
const snapshot = chainVmSnapshot(record, side)
return record[`${side}_vm_name`] || snapshot.vm_detail?.data?.name || snapshot.vm_summary?.vm_name || ''
}
const chainVmIps = (record, side) => {
const snapshot = chainVmSnapshot(record, side)
const directIps = snapshot.vm_detail?.data?.ips
const values = directIps
? String(directIps).split(',')
: [...(snapshot.ip_addresses?.ipv4 || []), ...(snapshot.ip_addresses?.ipv6 || [])]
return [...new Set(values.map(ip => String(ip).trim()).filter(Boolean))].join(', ')
}
const openDetail = async row => { detailVisible.value=true; currentRecord.value=row; detailLoading.value=true; try { const res=await getVmMigrationRecordDetail({id:row.id}); if(res?.data?.code!==200) throw new Error(extractApiError(res?.data,'加载详情失败')); currentRecord.value=res.data.data?.record||res.data.data||row } catch(e){ ElMessage.error(e.message||extractApiError(e?.response?.data,'加载详情失败')) } finally { detailLoading.value=false } }
const chainVisible=ref(false), chainLoading=ref(false), chainData=reactive({chain_id:'',records:[]})
const openChain = async row => { chainVisible.value=true; chainLoading.value=true; Object.assign(chainData,{chain_id:row.chain_id||'',records:[]}); try { const params=row.chain_id?{chain_id:row.chain_id}:{id:row.id}; const res=await getVmMigrationRecordChain(params); if(res?.data?.code!==200) throw new Error(extractApiError(res?.data,'加载迁移链失败')); Object.assign(chainData,res.data.data||{}) } catch(e){ ElMessage.error(e.message||extractApiError(e?.response?.data,'加载迁移链失败')) } finally { chainLoading.value=false } }
@@ -154,5 +188,5 @@ onMounted(async () => {
</script>
<style scoped>
.migration-record-page{padding:20px}.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-header h2{font-size:20px;margin:0 0 6px}.page-header p{margin:0;color:#86909c;font-size:13px}.filter-card{margin-bottom:16px}.filter-card :deep(.el-form-item){margin-bottom:8px}.selector-field{display:flex;gap:6px;width:300px}.selector-field .el-input{flex:1}.endpoint{display:flex;flex-direction:column;align-items:flex-start;gap:3px}.mono{font-family:Consolas,monospace;font-size:12px}.muted{font-size:12px;color:#86909c;margin-top:3px}.pagination{display:flex;justify-content:flex-end;margin-top:16px}.snapshot-tabs{margin-top:18px}.json-view{margin:0;padding:14px;max-height:460px;overflow:auto;background:#f7f8fa;border:1px solid #ebeef5;border-radius:6px;white-space:pre-wrap;word-break:break-all;font:12px/1.6 Consolas,monospace}.error-text{color:#f56c6c}.chain-id{margin-bottom:20px;color:#606266}.chain-card-title{display:flex;justify-content:space-between;align-items:center;font-weight:600}.chain-route{margin:10px 0;color:#606266;font-size:13px}
.migration-record-page{padding:20px}.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-header h2{font-size:20px;margin:0 0 6px}.page-header p{margin:0;color:#86909c;font-size:13px}.filter-card{margin-bottom:16px}.filter-card :deep(.el-form-item){margin-bottom:8px}.selector-field{display:flex;gap:6px;width:300px}.selector-field .el-input{flex:1}.endpoint{display:flex;flex-direction:column;align-items:flex-start;gap:3px}.mono{font-family:Consolas,monospace;font-size:12px}.muted{font-size:12px;color:#86909c;margin-top:3px}.pagination{display:flex;justify-content:flex-end;margin-top:16px}.snapshot-tabs{margin-top:18px}.json-view{margin:0;padding:14px;max-height:460px;overflow:auto;background:#f7f8fa;border:1px solid #ebeef5;border-radius:6px;white-space:pre-wrap;word-break:break-all;font:12px/1.6 Consolas,monospace}.error-text{color:#f56c6c}.chain-id{margin-bottom:20px;color:#606266}.chain-card-title{display:flex;justify-content:space-between;align-items:center;font-weight:600}.chain-route{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:16px;margin:14px 0;color:#606266;font-size:13px}.chain-endpoint{display:flex;flex-direction:column;align-items:flex-start;gap:4px;min-width:0;padding:12px;background:#f7f8fa;border-radius:6px}.chain-endpoint-label{font-size:12px;color:#86909c}.chain-arrow{color:#409eff;font-size:20px;font-weight:600}.chain-ip{max-width:100%;font-family:Consolas,monospace;font-size:12px;word-break:break-all}
</style>