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
+24
View File
@@ -298,6 +298,30 @@ export const resetVmMac = (data) => {
})
}
/** 断开虚拟机外部网络 */
export const disconnectVmNetwork = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/disconnect_network', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 恢复虚拟机外部网络 */
export const connectVmNetwork = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/vm/connect_network', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 查询虚拟机每小时流量 */
export const getVmTrafficHourly = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/vm/traffic_hourly', { params })
}
/** 获取宿主机额度统计 */
export const getHostQuotaStats = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/quota_stats', { params })
}
/**
* ================================
* 主控服务接口 - 数据卷管理
+2
View File
@@ -90,6 +90,8 @@ export const getUserVmNetworkList = (params) => http2.get(`${BASE}/network/list`
export const getUserVmNetworkDetail = (params) => http2.get(`${BASE}/network/detail`, { params })
export const setUserVmNetworkPrimary = (data) => http2.post(`${BASE}/network/set_primary`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const resetUserVmMac = (params) => http2.post(`${BASE}/reset_mac`, null, { params })
export const disconnectUserVmNetwork = (data) => http2.post(`${BASE}/disconnect_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const connectUserVmNetwork = (data) => http2.post(`${BASE}/connect_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
// ========== 组网 ==========
export const getUserVmNetworkingList = (params) => http2.get(`${BASE}/networking/list`, { params })
+34 -2
View File
@@ -19,6 +19,7 @@
<div class="name-row">
<h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2>
<el-tag v-if="vm?.status" :type="vmStatusType(vm.status)" size="small" style="margin-left:8px">{{ vmStatusLabel(vm.status) }}</el-tag>
<el-tag v-if="vm?.network_disabled" size="small" type="danger" effect="dark" style="margin-left:4px">已断网</el-tag>
<el-tag v-if="vm?.rescue" size="small" type="danger" effect="dark" style="margin-left:4px">救援模式</el-tag>
<el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag>
</div>
@@ -47,6 +48,8 @@
<el-dropdown-item command="rescue" :disabled="!!vm?.rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue" :disabled="!vm?.rescue">退出救援</el-dropdown-item>
<el-dropdown-item command="resetMac">重置MAC地址</el-dropdown-item>
<el-dropdown-item command="disconnectNetwork" :disabled="vm?.network_disabled">断网</el-dropdown-item>
<el-dropdown-item command="connectNetwork" :disabled="!vm?.network_disabled">恢复网络</el-dropdown-item>
<el-dropdown-item divided command="rebuild">重装系统</el-dropdown-item>
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
<el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item>
@@ -1125,7 +1128,8 @@ import {
getUserGoodsDetail,
getUserVmMetricsHistory,
getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic,
setUserVmNetworkPrimary, resetUserVmMac
setUserVmNetworkPrimary, resetUserVmMac,
disconnectUserVmNetwork, connectUserVmNetwork
} from '@/api/admin/userVm'
import { deleteNetwork as deletePointNetwork } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
@@ -1372,6 +1376,8 @@ const handleMoreCmd = (cmd) => {
if (cmd === 'migrate') openMigrateVm()
if (cmd === 'editGoods') openEditGoods()
if (cmd === 'resetMac') handleResetVmMac()
if (cmd === 'disconnectNetwork') handleDisconnectVmNetwork()
if (cmd === 'connectNetwork') handleConnectVmNetwork()
if (cmd === 'delete') {
ElMessageBox.confirm('确定删除该用户虚拟机吗?', '删除确认', { type: 'error' }).then(async () => {
try {
@@ -1746,7 +1752,7 @@ const handleSetVmNetworkPrimary = (row) => {
'设置主IP', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const res = await setUserVmNetworkPrimary({ user_good_id: userGoodsId.value, network_id: row.id })
const res = await setUserVmNetworkPrimary({ user_goods_id: userGoodsId.value, network_id: row.id })
if (res?.data?.code === 200) { ElMessage.success('设置主IP成功,虚拟机将重启'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '设置主IP失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置主IP失败')) }
@@ -1766,6 +1772,32 @@ const handleResetVmMac = () => {
}).catch(() => {})
}
const handleDisconnectVmNetwork = () => {
ElMessageBox.confirm(
'断开虚拟机外部网络连接?断网后虚拟机将无法访问外部网络。',
'断网', { confirmButtonText: '确定断网', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
const res = await disconnectUserVmNetwork({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) { ElMessage.success('已断网'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '断网失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '断网失败')) }
}).catch(() => {})
}
const handleConnectVmNetwork = () => {
ElMessageBox.confirm(
'恢复虚拟机外部网络连接?',
'恢复网络', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
).then(async () => {
try {
const res = await connectUserVmNetwork({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) { ElMessage.success('网络已恢复'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '恢复网络失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复网络失败')) }
}).catch(() => {})
}
// ---- 绑定网络 ----
const showBindNetworkSelector = ref(false)
+71 -1
View File
@@ -153,6 +153,42 @@
</div>
</el-tab-pane>
<el-tab-pane label="额度统计" name="quotaStats">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">资源额度统计</h3>
<el-button size="small" :icon="Refresh" @click="loadQuotaStats" :loading="quotaStatsLoading">刷新</el-button>
</div>
<template v-if="quotaStats">
<el-descriptions :column="3" border size="small" style="margin-top:12px">
<el-descriptions-item label="虚拟机数量">{{ quotaStats.vm_count ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="规划 CPU">{{ quotaStats.planned_cpu ?? '-' }} </el-descriptions-item>
<el-descriptions-item label="已分配 CPU">{{ quotaStats.allocated_cpu ?? '-' }} </el-descriptions-item>
<el-descriptions-item label="规划内存">{{ formatQuotaMem(quotaStats.planned_memory) }}</el-descriptions-item>
<el-descriptions-item label="已分配内存">{{ formatQuotaMem(quotaStats.allocated_memory) }}</el-descriptions-item>
<el-descriptions-item label="实时内存">{{ formatQuotaBytes(quotaStats.actual_memory_used) }} / {{ formatQuotaBytes(quotaStats.actual_memory_total) }}</el-descriptions-item>
<el-descriptions-item label="规划磁盘">{{ quotaStats.planned_disk ?? '-' }} GB</el-descriptions-item>
<el-descriptions-item label="已分配磁盘">{{ quotaStats.allocated_disk ?? '-' }} GB</el-descriptions-item>
<el-descriptions-item label="实时 CPU">{{ quotaStats.actual_cpu_percent != null ? quotaStats.actual_cpu_percent.toFixed(1) + '%' : '-' }}</el-descriptions-item>
<el-descriptions-item label="规划下行带宽">{{ quotaStats.planned_rx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
<el-descriptions-item label="已分配下行带宽">{{ quotaStats.allocated_rx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
<el-descriptions-item label="规划上行带宽">{{ quotaStats.planned_tx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
<el-descriptions-item label="已分配上行带宽">{{ quotaStats.allocated_tx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
</el-descriptions>
<template v-if="quotaStatsDisk.length">
<h4 style="margin:16px 0 8px;font-size:13px;color:#606266">磁盘使用</h4>
<el-table :data="quotaStatsDisk" size="small" stripe>
<el-table-column prop="path" label="路径" min-width="160" />
<el-table-column label="总量" width="100"><template #default="{row}">{{ formatQuotaBytes(row.total) }}</template></el-table-column>
<el-table-column label="已用" width="100"><template #default="{row}">{{ formatQuotaBytes(row.used) }}</template></el-table-column>
<el-table-column label="使用率" width="100"><template #default="{row}">{{ row.total ? ((row.used / row.total) * 100).toFixed(1) + '%' : '-' }}</template></el-table-column>
</el-table>
</template>
</template>
<el-empty v-else-if="!quotaStatsLoading" description="暂无额度统计数据" :image-size="60" />
</div>
</el-tab-pane>
<el-tab-pane label="监控" name="monitor">
<div class="section-block">
<div class="section-header">
@@ -613,7 +649,7 @@ import {
getRemoteHostDetail, updateRemoteHost, deleteRemoteHost,
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
assignUserNetworking, removeUserNetworkingNetwork,
createHostToken, getMetricsHistory
createHostToken, getMetricsHistory, getHostQuotaStats
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { baseUrl } from '@/config/env'
@@ -662,6 +698,7 @@ watch(activeTab, (tab) => {
}
}
if (tab === 'networking') loadNetworkingList()
if (tab === 'quotaStats' && !quotaStats.value) loadQuotaStats()
})
const loading = ref(false)
@@ -822,6 +859,39 @@ const loadDetail = async () => {
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
}
// ---- 额度统计 ----
const quotaStats = ref(null)
const quotaStatsLoading = ref(false)
const quotaStatsDisk = computed(() => {
if (!quotaStats.value?.actual_disk_json) return []
try { return JSON.parse(quotaStats.value.actual_disk_json) } catch { return [] }
})
const loadQuotaStats = async () => {
if (!serviceId.value || !hostId.value) return
quotaStatsLoading.value = true
try {
const res = await getHostQuotaStats({ service_id: serviceId.value, host_id: hostId.value })
if (res?.data?.code === 200) quotaStats.value = res.data.data
else quotaStats.value = null
} catch { quotaStats.value = null } finally { quotaStatsLoading.value = false }
}
const formatQuotaMem = (mb) => {
if (!mb && mb !== 0) return '-'
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB'
return mb + ' MB'
}
const formatQuotaBytes = (bytes) => {
if (!bytes && bytes !== 0) return '-'
const n = Number(bytes)
if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB'
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB'
if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'
return n + ' B'
}
const cpuChartRef = ref(null)
const memChartRef = ref(null)
const netChartRef = ref(null)
+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)