feat: 新增宿主机指标Top榜单
This commit is contained in:
@@ -449,6 +449,86 @@
|
||||
|
||||
<el-tab-pane label="监控" name="monitor">
|
||||
<div class="section-block">
|
||||
<div class="metric-top-section" v-loading="metricTopLoading">
|
||||
<div class="metric-top-header">
|
||||
<div class="metric-top-heading">
|
||||
<div class="metric-top-title-row">
|
||||
<h3 class="section-title metric-top-title"><el-icon><DataAnalysis /></el-icon> 虚拟机指标 Top 榜单</h3>
|
||||
<el-tag size="small" :type="metricTopMode === 'realtime' ? 'success' : 'warning'" effect="plain">
|
||||
{{ metricTopMode === 'realtime' ? '实时缓存' : '历史快照' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="metric-top-description">按指标降序展示当前宿主机中有成功采集样本的虚拟机</p>
|
||||
</div>
|
||||
<div class="metric-top-controls">
|
||||
<el-select v-model="metricTopMetric" size="small" class="metric-select" @change="loadMetricTop">
|
||||
<el-option-group v-for="group in metricTopOptionGroups" :key="group.label" :label="group.label">
|
||||
<el-option v-for="option in group.options" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
<el-radio-group v-model="metricTopMode" size="small" @change="handleMetricTopModeChange">
|
||||
<el-radio-button label="realtime">实时</el-radio-button>
|
||||
<el-radio-button label="historical">历史</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-date-picker
|
||||
v-if="metricTopMode === 'historical'"
|
||||
v-model="metricTopAt"
|
||||
type="datetime"
|
||||
size="small"
|
||||
placeholder="选择榜单时间"
|
||||
:disabled-date="disableFutureMetricTopDate"
|
||||
class="metric-time-picker"
|
||||
@change="loadMetricTop"
|
||||
/>
|
||||
<el-select v-model="metricTopLimit" size="small" class="metric-limit-select" @change="loadMetricTop">
|
||||
<el-option v-for="limit in metricTopLimitOptions" :key="limit" :label="`Top ${limit}`" :value="limit" />
|
||||
</el-select>
|
||||
<el-tooltip content="刷新榜单" placement="top">
|
||||
<el-button size="small" :icon="Refresh" circle @click="loadMetricTop" :loading="metricTopLoading" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="metricTopData" class="metric-top-meta">
|
||||
<span><strong>{{ currentMetricTopOption.label }}</strong></span>
|
||||
<span>{{ metricTopItems.length }} 台虚拟机</span>
|
||||
<span>{{ metricTopMode === 'realtime' ? '最新实时样本' : `查询时间 ${formatMetricTopSampleTime(metricTopData.requested_at || metricTopAt)}` }}</span>
|
||||
<span v-if="metricTopLatestSample">最近采样 {{ formatMetricTopSampleTime(metricTopLatestSample) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="metricTopItems.length" class="metric-top-list" :style="{ '--metric-color': currentMetricTopOption.color }">
|
||||
<div class="metric-top-list-head">
|
||||
<span>排名</span>
|
||||
<span>虚拟机</span>
|
||||
<span>指标分布</span>
|
||||
<span>当前值</span>
|
||||
<span>采样时间</span>
|
||||
</div>
|
||||
<div v-for="item in metricTopItems" :key="`${item.vm_id}-${item.sample_time}`" class="metric-top-item">
|
||||
<div class="metric-rank" :class="`rank-${item.rank}`">{{ item.rank }}</div>
|
||||
<div class="metric-vm-cell">
|
||||
<el-link type="primary" :underline="false" class="metric-vm-name" @click="goToVmDetail(item)">
|
||||
{{ item.vm_name || `虚拟机 #${item.vm_id}` }}
|
||||
</el-link>
|
||||
<div class="metric-vm-ids">
|
||||
<el-link type="primary" :underline="false" @click="goToVmDetail(item)">VM #{{ item.vm_id }}</el-link>
|
||||
<span v-if="item.host_vm_id">宿主机 VM #{{ item.host_vm_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-bar-cell">
|
||||
<div class="metric-bar-track">
|
||||
<span class="metric-bar-fill" :style="{ width: `${getMetricTopBarPercent(item.value)}%` }"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-value">{{ formatMetricTopValue(item) }}</div>
|
||||
<time class="metric-sample-time">{{ formatMetricTopSampleTime(item.sample_time) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else-if="!metricTopLoading" description="当前指标暂无可用虚拟机样本" :image-size="64" />
|
||||
</div>
|
||||
|
||||
<div class="monitor-section-divider"></div>
|
||||
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">监控指标</h3>
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user