Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/VmMigrationQueue.vue
T
shiran 6d47314a9a
Build and Deploy Vue3 / build (push) Successful in 1m41s
Build and Deploy Vue3 / deploy (push) Failing after 53s
feat: adapt migration queue aggregate APIs
2026-08-04 17:09:28 +08:00

312 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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.global_queue_position > 0" type="info">#{{ row.global_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.migration_record_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.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" :loading="abortingId === row.migration_record_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-pagination
v-if="total > pageSize"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
class="queue-pagination"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</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, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Setting } from '@element-plus/icons-vue'
import {
abortDataMigrateQueue,
getDataMigrateQueue,
updateDataMigrateConcurrency
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
const router = useRouter()
const injectedServiceId = inject('serviceId', null)
const activeDetailTab = inject('activeKvmDetailTab', null)
const serviceId = computed(() => Number(injectedServiceId?.value) || 0)
const loading = ref(false)
const loadError = ref('')
const queueRecords = ref([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const revision = ref(0)
const abortingId = ref(null)
const concurrency = reactive({ concurrency: 1, active_count: 0, queued_count: 0, available_slots: 0, updated_at: '' })
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 loadData = async ({ silent = false } = {}) => {
if (!serviceId.value || requestInFlight) return
requestInFlight = true
if (!silent) loading.value = true
loadError.value = ''
try {
const params = { service_id: serviceId.value, relation: 'any', page: page.value, count: pageSize.value }
if (silent && revision.value) params.after_revision = revision.value
const res = await getDataMigrateQueue(params)
if (res?.data?.code !== 200) throw new Error(extractApiError(res?.data, '加载迁移队列失败'))
const data = res.data.data || {}
revision.value = Number(data.revision) || revision.value
if (data.changed === false) return
queueRecords.value = Array.isArray(data.data) ? data.data : []
total.value = Number(data.all_count) || 0
Object.assign(concurrency, data.summary || {})
} 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
if (result.summary) Object.assign(concurrency, result.summary)
revision.value = 0
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.migration_record_id
try {
const formData = new FormData()
formData.append('migration_record_id', row.migration_record_id)
const res = await abortDataMigrateQueue(formData)
if (res?.data?.code !== 200) throw new Error(extractApiError(res?.data, '中断迁移失败'))
const result = res.data.data || {}
ElMessage.success(`迁移任务已中断,释放 ${result.released_count || 0} 个队列任务`)
revision.value = 0
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}`)
const startPolling = () => {
if (!refreshTimer) refreshTimer = window.setInterval(() => loadData({ silent: true }), 5000)
}
const stopPolling = () => {
if (refreshTimer) window.clearInterval(refreshTimer)
refreshTimer = null
}
const handlePageChange = () => { revision.value = 0; loadData() }
const handleSizeChange = () => { page.value = 1; revision.value = 0; loadData() }
onMounted(async () => {
if (!activeDetailTab || activeDetailTab.value === 'migration_queue') {
await loadData()
startPolling()
}
})
watch(() => activeDetailTab?.value, async tab => {
if (tab === 'migration_queue') {
revision.value = 0
await loadData()
startPolling()
} else stopPolling()
})
onBeforeUnmount(() => {
stopPolling()
})
</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; }
.queue-pagination { justify-content: flex-end; margin-top: 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>