feat: 对接主控服务接口
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="vm-detail-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回虚拟机列表</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">虚拟机详情</span>
|
||||
<el-tag v-if="detail" :type="vmStatusType(detail.status)" size="small" style="margin-left: 8px">{{ vmStatusLabel(detail.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content" v-loading="loading">
|
||||
<!-- 基本信息 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">基本信息</span></template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="CPU">{{ detail.vcpu ? detail.vcpu + ' 核' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="内存">{{ formatMemory(detail.memory) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="系统盘">{{ detail.system_size ? detail.system_size + ' MB' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="镜像ID">{{ detail.image_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机">{{ getHostLabel(detail.host_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机组ID">{{ detail.host_group_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下行带宽">{{ detail.rx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="上行带宽">{{ detail.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{{ detail.user_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP数量">{{ detail.ip_num || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 电源控制 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">电源控制</span></template>
|
||||
<div class="action-buttons">
|
||||
<el-button type="success" @click="handlePower('start')" :disabled="detail.status === 'running'">启动</el-button>
|
||||
<el-button type="warning" @click="handlePower('stop')" :disabled="detail.status === 'stopped' || detail.status === 'stop'">停止</el-button>
|
||||
<el-button type="primary" @click="handlePower('reboot')">重启</el-button>
|
||||
<el-button type="info" @click="handlePower('suspend')">暂停</el-button>
|
||||
<el-button type="success" @click="handlePower('resume')" v-if="detail.status === 'paused'">恢复</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button @click="handleRebuild">重建</el-button>
|
||||
<el-button type="warning" @click="handleRescue">救援模式</el-button>
|
||||
<el-button @click="handleExitRescue">退出救援</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button @click="fetchVmStatus" :loading="statusLoading">刷新状态</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 实时指标 -->
|
||||
<el-card shadow="never" class="info-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span class="card-title">实时指标</span>
|
||||
<el-button size="small" :icon="Refresh" @click="fetchVmMetrics" :loading="metricsLoading">刷新指标</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="metricsLoading">
|
||||
<template v-if="metricsData">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12" v-if="metricsData.cpu">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="使用率">{{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="核心数">{{ metricsData.cpu.cpu_count ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="metricsData.memory">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.memory.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.memory.used) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
<el-empty v-else description="暂无指标数据,点击刷新加载" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 子表格:网络/数据卷/镜像 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail && (detail.networks?.length || detail.volumes?.length)">
|
||||
<template #header><span class="card-title">关联资源</span></template>
|
||||
<template v-if="detail.networks?.length">
|
||||
<h4 style="margin: 0 0 8px; color: #303133">网络</h4>
|
||||
<el-table :data="detail.networks" size="small" stripe border style="margin-bottom: 16px">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column prop="mac" label="MAC" min-width="140" />
|
||||
<el-table-column prop="ip" label="IP" min-width="120" />
|
||||
</el-table>
|
||||
</template>
|
||||
<template v-if="detail.volumes?.length">
|
||||
<h4 style="margin: 0 0 8px; color: #303133">数据卷</h4>
|
||||
<el-table :data="detail.volumes" size="small" stripe border>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column prop="size" label="大小" width="100" />
|
||||
<el-table-column prop="path" label="路径" min-width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 重建弹窗 -->
|
||||
<el-dialog v-model="rebuildDialogVisible" title="重建虚拟机" width="480px" destroy-on-close>
|
||||
<el-alert title="重建会清除当前虚拟机数据并使用新镜像重新创建,请谨慎操作!" type="warning" :closable="false" style="margin-bottom: 16px" />
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="虚拟机">{{ detail?.name || '-' }}</el-form-item>
|
||||
<el-form-item label="新镜像" required>
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="rebuildImageId ? `${rebuildImageName || ''} (ID: ${rebuildImageId})` : ''" readonly placeholder="请选择镜像" style="flex: 1" />
|
||||
<el-button type="primary" @click="showImageSelector = true">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rebuildDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="actionLoading" @click="submitRebuild">确定重建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<ImageSelectorPopup v-model="showImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="img => { rebuildImageId = img.id; rebuildImageName = img.name }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Monitor, Coin } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getVmDetail, getVmStatus, getVmMetrics,
|
||||
startVm, stopVm, rebootVm, suspendVm, resumeVm,
|
||||
rebuildVm, rescueVm, exitRescueVm, deleteVm, getRemoteHostList
|
||||
} from '@/api/admin/kvmService'
|
||||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => route.query.service_name || '')
|
||||
const vmId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const statusLoading = ref(false)
|
||||
const metricsLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const metricsData = ref(null)
|
||||
const hostOptions = ref([])
|
||||
const rebuildDialogVisible = ref(false)
|
||||
const rebuildImageId = ref(0)
|
||||
const rebuildImageName = ref('')
|
||||
const showImageSelector = ref(false)
|
||||
|
||||
const vmStatusType = (s) => ({ running: 'success', ready: 'success', creating: 'warning', pending: 'info', stopped: 'danger', stop: 'danger', error: 'danger', paused: 'warning', reboot: 'warning', poweroff: 'info', unknown: 'info' }[s] || 'info')
|
||||
const vmStatusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中', stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停', reboot: '重启中', poweroff: '已关机', unknown: '未知' }[s] || s || '-')
|
||||
|
||||
const formatMemory = (kb) => { if (!kb) return '-'; if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'; if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'; return kb + ' KB' }
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
|
||||
return '-'
|
||||
}
|
||||
const formatBytesRaw = (val) => {
|
||||
if (!val && val !== 0) return '-'; val = Number(val)
|
||||
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||||
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||||
return val + ' B'
|
||||
}
|
||||
const getHostLabel = (hid) => { const h = hostOptions.value.find(x => x.id === hid); return h ? h.name : (hid || '-') }
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!vmId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getVmDetail({ service_id: serviceId.value, vm_id: vmId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
detail.value = d.vm ?? d.data ?? d
|
||||
} else ElMessage.error(res?.data?.message || '加载失败')
|
||||
} catch { ElMessage.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const fetchVmStatus = async () => {
|
||||
if (!detail.value) return
|
||||
statusLoading.value = true
|
||||
try {
|
||||
const res = await getVmStatus({ service_id: serviceId.value, vm_id: vmId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const sd = res.data.data
|
||||
detail.value = { ...detail.value, status: sd.status ?? sd }
|
||||
ElMessage.success('状态已刷新: ' + vmStatusLabel(detail.value.status))
|
||||
}
|
||||
} catch { ElMessage.error('获取状态失败') } finally { statusLoading.value = false }
|
||||
}
|
||||
|
||||
const fetchVmMetrics = async () => {
|
||||
if (!detail.value) return
|
||||
metricsLoading.value = true
|
||||
try {
|
||||
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: detail.value.name })
|
||||
if (res?.data?.code === 200) metricsData.value = res.data.data?.data ?? res.data.data
|
||||
else ElMessage.warning('暂无指标数据')
|
||||
} catch { ElMessage.error('获取指标失败') } finally { metricsLoading.value = false }
|
||||
}
|
||||
|
||||
const handlePower = (action) => {
|
||||
const labels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
|
||||
ElMessageBox.confirm(`确定要${labels[action]}虚拟机「${detail.value?.name}」吗?`, `${labels[action]}确认`, {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: action === 'stop' ? 'warning' : 'info'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
|
||||
let res
|
||||
if (action === 'resume') {
|
||||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
|
||||
res = await resumeVm(fd)
|
||||
} else {
|
||||
res = await apis[action]({ service_id: serviceId.value, vm_id: vmId.value })
|
||||
}
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${labels[action]}成功`); loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || `${labels[action]}失败`)
|
||||
} catch { ElMessage.error(`${labels[action]}失败`) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleRebuild = () => {
|
||||
rebuildImageId.value = detail.value?.image_id || 0
|
||||
rebuildImageName.value = ''
|
||||
rebuildDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitRebuild = async () => {
|
||||
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await rebuildVm({ service_id: serviceId.value, vm_id: vmId.value, image_id: rebuildImageId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('重建成功'); rebuildDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '重建失败')
|
||||
} catch { ElMessage.error('重建失败') } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const handleRescue = () => {
|
||||
ElMessageBox.confirm(`确定让虚拟机「${detail.value?.name}」进入救援模式吗?`, '救援模式', {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
|
||||
try {
|
||||
const res = await rescueVm(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('已进入救援模式'); loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleExitRescue = () => {
|
||||
ElMessageBox.confirm(`确定让虚拟机「${detail.value?.name}」退出救援模式吗?`, '退出救援', {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
|
||||
}).then(async () => {
|
||||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
|
||||
try {
|
||||
const res = await exitRescueVm(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('已退出救援模式'); loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
tagsViewStore.delVisitedView(route)
|
||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||||
}
|
||||
|
||||
onMounted(() => { loadHostOptions(); loadDetail() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vm-detail-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 0; }
|
||||
.back-btn { font-size: 14px; color: #606266; }
|
||||
.back-btn:hover { color: #409eff; }
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.main-content { padding: 20px; }
|
||||
.info-card { margin-bottom: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
|
||||
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.metrics-card { margin-bottom: 0; }
|
||||
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user