diff --git a/src/api/admin/kvmService.js b/src/api/admin/kvmService.js index 9cda5f4..97d4324 100644 --- a/src/api/admin/kvmService.js +++ b/src/api/admin/kvmService.js @@ -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 }) +} + /** * ================================ * 主控服务接口 - 数据卷管理 diff --git a/src/api/admin/userVm.js b/src/api/admin/userVm.js index 9d1d14f..3a31848 100644 --- a/src/api/admin/userVm.js +++ b/src/api/admin/userVm.js @@ -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 }) diff --git a/src/views/user-vm/UserVmDetail.vue b/src/views/user-vm/UserVmDetail.vue index e0b1904..6e8f8e6 100644 --- a/src/views/user-vm/UserVmDetail.vue +++ b/src/views/user-vm/UserVmDetail.vue @@ -19,6 +19,7 @@

{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}

{{ vmStatusLabel(vm.status) }} + 已断网 救援模式 {{ userGoods.tag }}
@@ -47,6 +48,8 @@ 救援模式 退出救援 重置MAC地址 + 断网 + 恢复网络 重装系统 编辑虚拟机 重构虚拟机 @@ -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) diff --git a/src/views/virtualization/HostDetail.vue b/src/views/virtualization/HostDetail.vue index 5916eae..abbc514 100644 --- a/src/views/virtualization/HostDetail.vue +++ b/src/views/virtualization/HostDetail.vue @@ -153,6 +153,42 @@ + +
+
+

资源额度统计

+ 刷新 +
+ + +
+
+
@@ -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) diff --git a/src/views/virtualization/VmDetail.vue b/src/views/virtualization/VmDetail.vue index efed543..f349aec 100644 --- a/src/views/virtualization/VmDetail.vue +++ b/src/views/virtualization/VmDetail.vue @@ -31,6 +31,8 @@ 重构虚拟机 修改带宽 重置MAC地址 + 断网 + 恢复网络 重装虚拟机 救援模式 退出救援 @@ -51,6 +53,7 @@ {{ vmStatusLabel(detail.status) }} + 已断网 迁移中
@@ -664,6 +667,34 @@
+ + +
+
+

每小时流量

+
+ + 刷新 +
+
+ + +
+
+ +
+
+
@@ -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('
') }, + 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)