312 lines
15 KiB
Vue
312 lines
15 KiB
Vue
<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">允许范围 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, 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>
|