feat(admin+user): 虚拟机断网/恢复网络+每小时流量图表+宿主机额度统计 -- 缘由: 后端新增disconnect/connect_network,traffic_hourly,quota_stats接口,VM新增network_disabled字段 -- 预期: VmDetail/UserVmDetail/用户详情支持断网恢复操作并显示断网状态,VmDetail新增流量统计tab,HostDetail新增额度统计tab

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shiran
2026-05-15 16:29:18 +08:00
parent 564e6cc017
commit a5f8a9ef13
5 changed files with 249 additions and 5 deletions
+118 -2
View File
@@ -31,6 +31,8 @@
<el-dropdown-item command="refactorVm" :disabled="isMigrating">重构虚拟机</el-dropdown-item>
<el-dropdown-item command="updateTraffic" :disabled="isMigrating">修改带宽</el-dropdown-item>
<el-dropdown-item command="resetMac" :disabled="isMigrating">重置MAC地址</el-dropdown-item>
<el-dropdown-item command="disconnectNetwork" :disabled="isMigrating || detail.network_disabled">断网</el-dropdown-item>
<el-dropdown-item command="connectNetwork" :disabled="isMigrating || !detail.network_disabled">恢复网络</el-dropdown-item>
<el-dropdown-item divided command="rebuild" :disabled="isMigrating">重装虚拟机</el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
@@ -51,6 +53,7 @@
<span class="status-value">
<span class="status-dot" :class="detail.status === 'running' ? 'dot-running' : 'dot-other'"></span>
{{ vmStatusLabel(detail.status) }}
<el-tag v-if="detail.network_disabled" type="danger" size="small" effect="dark" style="margin-left:8px">已断网</el-tag>
<el-tag v-if="isMigrating" type="warning" size="small" effect="dark" style="margin-left:8px">迁移中</el-tag>
</span>
</div>
@@ -664,6 +667,34 @@
</div>
</el-tab-pane>
<!-- 每小时流量 -->
<el-tab-pane label="流量统计" name="trafficHourly">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">每小时流量</h3>
<div style="display: flex; align-items: center; gap: 8px">
<el-date-picker
v-model="trafficHourlyRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
size="small"
style="width: 360px"
:shortcuts="monitorShortcuts"
@change="loadTrafficHourly"
/>
<el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button>
</div>
</div>
<el-card v-if="trafficHourlyData.length" shadow="hover" class="metrics-card" style="margin-top:12px">
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 每小时流量(MB</span></template>
<div ref="trafficHourlyChartRef" class="chart-container"></div>
</el-card>
<el-empty v-else-if="!trafficHourlyLoading" description="暂无流量统计数据" :image-size="60" />
</div>
</el-tab-pane>
<!-- 流量策略 -->
<el-tab-pane label="流量策略" name="vmTrafficPolicy">
<div class="section-block">
@@ -1606,7 +1637,8 @@ import {
dataMigrateVm, getDataMigrateProgress, abortDataMigrate,
getKvmServiceList, getMetricsHistory, getNetworkList,
getVmTrafficPolicy, updateVmTrafficPolicy, addVmFixedTraffic, addVmTemporaryTraffic,
setNetworkPrimary, resetVmMac
setNetworkPrimary, resetVmMac,
disconnectVmNetwork, connectVmNetwork, getVmTrafficHourly
} from '@/api/admin/kvmService'
import { getUserInfo } from '@/api/admin/user'
import { extractApiError } from '@/utils/kvmErrorUtil'
@@ -1744,7 +1776,8 @@ const handleMoreCommand = (cmd) => {
const actionMap = {
editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic,
rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue,
migrateVm: handleMigrateVm, resetMac: handleResetMac
migrateVm: handleMigrateVm, resetMac: handleResetMac,
disconnectNetwork: handleDisconnectNetwork, connectNetwork: handleConnectNetwork
}
if (actionMap[cmd]) actionMap[cmd]()
if (cmd === 'dataMigrateVm') handleDataMigrateVm()
@@ -2866,6 +2899,89 @@ const handleResetMac = () => {
}).catch(() => {})
}
const handleDisconnectNetwork = () => {
ElMessageBox.confirm(
'断开虚拟机外部网络连接?断网后虚拟机将无法访问外部网络。',
'断网', { confirmButtonText: '确定断网', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', detail.value?.id)
const res = await disconnectVmNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success('已断网'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '断网失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '断网失败')) }
}).catch(() => {})
}
const handleConnectNetwork = () => {
ElMessageBox.confirm(
'恢复虚拟机外部网络连接?',
'恢复网络', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', detail.value?.id)
const res = await connectVmNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success('网络已恢复'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '恢复网络失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复网络失败')) }
}).catch(() => {})
}
// ---- 每小时流量统计 ----
const trafficHourlyRange = ref(null)
const trafficHourlyData = ref([])
const trafficHourlyLoading = ref(false)
const trafficHourlyChartRef = ref(null)
let trafficHourlyChart = null
const loadTrafficHourly = async () => {
if (!serviceId.value || !detail.value?.host_id || !detail.value?.name) return
if (!trafficHourlyRange.value) {
const now = new Date()
trafficHourlyRange.value = [new Date(now.getTime() - 24 * 3600 * 1000), now]
}
trafficHourlyLoading.value = true
try {
const res = await getVmTrafficHourly({
service_id: serviceId.value,
host_id: detail.value.host_id,
vm_name: detail.value.name,
start: new Date(trafficHourlyRange.value[0]).toISOString(),
end_time: new Date(trafficHourlyRange.value[1]).toISOString()
})
const raw = res?.data?.data?.data
trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : [])
nextTick(renderTrafficHourlyChart)
} catch { trafficHourlyData.value = [] } finally { trafficHourlyLoading.value = false }
}
const renderTrafficHourlyChart = () => {
if (!trafficHourlyChartRef.value || !trafficHourlyData.value.length) return
if (!trafficHourlyChart) trafficHourlyChart = echarts.init(trafficHourlyChartRef.value)
const data = trafficHourlyData.value
const times = data.map(d => {
const date = new Date(d.bucket)
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
})
const toMB = (b) => +(b / 1048576).toFixed(2)
trafficHourlyChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => params.map(p => `${p.marker}${p.seriesName}: ${p.value} MB`).join('<br/>') },
legend: { data: ['下行', '上行', '合计'], bottom: 0 },
grid: { top: 10, right: 16, bottom: 40, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', name: 'MB', axisLabel: { fontSize: 10 } },
series: [
{ name: '下行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.rx_bytes)), itemStyle: { color: '#409EFF' } },
{ name: '上行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.tx_bytes)), itemStyle: { color: '#67C23A' } },
{ name: '合计', type: 'line', smooth: true, data: data.map(d => toMB(d.total_bytes)), itemStyle: { color: '#E6A23C' }, lineStyle: { width: 2 } }
]
}, true)
}
// ---- 数据卷操作(绑定/创建/调整/挂载/卸载/迁移/删除/详情) ----
const showVolSelector = ref(false)