From 816771f1517f9d799c3b869ad5afd26e5cdaa4ca Mon Sep 17 00:00:00 2001 From: shiran Date: Thu, 23 Jul 2026 22:45:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E6=8C=87=E6=A0=87Top=E6=A6=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/kvmService.js | 5 + src/views/virtualization/HostDetail.vue | 275 +++++++++++++++++++++++- 2 files changed, 278 insertions(+), 2 deletions(-) diff --git a/src/api/admin/kvmService.js b/src/api/admin/kvmService.js index a5d545f..f9b6dcf 100644 --- a/src/api/admin/kvmService.js +++ b/src/api/admin/kvmService.js @@ -145,6 +145,11 @@ export const getRemoteHostMetrics = (params) => { return http2.get('/api/v1/admin/server/host_service/point/host/metrics', { params }) } +/** 获取宿主机内虚拟机指标 Top 榜单 */ +export const getHostMetricsTop = (params) => { + return http2.get('/api/v1/admin/server/host_service/point/host/metrics/top', { params }) +} + /** 查询历史指标(宿主机或虚拟机) */ export const getMetricsHistory = (params) => { return http2.get('/api/v1/admin/server/host_service/point/host/metrics_history', { params }) diff --git a/src/views/virtualization/HostDetail.vue b/src/views/virtualization/HostDetail.vue index 6a79170..ee65818 100644 --- a/src/views/virtualization/HostDetail.vue +++ b/src/views/virtualization/HostDetail.vue @@ -449,6 +449,86 @@
+
+
+
+
+

虚拟机指标 Top 榜单

+ + {{ metricTopMode === 'realtime' ? '实时缓存' : '历史快照' }} + +
+

按指标降序展示当前宿主机中有成功采集样本的虚拟机

+
+
+ + + + + + + 实时 + 历史 + + + + + + + + +
+
+ +
+ {{ currentMetricTopOption.label }} + {{ metricTopItems.length }} 台虚拟机 + {{ metricTopMode === 'realtime' ? '最新实时样本' : `查询时间 ${formatMetricTopSampleTime(metricTopData.requested_at || metricTopAt)}` }} + 最近采样 {{ formatMetricTopSampleTime(metricTopLatestSample) }} +
+ +
+
+ 排名 + 虚拟机 + 指标分布 + 当前值 + 采样时间 +
+
+
{{ item.rank }}
+
+ + {{ item.vm_name || `虚拟机 #${item.vm_id}` }} + +
+ VM #{{ item.vm_id }} + 宿主机 VM #{{ item.host_vm_id }} +
+
+
+
+ +
+
+
{{ formatMetricTopValue(item) }}
+ +
+
+ +
+ +
+

监控指标

@@ -940,12 +1020,12 @@ import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { ArrowLeft, ArrowRight, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue' +import { ArrowLeft, ArrowRight, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument, DataAnalysis } from '@element-plus/icons-vue' import { getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost, getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking, assignUserNetworking, removeUserNetworkingNetwork, - createHostToken, getMetricsHistory, getHostQuotaStats, + createHostToken, getMetricsHistory, getHostMetricsTop, getHostQuotaStats, getHostKsmStatus, configureHostKsm } from '@/api/admin/kvmService' import { extractApiError } from '@/utils/kvmErrorUtil' @@ -997,6 +1077,7 @@ watch(activeTab, (tab) => { if (!historicalMetricsData.value) { loadHistoricalMetrics() } + if (!metricTopData.value) loadMetricTop() } if (tab === 'networking') loadNetworkingList() if (tab === 'quotaStats' && !quotaStats.value) loadQuotaStats() @@ -1430,6 +1511,118 @@ const monitorTimeMode = ref('relative') const monitorRelativeMinutes = ref(10) const monitorDateRange = ref(null) +const metricTopOptionGroups = [ + { + label: '资源使用率', + options: [ + { label: 'CPU 使用率', value: 'cpu_usage_percent', color: '#409eff' }, + { label: '内存使用率', value: 'memory_used_percent', color: '#67c23a' } + ] + }, + { + label: '网络速率', + options: [ + { label: '网络接收速率', value: 'net_rx_bytes_per_sec', color: '#00a6a6' }, + { label: '网络发送速率', value: 'net_tx_bytes_per_sec', color: '#e6a23c' }, + { label: 'OVS 接收速率', value: 'ovs_rx_bytes_per_sec', color: '#7c6ee6' }, + { label: 'OVS 发送速率', value: 'ovs_tx_bytes_per_sec', color: '#d05a8a' } + ] + }, + { + label: '磁盘吞吐', + options: [ + { label: '磁盘读取速率', value: 'disk_read_bytes_per_sec', color: '#3f8f62' }, + { label: '磁盘写入速率', value: 'disk_write_bytes_per_sec', color: '#c27b32' } + ] + } +] +const metricTopOptions = metricTopOptionGroups.flatMap(group => group.options) +const metricTopLimitOptions = [5, 10, 20, 50] +const metricTopLoading = ref(false) +const metricTopData = ref(null) +const metricTopMetric = ref('cpu_usage_percent') +const metricTopMode = ref('realtime') +const metricTopAt = ref(new Date()) +const metricTopLimit = ref(10) + +const currentMetricTopOption = computed(() => ( + metricTopOptions.find(option => option.value === metricTopMetric.value) || metricTopOptions[0] +)) +const metricTopItems = computed(() => ( + Array.isArray(metricTopData.value?.items) ? metricTopData.value.items : [] +)) +const metricTopLatestSample = computed(() => { + const timestamps = metricTopItems.value + .map(item => new Date(item.sample_time).getTime()) + .filter(timestamp => Number.isFinite(timestamp)) + return timestamps.length ? new Date(Math.max(...timestamps)) : null +}) + +const getMetricTopBarPercent = (value) => { + const maxValue = Math.max(...metricTopItems.value.map(item => Number(item.value) || 0), 0) + if (!maxValue) return 0 + return Math.max(2, Math.min(100, ((Number(value) || 0) / maxValue) * 100)) +} + +const formatMetricTopValue = (item) => { + const value = Number(item?.value) || 0 + if (item?.unit === '%' || metricTopMetric.value.endsWith('_percent')) return `${value.toFixed(2)}%` + return formatSpeedAuto(value) +} + +const formatMetricTopSampleTime = (value) => { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value) + return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) +} + +const disableFutureMetricTopDate = (date) => date.getTime() > Date.now() + +const handleMetricTopModeChange = () => { + if (metricTopMode.value === 'historical' && !metricTopAt.value) metricTopAt.value = new Date() + loadMetricTop() +} + +const loadMetricTop = async () => { + if (!serviceId.value || !hostId.value) return + if (metricTopMode.value === 'historical' && !metricTopAt.value) { + metricTopData.value = null + return + } + metricTopLoading.value = true + try { + const params = { + service_id: serviceId.value, + host_id: hostId.value, + metric: metricTopMetric.value, + limit: metricTopLimit.value + } + if (metricTopMode.value === 'historical') params.at = new Date(metricTopAt.value).toISOString() + const res = await getHostMetricsTop(params) + const body = res?.data + if (body?.code === 200 && body?.data) { + metricTopData.value = body.data.data ?? body.data + } else { + metricTopData.value = null + ElMessage.error(extractApiError(body, '加载虚拟机指标榜单失败')) + } + } catch (e) { + metricTopData.value = null + ElMessage.error(extractApiError(e?.response?.data, '加载虚拟机指标榜单失败')) + } finally { + metricTopLoading.value = false + } +} + +const goToVmDetail = (item) => { + if (!item?.vm_id) return + router.push({ + path: '/virtualization/vm-detail', + query: { service_id: serviceId.value, service_name: serviceName.value, vm_id: item.vm_id } + }) +} + const getMonitorTimeRange = () => { if (monitorTimeMode.value === 'relative') { const endTime = new Date() @@ -1939,6 +2132,7 @@ const initPage = () => { showPassword.value = false showPrivateKey.value = false historicalMetricsData.value = null + metricTopData.value = null disposeCharts() loadDetail() loadHostMetrics() @@ -2007,6 +2201,48 @@ onBeforeUnmount(() => { isPageActive = false; disposeCharts() }) .metrics-title .el-icon { font-size: 16px; color: #409eff; } .chart-container { width: 100%; height: 220px; } +/* 虚拟机指标 Top 榜单 */ +.metric-top-section { min-height: 180px; border: 1px solid #e5e9f0; border-radius: 8px; overflow: hidden; background: #fff; } +.metric-top-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 20px; background: #f8fafc; border-bottom: 1px solid #e8edf3; } +.metric-top-heading { min-width: 220px; } +.metric-top-title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.metric-top-title { display: inline-flex; align-items: center; gap: 7px; margin-bottom: 0; } +.metric-top-title .el-icon { color: #409eff; font-size: 17px; } +.metric-top-description { margin: 6px 0 0; color: #86909c; font-size: 12px; line-height: 1.5; } +.metric-top-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } +.metric-select { width: 176px; } +.metric-time-picker { width: 190px !important; } +.metric-limit-select { width: 92px; } +.metric-top-meta { min-height: 38px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px 22px; padding: 8px 20px; color: #86909c; font-size: 12px; background: #fcfdff; border-bottom: 1px solid #eef1f5; } +.metric-top-meta span { position: relative; } +.metric-top-meta span + span::before { content: ''; position: absolute; left: -12px; top: 50%; width: 3px; height: 3px; border-radius: 50%; background: #c0c4cc; transform: translateY(-50%); } +.metric-top-meta strong { color: #303133; font-weight: 600; } +.metric-top-list { width: 100%; } +.metric-top-list-head, +.metric-top-item { display: grid; grid-template-columns: 52px minmax(190px, 1.1fr) minmax(180px, 1.5fr) 126px 164px; align-items: center; column-gap: 16px; } +.metric-top-list-head { min-height: 38px; padding: 0 20px; color: #909399; font-size: 12px; background: #fafbfc; border-bottom: 1px solid #eef1f5; } +.metric-top-list-head span:nth-child(4), +.metric-top-list-head span:nth-child(5) { text-align: right; } +.metric-top-item { min-height: 70px; padding: 10px 20px; border-bottom: 1px solid #f0f2f5; transition: background-color .2s; } +.metric-top-item:last-child { border-bottom: none; } +.metric-top-item:hover { background: #f8fbff; } +.metric-rank { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: #f0f2f5; color: #606266; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; } +.metric-rank.rank-1 { background: #fff3d6; color: #ad6b00; } +.metric-rank.rank-2 { background: #eef1f5; color: #596577; } +.metric-rank.rank-3 { background: #f7ede4; color: #9a5b2f; } +.metric-vm-cell { min-width: 0; display: flex; flex-direction: column; gap: 5px; } +.metric-vm-name { max-width: 100%; justify-content: flex-start; font-size: 13px; font-weight: 600; } +.metric-vm-name :deep(.el-link__inner) { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.metric-vm-ids { display: flex; align-items: center; gap: 10px; min-width: 0; color: #a1a7b3; font-size: 11px; } +.metric-vm-ids .el-link { font-size: 11px; } +.metric-vm-ids span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.metric-bar-cell { min-width: 0; } +.metric-bar-track { width: 100%; height: 8px; overflow: hidden; border-radius: 4px; background: #edf1f5; } +.metric-bar-fill { display: block; height: 100%; border-radius: 4px; background: var(--metric-color, #409eff); transition: width .35s ease; } +.metric-value { color: #1d2129; font-size: 14px; font-weight: 700; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } +.metric-sample-time { color: #86909c; font-size: 11px; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } +.monitor-section-divider { height: 1px; margin: 28px 0; background: #e8edf3; } + .metric-summary-row { display: flex; gap: 16px; margin-bottom: 16px; } .metric-summary-card { flex: 1; min-width: 0; background: #f7f8fa; border-radius: 6px; padding: 14px 16px; border: 1px solid #e8e8e8; display: flex; flex-direction: column; } .metric-summary-label { font-size: 12px; color: #86909c; margin-bottom: 8px; } @@ -2129,4 +2365,39 @@ onBeforeUnmount(() => { isPageActive = false; disposeCharts() }) .nic-addr.v6 { background: #fdf6ec; border-color: #faecd8; color: #e6a23c; font-size: 11px; } .nic-mac { font-size: 12px; color: #606266; } +@media (max-width: 1100px) { + .metric-top-header { flex-direction: column; } + .metric-top-controls { width: 100%; justify-content: flex-start; } + .metric-top-list-head, + .metric-top-item { grid-template-columns: 44px minmax(170px, 1.1fr) minmax(140px, 1fr) 108px 150px; column-gap: 12px; } +} + +@media (max-width: 820px) { + .metric-top-list-head { display: none; } + .metric-top-item { + grid-template-columns: 36px minmax(0, 1fr) auto; + grid-template-areas: + 'rank vm value' + 'rank bar bar' + 'rank sample sample'; + row-gap: 8px; + } + .metric-rank { grid-area: rank; } + .metric-vm-cell { grid-area: vm; } + .metric-bar-cell { grid-area: bar; } + .metric-value { grid-area: value; } + .metric-sample-time { grid-area: sample; text-align: left; } +} + +@media (max-width: 620px) { + .metric-top-header, + .metric-top-meta, + .metric-top-item { padding-left: 14px; padding-right: 14px; } + .metric-select, + .metric-time-picker { width: 100% !important; } + .metric-limit-select { flex: 1; } + .metric-top-meta { gap: 6px 14px; } + .metric-top-meta span + span::before { display: none; } +} +