From a443e4f1470bc9e78577f0e0d684d18b250316ad Mon Sep 17 00:00:00 2001 From: shiran Date: Wed, 20 May 2026 16:41:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20KSM=E5=86=85=E5=AD=98=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=E7=AE=A1=E7=90=86+=E7=9B=91=E6=8E=A7=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E5=A2=9E=E5=BC=BA+=E9=A2=9D=E5=BA=A6=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1UI=E9=87=8D=E6=9E=84+=E6=B5=81=E9=87=8F=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=88=E5=B9=B6=20--=20=E7=BC=98=E7=94=B1:=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=96=B0=E5=A2=9EKSM=E7=8A=B6=E6=80=81/?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=8E=A5=E5=8F=A3,=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=94=B9=E4=B8=BA=E7=BB=9D=E5=AF=B9=E5=80=BC?= =?UTF-8?q?,=E9=A2=9D=E5=BA=A6=E7=BB=9F=E8=AE=A1=E9=9C=80=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96=20--=20=E9=A2=84=E6=9C=9F:=20HostDetail?= =?UTF-8?q?=E6=94=AF=E6=8C=81KSM=E6=9F=A5=E7=9C=8B/=E5=90=AF=E5=81=9C/?= =?UTF-8?q?=E8=B0=83=E5=8F=82,=E5=86=85=E5=AD=98=E5=9B=BE=E8=A1=A8?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=BB=9D=E5=AF=B9=E5=80=BC+=E7=A3=81?= =?UTF-8?q?=E7=9B=98IOPS=E5=9B=BE+=E6=B5=81=E9=87=8F=E8=B6=8B=E5=8A=BF?= =?UTF-8?q?=E5=9B=BE,=E9=A2=9D=E5=BA=A6=E7=BB=9F=E8=AE=A1=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E7=8E=AF=E5=BD=A2=E8=BF=9B=E5=BA=A6=E5=8D=A1=E7=89=87?= =?UTF-8?q?,=E6=B5=81=E9=87=8F=E7=AD=96=E7=95=A5=E4=B8=8E=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E5=90=88=E5=B9=B6=E4=B8=BA=E6=B5=81=E9=87=8F=E7=AE=A1?= =?UTF-8?q?=E7=90=86tab,=E8=AE=A2=E5=8D=95=E4=BB=A3=E9=87=91=E5=88=B8?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=9D=9E=E5=BF=85=E5=A1=AB,VmManage=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E7=B4=AF=E8=AE=A1=E6=B5=81=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/api/admin/kvmService.js | 12 + src/api/admin/userVm.js | 1 + src/views/order/OrderList.vue | 2 +- src/views/user-vm/UserVmDetail.vue | 166 +++++++++++++- src/views/virtualization/HostDetail.vue | 283 +++++++++++++++++++++--- src/views/virtualization/VmDetail.vue | 129 ++++++++--- src/views/virtualization/VmManage.vue | 1 + 7 files changed, 527 insertions(+), 67 deletions(-) diff --git a/src/api/admin/kvmService.js b/src/api/admin/kvmService.js index 97d4324..8637786 100644 --- a/src/api/admin/kvmService.js +++ b/src/api/admin/kvmService.js @@ -322,6 +322,18 @@ export const getHostQuotaStats = (params) => { return http2.get('/api/v1/admin/server/host_service/point/host/quota_stats', { params }) } +/** 获取宿主机 KSM 状态 */ +export const getHostKsmStatus = (params) => { + return http2.get('/api/v1/admin/server/host_service/point/host/ksm/status', { params }) +} + +/** 配置宿主机 KSM */ +export const configureHostKsm = (data) => { + return http2.post('/api/v1/admin/server/host_service/point/host/ksm/configure', data, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + /** * ================================ * 主控服务接口 - 数据卷管理 diff --git a/src/api/admin/userVm.js b/src/api/admin/userVm.js index 3a31848..7804144 100644 --- a/src/api/admin/userVm.js +++ b/src/api/admin/userVm.js @@ -109,6 +109,7 @@ export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(d export const deleteUserGoods = (params) => http2.delete(`${GOODS_BASE}/delete`, { params }) export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { params }) +export const getUserVmTrafficHourly = (params) => http2.get(`${BASE}/traffic_hourly`, { params }) // ========== 流量策略 ========== // 测试未通过(接口新增,待联调) diff --git a/src/views/order/OrderList.vue b/src/views/order/OrderList.vue index 289ead1..818b005 100644 --- a/src/views/order/OrderList.vue +++ b/src/views/order/OrderList.vue @@ -474,7 +474,7 @@ const orderRules = { { type: 'number', message: '用户ID必须是数字', trigger: 'blur' } ], coupon_id: [ - { required: true, message: '请输入代金券ID', trigger: 'blur' }, + { message: '请输入代金券ID', trigger: 'blur' }, { type: 'number', message: '代金券ID必须是数字', trigger: 'blur' } ], pay_num: [ diff --git a/src/views/user-vm/UserVmDetail.vue b/src/views/user-vm/UserVmDetail.vue index 6e8f8e6..ac25dd0 100644 --- a/src/views/user-vm/UserVmDetail.vue +++ b/src/views/user-vm/UserVmDetail.vue @@ -439,6 +439,11 @@
↓{{ formatNetLabel(latestMetrics.net_rx) }}
↑{{ formatNetLabel(latestMetrics.net_tx) }}
+
+
累计流量
+
{{ latestMetrics.traffic_used_mb != null ? latestMetrics.traffic_used_mb + ' MB' : '-' }}
+
 
+
- - + +

流量策略

@@ -494,6 +513,30 @@
+
+
+

每小时流量

+
+ + 刷新 +
+
+ + +
+
+ +
@@ -1126,7 +1169,7 @@ import { getUserVmPostGroupDetail, getUserVmNetworkList, getUserVmNetworkDetail, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking, getUserGoodsDetail, - getUserVmMetricsHistory, + getUserVmMetricsHistory, getUserVmTrafficHourly, getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic, setUserVmNetworkPrimary, resetUserVmMac, disconnectUserVmNetwork, connectUserVmNetwork @@ -1318,7 +1361,7 @@ const handleTabChange = (tab) => { if (tab === 'security') loadSgLockInfo() if (tab === 'networking') loadNetworkings() if (tab === 'monitor' && !metricsData.value) loadMetricsHistory() - if (tab === 'trafficPolicy') loadTrafficPolicy() + if (tab === 'trafficManage') { loadTrafficPolicy(); loadTrafficHourly() } } // 请求安全组详情补充 lock 字段(使用用户虚拟机安全组详情接口) @@ -2215,6 +2258,55 @@ const submitAddTraffic = async () => { } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { trafficPolicyLoading.value = false } } +// ---- 每小时流量统计 ---- +const trafficHourlyRange = ref(null) +const trafficHourlyData = ref([]) +const trafficHourlyLoading = ref(false) +const trafficHourlyChartRef = ref(null) +let trafficHourlyChart = null + +const loadTrafficHourly = async () => { + if (!userGoodsId.value) 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 getUserVmTrafficHourly({ + user_goods_id: userGoodsId.value, + 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 transferVisible = ref(false) const showTransferUserSelector = ref(false) @@ -2267,11 +2359,15 @@ const submitEditGoods = async () => { const cpuChartRef = ref(null) const memChartRef = ref(null) const diskChartRef = ref(null) +const diskIopsChartRef = ref(null) const netChartRef = ref(null) +const trafficUsedChartRef = ref(null) let cpuChart = null let memChart = null let diskChart = null +let diskIopsChart = null let netChart = null +let trafficUsedChart = null const metricsData = ref(null) const metricsLoading = ref(false) @@ -2398,9 +2494,28 @@ const renderMetricsCharts = () => { }) const cpuData = metrics.map(m => m.cpu_usage ?? 0) - const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0) + const memData = metrics.map(m => m.mem_used ?? 0) + const memTotal = Math.max(...metrics.map(m => m.mem_total ?? 0)) + const memAxisFmt = memTotal >= 1048576 + ? (v) => (v / 1048576).toFixed(1) + ' GB' + : memTotal >= 1024 + ? (v) => (v / 1024).toFixed(0) + ' MB' + : (v) => v + ' KB' + const diskReadData = metrics.map(m => m.disk_read ?? 0) const diskWriteData = metrics.map(m => m.disk_write ?? 0) + + const diskReadRate = [] + const diskWriteRate = [] + for (let i = 0; i < metrics.length; i++) { + if (i === 0) { diskReadRate.push(0); diskWriteRate.push(0); continue } + const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000 + if (dt > 0) { + diskReadRate.push(+Math.max(0, ((metrics[i].disk_read ?? 0) - (metrics[i - 1].disk_read ?? 0)) / dt / 1024).toFixed(2)) + diskWriteRate.push(+Math.max(0, ((metrics[i].disk_write ?? 0) - (metrics[i - 1].disk_write ?? 0)) / dt / 1024).toFixed(2)) + } else { diskReadRate.push(0); diskWriteRate.push(0) } + } + const netRxData = metrics.map(m => m.net_rx ?? 0) const netTxData = metrics.map(m => m.net_tx ?? 0) @@ -2421,9 +2536,9 @@ const renderMetricsCharts = () => { if (memChartRef.value) { if (!memChart) memChart = echarts.init(memChartRef.value) memChart.setOption({ - tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}
${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` }, - grid: baseGrid, xAxis: makeXAxis(), - yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } }, + tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}
${p[0].marker} 内存: ${formatMemKB(p[0].value)}` }, + grid: { ...baseGrid, left: 60 }, xAxis: makeXAxis(), + yAxis: { type: 'value', min: 0, max: memTotal || undefined, axisLabel: { fontSize: 10, formatter: memAxisFmt } }, series: [makeSeries('内存', memData, '#67c23a')] }, true) } @@ -2442,6 +2557,20 @@ const renderMetricsCharts = () => { }, true) } + if (diskIopsChartRef.value) { + if (!diskIopsChart) diskIopsChart = echarts.init(diskIopsChartRef.value) + diskIopsChart.setOption({ + tooltip: { trigger: 'axis', formatter: (params) => { + let s = params[0].axisValue + params.forEach(p => { s += `
${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}/s` }) + return s + }}, + grid: baseGrid, xAxis: makeXAxis(), + yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) + '/s' } }, + series: [makeSeries('读取', diskReadRate, '#409eff'), makeSeries('写入', diskWriteRate, '#e6a23c')] + }, true) + } + if (netChartRef.value) { if (!netChart) netChart = echarts.init(netChartRef.value) netChart.setOption({ @@ -2455,13 +2584,32 @@ const renderMetricsCharts = () => { series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')] }, true) } + + if (trafficUsedChartRef.value) { + if (!trafficUsedChart) trafficUsedChart = echarts.init(trafficUsedChartRef.value) + const trafficDelta = [] + for (let i = 0; i < metrics.length; i++) { + if (i === 0) { trafficDelta.push(0); continue } + const delta = Math.max(0, (metrics[i].traffic_used_mb ?? 0) - (metrics[i - 1].traffic_used_mb ?? 0)) + trafficDelta.push(+delta.toFixed(2)) + } + trafficUsedChart.setOption({ + tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}
${p[0].marker} 流量增量: ${p[0].value} MB` }, + grid: baseGrid, xAxis: makeXAxis(), + yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v + ' MB' } }, + series: [makeSeries('流量增量', trafficDelta, '#722ed1')] + }, true) + } } const disposeCharts = () => { cpuChart?.dispose(); cpuChart = null memChart?.dispose(); memChart = null diskChart?.dispose(); diskChart = null + diskIopsChart?.dispose(); diskIopsChart = null netChart?.dispose(); netChart = null + trafficUsedChart?.dispose(); trafficUsedChart = null + trafficHourlyChart?.dispose(); trafficHourlyChart = null } onMounted(() => { loadDetail() }) diff --git a/src/views/virtualization/HostDetail.vue b/src/views/virtualization/HostDetail.vue index abbc514..7273a14 100644 --- a/src/views/virtualization/HostDetail.vue +++ b/src/views/virtualization/HostDetail.vue @@ -151,6 +151,42 @@
+
+
+

KSM 内存去重

+
+ 刷新 +
+
+ + +
@@ -160,29 +196,58 @@ 刷新 @@ -637,6 +702,25 @@ + + + + + + + + + + + + + + + + @@ -649,7 +733,8 @@ import { getRemoteHostDetail, updateRemoteHost, deleteRemoteHost, getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking, assignUserNetworking, removeUserNetworkingNetwork, - createHostToken, getMetricsHistory, getHostQuotaStats + createHostToken, getMetricsHistory, getHostQuotaStats, + getHostKsmStatus, configureHostKsm } from '@/api/admin/kvmService' import { extractApiError } from '@/utils/kvmErrorUtil' import { baseUrl } from '@/config/env' @@ -864,7 +949,11 @@ 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 [] } + try { + const parsed = JSON.parse(quotaStats.value.actual_disk_json) + if (Array.isArray(parsed)) return parsed + return Object.entries(parsed).map(([path, info]) => ({ path, ...info })) + } catch { return [] } }) const loadQuotaStats = async () => { @@ -877,21 +966,128 @@ const loadQuotaStats = async () => { } 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 formatQuotaKB = (kb) => { + if (!kb && kb !== 0) return '-' + const v = Number(kb) + if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' TB' + if (v >= 1048576) return (v / 1048576).toFixed(1) + ' GB' + if (v >= 1024) return (v / 1024).toFixed(1) + ' MB' + return v + ' KB' +} + +const formatQuotaDisk = (gb) => { + if (!gb && gb !== 0) return '-' + const v = Number(gb) + if (v >= 1024) return (v / 1024).toFixed(1) + ' TB' + return v + ' GB' +} + +const formatBandwidth = (mbps) => { + if (!mbps && mbps !== 0) return '-' + const v = Number(mbps) + if (v >= 1000) return (v / 1000).toFixed(1) + ' Gbps' + return v + ' Mbps' } const formatQuotaBytes = (bytes) => { if (!bytes && bytes !== 0) return '-' const n = Number(bytes) + if (n >= 1099511627776) return (n / 1099511627776).toFixed(2) + ' TB' 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 quotaColor = (ratio) => { + if (ratio >= 90) return '#f56c6c' + if (ratio >= 70) return '#e6a23c' + return '#409eff' +} + +const quotaAllocationItems = computed(() => { + const s = quotaStats.value + if (!s) return [] + const ratio = (a, p) => p ? Math.round((a / p) * 100) : 0 + return [ + { label: 'CPU', ratio: ratio(s.allocated_cpu, s.planned_cpu), allocatedText: (s.allocated_cpu ?? 0) + ' 核', plannedText: (s.planned_cpu ?? 0) + ' 核' }, + { label: '内存', ratio: ratio(s.allocated_memory, s.planned_memory), allocatedText: formatQuotaKB(s.allocated_memory), plannedText: formatQuotaKB(s.planned_memory) }, + { label: '磁盘', ratio: ratio(s.allocated_disk, s.planned_disk), allocatedText: formatQuotaDisk(s.allocated_disk), plannedText: formatQuotaDisk(s.planned_disk) }, + { label: '下行带宽', ratio: ratio(s.allocated_rx_bandwidth, s.planned_rx_bandwidth), allocatedText: formatBandwidth(s.allocated_rx_bandwidth), plannedText: formatBandwidth(s.planned_rx_bandwidth) }, + { label: '上行带宽', ratio: ratio(s.allocated_tx_bandwidth, s.planned_tx_bandwidth), allocatedText: formatBandwidth(s.allocated_tx_bandwidth), plannedText: formatBandwidth(s.planned_tx_bandwidth) } + ] +}) + +const actualMemPercent = computed(() => { + const s = quotaStats.value + if (!s?.actual_memory_total) return 0 + return Math.round((s.actual_memory_used / s.actual_memory_total) * 100) +}) + +// ---- KSM 内存去重 ---- +const ksmStats = ref(null) +const ksmLoading = ref(false) +const ksmActionLoading = ref(false) +const ksmTuneVisible = ref(false) +const ksmTuneForm = reactive({ pages_to_scan: 300, sleep_millisecs: 100, merge_across_nodes: false }) + +const loadKsmStatus = async () => { + if (!serviceId.value || !hostId.value) return + ksmLoading.value = true + try { + const res = await getHostKsmStatus({ service_id: serviceId.value, host_id: hostId.value }) + if (res?.data?.code === 200) ksmStats.value = res.data.data?.stats || res.data.data + else ElMessage.error(extractApiError(res?.data, '获取 KSM 状态失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取 KSM 状态失败')) } finally { ksmLoading.value = false } +} + +const handleKsmAction = async (action) => { + const labels = { enable: '启用', disable: '关闭' } + try { + await ElMessageBox.confirm(`确定要${labels[action]} KSM 吗?`, 'KSM 操作', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }) + } catch { return } + ksmActionLoading.value = true + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('host_id', hostId.value) + fd.append('action', action) + const res = await configureHostKsm(fd) + if (res?.data?.code === 200) { + ElMessage.success(`KSM 已${labels[action]}`) + ksmStats.value = res.data.data?.stats || res.data.data + } else ElMessage.error(extractApiError(res?.data, '操作失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { ksmActionLoading.value = false } +} + +const openKsmTuneDialog = () => { + if (ksmStats.value) { + ksmTuneForm.pages_to_scan = ksmStats.value.pages_to_scan ?? 300 + ksmTuneForm.sleep_millisecs = ksmStats.value.sleep_millisecs ?? 100 + ksmTuneForm.merge_across_nodes = !!ksmStats.value.merge_across_nodes + } + ksmTuneVisible.value = true +} + +const submitKsmTune = async () => { + ksmActionLoading.value = true + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('host_id', hostId.value) + fd.append('action', 'tune') + fd.append('pages_to_scan', ksmTuneForm.pages_to_scan) + fd.append('sleep_millisecs', ksmTuneForm.sleep_millisecs) + fd.append('merge_across_nodes', ksmTuneForm.merge_across_nodes) + const res = await configureHostKsm(fd) + if (res?.data?.code === 200) { + ElMessage.success('KSM 参数已更新') + ksmStats.value = res.data.data?.stats || res.data.data + ksmTuneVisible.value = false + } else ElMessage.error(extractApiError(res?.data, '调参失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调参失败')) } finally { ksmActionLoading.value = false } +} + const cpuChartRef = ref(null) const memChartRef = ref(null) const netChartRef = ref(null) @@ -1499,4 +1695,35 @@ onBeforeUnmount(() => { isPageActive = false; disposeCharts() }) .tk-section-title.clickable:hover { color: #409eff; } .io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; } +.quota-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 16px; margin-top: 12px; } +.quota-card { background: #f7f8fa; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px 16px; display: flex; flex-direction: column; align-items: center; gap: 12px; transition: box-shadow 0.2s; } +.quota-card:hover { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); } +.quota-card-vm { flex-direction: row; justify-content: center; gap: 16px; } +.quota-card-icon { display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; background: #ecf5ff; border-radius: 12px; } +.quota-card-body { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; } +.quota-card-vm .quota-card-body { align-items: flex-start; } +.quota-card-label { font-size: 12px; color: #86909c; white-space: nowrap; } +.quota-card-number { font-size: 32px; font-weight: 700; color: #1d2129; line-height: 1.1; } +.quota-card-unit { font-size: 14px; font-weight: 400; color: #86909c; margin-left: 4px; } +.quota-ring-pct { font-size: 16px; font-weight: 700; line-height: 1; } +.quota-card-alloc { font-size: 13px; color: #1d2129; font-weight: 500; white-space: nowrap; text-align: center; } +.quota-card-sep { color: #c0c4cc; margin: 0 2px; } +.quota-card-sub { font-size: 11px; color: #a8abb2; } + +.quota-actual-section { margin-top: 24px; } +.quota-subtitle { font-size: 14px; font-weight: 600; color: #1d2129; margin: 0 0 14px; padding-left: 8px; border-left: 3px solid #409eff; } +.quota-actual-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; } +.quota-actual-card { background: #f7f8fa; border: 1px solid #e8e8e8; border-radius: 8px; padding: 16px 20px; } +.quota-actual-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } +.quota-actual-label { font-size: 13px; color: #606266; font-weight: 500; } +.quota-actual-value { font-size: 14px; font-weight: 600; } + +.quota-disk-section { margin-top: 24px; } +.quota-disk-list { display: flex; flex-direction: column; gap: 12px; } +.quota-disk-item { background: #f7f8fa; border: 1px solid #e8e8e8; border-radius: 8px; padding: 12px 20px; } +.quota-disk-header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; flex-wrap: wrap; } +.quota-disk-path { font-size: 13px; font-family: 'Consolas', 'Monaco', monospace; color: #1d2129; background: #e8eaed; padding: 2px 8px; border-radius: 4px; } +.quota-disk-detail { font-size: 12px; color: #86909c; flex: 1; text-align: right; } +.quota-disk-pct { font-size: 13px; font-weight: 600; min-width: 48px; text-align: right; } + diff --git a/src/views/virtualization/VmDetail.vue b/src/views/virtualization/VmDetail.vue index f349aec..ee0eb51 100644 --- a/src/views/virtualization/VmDetail.vue +++ b/src/views/virtualization/VmDetail.vue @@ -631,6 +631,11 @@
↓{{ formatNetLabel(latestMetrics.net_rx) }}
↑{{ formatNetLabel(latestMetrics.net_tx) }}
+
+
累计流量
+
{{ latestMetrics.traffic_used_mb != null ? latestMetrics.traffic_used_mb + ' MB' : '-' }}
+
 
+
- - + + +
+
+

流量策略

+
+ 修改流量策略 + 增加固定流量 + 增加临时流量 + 刷新 +
+
+ + {{ vmTrafficPolicy.traffic_max_mb != null ? (vmTrafficPolicy.traffic_max_mb === 0 ? '不限' : vmTrafficPolicy.traffic_max_mb + ' MB') : '-' }} + {{ vmTrafficPolicy.exhausted_rx_mbps != null ? (vmTrafficPolicy.exhausted_rx_mbps === 0 ? '不限' : vmTrafficPolicy.exhausted_rx_mbps + ' Mbps') : '-' }} + {{ vmTrafficPolicy.exhausted_tx_mbps != null ? (vmTrafficPolicy.exhausted_tx_mbps === 0 ? '不限' : vmTrafficPolicy.exhausted_tx_mbps + ' Mbps') : '-' }} + + +

每小时流量

@@ -695,27 +731,6 @@
- - -
-
-

流量策略

-
- 修改流量策略 - 增加固定流量 - 增加临时流量 - 刷新 -
-
- - {{ vmTrafficPolicy.traffic_max_mb != null ? (vmTrafficPolicy.traffic_max_mb === 0 ? '不限' : vmTrafficPolicy.traffic_max_mb + ' MB') : '-' }} - {{ vmTrafficPolicy.exhausted_rx_mbps != null ? (vmTrafficPolicy.exhausted_rx_mbps === 0 ? '不限' : vmTrafficPolicy.exhausted_rx_mbps + ' Mbps') : '-' }} - {{ vmTrafficPolicy.exhausted_tx_mbps != null ? (vmTrafficPolicy.exhausted_tx_mbps === 0 ? '不限' : vmTrafficPolicy.exhausted_tx_mbps + ' Mbps') : '-' }} - - -
-
-
@@ -1883,10 +1898,14 @@ const cpuChartRef = ref(null) const memChartRef = ref(null) const netChartRef = ref(null) const diskChartRef = ref(null) +const diskIopsChartRef = ref(null) +const trafficUsedChartRef = ref(null) let cpuChart = null let memChart = null let netChart = null let diskChart = null +let diskIopsChart = null +let trafficUsedChart = null let isPageActive = false const historicalMetricsData = ref(null) @@ -2014,9 +2033,28 @@ const renderHistoricalCharts = () => { }) const cpuData = metrics.map(m => m.cpu_usage ?? 0) - const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0) + const memData = metrics.map(m => m.mem_used ?? 0) + const memTotal = Math.max(...metrics.map(m => m.mem_total ?? 0)) + const memAxisFmt = memTotal >= 1048576 + ? (v) => (v / 1048576).toFixed(1) + ' GB' + : memTotal >= 1024 + ? (v) => (v / 1024).toFixed(0) + ' MB' + : (v) => v + ' KB' + const diskReadData = metrics.map(m => m.disk_read ?? 0) const diskWriteData = metrics.map(m => m.disk_write ?? 0) + + const diskReadRate = [] + const diskWriteRate = [] + for (let i = 0; i < metrics.length; i++) { + if (i === 0) { diskReadRate.push(0); diskWriteRate.push(0); continue } + const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000 + if (dt > 0) { + diskReadRate.push(+Math.max(0, ((metrics[i].disk_read ?? 0) - (metrics[i - 1].disk_read ?? 0)) / dt / 1024).toFixed(2)) + diskWriteRate.push(+Math.max(0, ((metrics[i].disk_write ?? 0) - (metrics[i - 1].disk_write ?? 0)) / dt / 1024).toFixed(2)) + } else { diskReadRate.push(0); diskWriteRate.push(0) } + } + const netRxData = metrics.map(m => m.net_rx ?? 0) const netTxData = metrics.map(m => m.net_tx ?? 0) @@ -2037,9 +2075,9 @@ const renderHistoricalCharts = () => { if (memChartRef.value) { if (!memChart) memChart = echarts.init(memChartRef.value) memChart.setOption({ - tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}
${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` }, - grid: baseGrid, xAxis: makeXAxis(), - yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } }, + tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}
${p[0].marker} 内存: ${formatMemKB(p[0].value)}` }, + grid: { ...baseGrid, left: 60 }, xAxis: makeXAxis(), + yAxis: { type: 'value', min: 0, max: memTotal || undefined, axisLabel: { fontSize: 10, formatter: memAxisFmt } }, series: [makeSeries('内存', memData, '#67c23a')] }, true) } @@ -2058,6 +2096,20 @@ const renderHistoricalCharts = () => { }, true) } + if (diskIopsChartRef.value) { + if (!diskIopsChart) diskIopsChart = echarts.init(diskIopsChartRef.value) + diskIopsChart.setOption({ + tooltip: { trigger: 'axis', formatter: (params) => { + let s = params[0].axisValue + params.forEach(p => { s += `
${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}/s` }) + return s + }}, + grid: baseGrid, xAxis: makeXAxis(), + yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) + '/s' } }, + series: [makeSeries('读取', diskReadRate, '#409eff'), makeSeries('写入', diskWriteRate, '#e6a23c')] + }, true) + } + if (netChartRef.value) { if (!netChart) netChart = echarts.init(netChartRef.value) netChart.setOption({ @@ -2071,6 +2123,22 @@ const renderHistoricalCharts = () => { series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')] }, true) } + + if (trafficUsedChartRef.value) { + if (!trafficUsedChart) trafficUsedChart = echarts.init(trafficUsedChartRef.value) + const trafficDelta = [] + for (let i = 0; i < metrics.length; i++) { + if (i === 0) { trafficDelta.push(0); continue } + const delta = Math.max(0, (metrics[i].traffic_used_mb ?? 0) - (metrics[i - 1].traffic_used_mb ?? 0)) + trafficDelta.push(+delta.toFixed(2)) + } + trafficUsedChart.setOption({ + tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}
${p[0].marker} 流量增量: ${p[0].value} MB` }, + grid: baseGrid, xAxis: makeXAxis(), + yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v + ' MB' } }, + series: [makeSeries('流量增量', trafficDelta, '#722ed1')] + }, true) + } } const disposeCharts = () => { @@ -2078,6 +2146,9 @@ const disposeCharts = () => { memChart?.dispose(); memChart = null netChart?.dispose(); netChart = null diskChart?.dispose(); diskChart = null + diskIopsChart?.dispose(); diskIopsChart = null + trafficUsedChart?.dispose(); trafficUsedChart = null + trafficHourlyChart?.dispose(); trafficHourlyChart = null } const powerDialogVisible = ref(false) @@ -3821,7 +3892,7 @@ const triggerTabLoad = (tab) => { if (tab === 'backup') { loadBackups(); loadBackupQuota() } if (tab === 'userNetworking') loadVmNetworkingList() if (tab === 'security') loadSgLockInfo() - if (tab === 'vmTrafficPolicy') loadVmTrafficPolicy() + if (tab === 'trafficManage') { loadVmTrafficPolicy(); loadTrafficHourly() } } // 请求安全组详情补充 lock 字段 diff --git a/src/views/virtualization/VmManage.vue b/src/views/virtualization/VmManage.vue index 2653056..693fd2f 100644 --- a/src/views/virtualization/VmManage.vue +++ b/src/views/virtualization/VmManage.vue @@ -391,6 +391,7 @@ {{ formatBytesRaw(vmMetricsData.disk_write) }} {{ formatNetSpeed(vmMetricsData.net_rx) }} {{ formatNetSpeed(vmMetricsData.net_tx) }} + {{ vmMetricsData.traffic_used_mb != null ? vmMetricsData.traffic_used_mb + ' MB' : '-' }}