fix(monitor): 虚拟机指标单位矫正与监控体验优化
VmMonitor: 同指标多VM合并为一张多折线图;net_rx/net_tx按Bytes/s直接使用不再差分;时间选择器改为相对时间动态计算;新增自动刷新。VmDetail/UserVmDetail: 磁盘IOPS改为磁盘IO速率;磁盘I/O改为磁盘读写量;网络流量改为网络速率。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -464,13 +464,13 @@
|
|||||||
<el-row :gutter="16" style="margin-top: 16px">
|
<el-row :gutter="16" style="margin-top: 16px">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<el-card shadow="hover" class="metrics-card">
|
||||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘 I/O</span></template>
|
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘读写量</span></template>
|
||||||
<div ref="diskChartRef" class="chart-container"></div>
|
<div ref="diskChartRef" class="chart-container"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<el-card shadow="hover" class="metrics-card">
|
||||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 网络流量</span></template>
|
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 网络速率</span></template>
|
||||||
<div ref="netChartRef" class="chart-container"></div>
|
<div ref="netChartRef" class="chart-container"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -478,7 +478,7 @@
|
|||||||
<el-row :gutter="16" style="margin-top: 16px">
|
<el-row :gutter="16" style="margin-top: 16px">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<el-card shadow="hover" class="metrics-card">
|
||||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘 IOPS</span></template>
|
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘IO速率</span></template>
|
||||||
<div ref="diskIopsChartRef" class="chart-container"></div>
|
<div ref="diskIopsChartRef" class="chart-container"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|||||||
@@ -656,13 +656,13 @@
|
|||||||
<el-row :gutter="16" style="margin-top: 16px">
|
<el-row :gutter="16" style="margin-top: 16px">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<el-card shadow="hover" class="metrics-card">
|
||||||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘 I/O</span></template>
|
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘读写量</span></template>
|
||||||
<div ref="diskChartRef" class="chart-container"></div>
|
<div ref="diskChartRef" class="chart-container"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<el-card shadow="hover" class="metrics-card">
|
||||||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 网络流量</span></template>
|
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 网络速率</span></template>
|
||||||
<div ref="netChartRef" class="chart-container"></div>
|
<div ref="netChartRef" class="chart-container"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -670,7 +670,7 @@
|
|||||||
<el-row :gutter="16" style="margin-top: 16px">
|
<el-row :gutter="16" style="margin-top: 16px">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<el-card shadow="hover" class="metrics-card">
|
||||||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘 IOPS</span></template>
|
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘IO速率</span></template>
|
||||||
<div ref="diskIopsChartRef" class="chart-container"></div>
|
<div ref="diskIopsChartRef" class="chart-container"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="vm-monitor-container">
|
<div class="vm-monitor-container">
|
||||||
<!-- 顶部选择器 -->
|
|
||||||
<div class="monitor-toolbar">
|
<div class="monitor-toolbar">
|
||||||
<div class="toolbar-row">
|
<div class="toolbar-row">
|
||||||
<div class="toolbar-item">
|
<div class="toolbar-item">
|
||||||
<span class="toolbar-label">选择虚拟机</span>
|
<span class="toolbar-label">虚拟机</span>
|
||||||
<el-select
|
<el-select
|
||||||
v-model="selectedVms"
|
v-model="selectedVms"
|
||||||
multiple
|
multiple
|
||||||
@@ -21,69 +20,62 @@
|
|||||||
<el-button link @click="selectedVms = []" v-if="selectedVms.length > 0">清空</el-button>
|
<el-button link @click="selectedVms = []" v-if="selectedVms.length > 0">清空</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-item">
|
<div class="toolbar-item">
|
||||||
<span class="toolbar-label">监控指标</span>
|
<span class="toolbar-label">指标</span>
|
||||||
<el-checkbox-group v-model="selectedMetrics">
|
<el-checkbox-group v-model="selectedMetrics">
|
||||||
<el-checkbox label="cpu">CPU</el-checkbox>
|
<el-checkbox label="cpu">CPU</el-checkbox>
|
||||||
<el-checkbox label="memory">内存</el-checkbox>
|
<el-checkbox label="memory">内存</el-checkbox>
|
||||||
<el-checkbox label="disk">磁盘 IO</el-checkbox>
|
<el-checkbox label="disk">磁盘IO</el-checkbox>
|
||||||
<el-checkbox label="network">网络</el-checkbox>
|
<el-checkbox label="network">网络</el-checkbox>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-row">
|
<div class="toolbar-row">
|
||||||
<div class="toolbar-item">
|
<div class="toolbar-item">
|
||||||
<span class="toolbar-label">时间范围</span>
|
<span class="toolbar-label">时间</span>
|
||||||
<el-date-picker
|
<el-select v-model="timeRange" style="width: 140px" @change="handleRefresh">
|
||||||
v-model="dateRange"
|
<el-option label="最近10分钟" :value="10" />
|
||||||
type="datetimerange"
|
<el-option label="最近30分钟" :value="30" />
|
||||||
range-separator="至"
|
<el-option label="最近1小时" :value="60" />
|
||||||
start-placeholder="开始时间"
|
<el-option label="最近3小时" :value="180" />
|
||||||
end-placeholder="结束时间"
|
<el-option label="最近6小时" :value="360" />
|
||||||
:shortcuts="dateShortcuts"
|
<el-option label="最近12小时" :value="720" />
|
||||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
<el-option label="最近24小时" :value="1440" />
|
||||||
size="default"
|
</el-select>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-item">
|
<div class="toolbar-item">
|
||||||
<el-button type="primary" @click="loadAllMetrics" :loading="metricsLoading" :disabled="selectedVms.length === 0">
|
<span class="toolbar-label">自动刷新</span>
|
||||||
开始监控
|
<el-select v-model="autoRefreshInterval" style="width: 120px" @change="resetAutoRefresh">
|
||||||
</el-button>
|
<el-option label="关闭" :value="0" />
|
||||||
<el-button @click="loadAllMetrics" :loading="metricsLoading" :disabled="selectedVms.length === 0">
|
<el-option label="10秒" :value="10" />
|
||||||
|
<el-option label="30秒" :value="30" />
|
||||||
|
<el-option label="1分钟" :value="60" />
|
||||||
|
<el-option label="5分钟" :value="300" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-item">
|
||||||
|
<el-button type="primary" @click="handleRefresh" :loading="metricsLoading" :disabled="selectedVms.length === 0">
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图表区域 -->
|
|
||||||
<div class="charts-area" v-if="hasData">
|
<div class="charts-area" v-if="hasData">
|
||||||
<template v-for="metric in selectedMetrics" :key="metric">
|
<div class="chart-section" v-for="metric in selectedMetrics" :key="metric">
|
||||||
<div class="metric-section">
|
<h3 class="chart-section-title">{{ metricLabels[metric] }}</h3>
|
||||||
<h3 class="metric-section-title">{{ metricLabels[metric] }}</h3>
|
<div class="chart-wrapper">
|
||||||
<div class="charts-grid">
|
<div class="chart-box" :ref="el => setChartRef(metric, el)"></div>
|
||||||
<div
|
|
||||||
v-for="vmName in selectedVms"
|
|
||||||
:key="`${metric}-${vmName}`"
|
|
||||||
class="chart-card"
|
|
||||||
>
|
|
||||||
<div class="chart-card-header">
|
|
||||||
<span class="chart-vm-name">{{ vmName }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="chart-box" :ref="el => setChartRef(metric, vmName, el)"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<el-empty v-else-if="!metricsLoading && selectedVms.length === 0" description="请选择要监控的虚拟机" :image-size="80" />
|
<el-empty v-else-if="!metricsLoading && selectedVms.length === 0" description="请选择要监控的虚拟机" :image-size="80" />
|
||||||
<el-empty v-else-if="!metricsLoading && loaded && !hasData" description="暂无监控数据" :image-size="80" />
|
<el-empty v-else-if="!metricsLoading && loaded && !hasData" description="暂无监控数据" :image-size="80" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, inject, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
import { ref, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getVmList, getMetricsHistory } from '@/api/admin/kvmService'
|
import { getVmList, getMetricsHistory } from '@/api/admin/kvmService'
|
||||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
@@ -99,47 +91,38 @@ const selectedMetrics = ref(['cpu', 'memory'])
|
|||||||
const metricsLoading = ref(false)
|
const metricsLoading = ref(false)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const metricsDataMap = ref({})
|
const metricsDataMap = ref({})
|
||||||
|
const timeRange = ref(60)
|
||||||
|
const autoRefreshInterval = ref(0)
|
||||||
|
let autoRefreshTimer = null
|
||||||
|
|
||||||
const metricLabels = {
|
const metricLabels = {
|
||||||
cpu: 'CPU 使用率',
|
cpu: 'CPU 使用率',
|
||||||
memory: '内存使用',
|
memory: '内存使用',
|
||||||
disk: '磁盘 IO 速率',
|
disk: '磁盘IO速率',
|
||||||
network: '网络流量速率'
|
network: '网络速率'
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateShortcuts = [
|
const vmColors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#b37feb', '#36cfc9', '#ff85c0', '#ffc53d', '#597ef7']
|
||||||
{ text: '最近10分钟', value: () => { const e = new Date(); return [new Date(e - 10 * 60 * 1000), e] } },
|
|
||||||
{ text: '最近30分钟', value: () => { const e = new Date(); return [new Date(e - 30 * 60 * 1000), e] } },
|
|
||||||
{ text: '最近1小时', value: () => { const e = new Date(); return [new Date(e - 60 * 60 * 1000), e] } },
|
|
||||||
{ text: '最近3小时', value: () => { const e = new Date(); return [new Date(e - 3 * 60 * 60 * 1000), e] } },
|
|
||||||
{ text: '最近6小时', value: () => { const e = new Date(); return [new Date(e - 6 * 60 * 60 * 1000), e] } },
|
|
||||||
{ text: '最近24小时', value: () => { const e = new Date(); return [new Date(e - 24 * 60 * 60 * 1000), e] } },
|
|
||||||
]
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const dateRange = ref([new Date(now - 60 * 60 * 1000), now])
|
|
||||||
|
|
||||||
const hasData = computed(() => Object.keys(metricsDataMap.value).length > 0)
|
const hasData = computed(() => Object.keys(metricsDataMap.value).length > 0)
|
||||||
|
|
||||||
const chartInstances = {}
|
const chartInstances = {}
|
||||||
|
const chartElements = {}
|
||||||
|
|
||||||
const setChartRef = (metric, vmName, el) => {
|
const setChartRef = (metric, el) => {
|
||||||
const key = `${metric}-${vmName}`
|
|
||||||
if (el) {
|
if (el) {
|
||||||
|
chartElements[metric] = el
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!chartInstances[key]) {
|
if (!chartInstances[metric]) {
|
||||||
chartInstances[key] = echarts.init(el)
|
chartInstances[metric] = echarts.init(el)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAllVms = () => {
|
const selectAllVms = () => { selectedVms.value = vmOptions.value.map(v => v.name) }
|
||||||
selectedVms.value = vmOptions.value.map(v => v.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
const calcInterval = (start, end) => {
|
const calcInterval = (ms) => {
|
||||||
const ms = end - start
|
|
||||||
if (ms <= 10 * 60 * 1000) return '1m'
|
if (ms <= 10 * 60 * 1000) return '1m'
|
||||||
if (ms <= 30 * 60 * 1000) return '3m'
|
if (ms <= 30 * 60 * 1000) return '3m'
|
||||||
if (ms <= 60 * 60 * 1000) return '5m'
|
if (ms <= 60 * 60 * 1000) return '5m'
|
||||||
@@ -147,8 +130,26 @@ const calcInterval = (start, end) => {
|
|||||||
if (ms <= 6 * 3600 * 1000) return '20m'
|
if (ms <= 6 * 3600 * 1000) return '20m'
|
||||||
if (ms <= 12 * 3600 * 1000) return '30m'
|
if (ms <= 12 * 3600 * 1000) return '30m'
|
||||||
if (ms <= 24 * 3600 * 1000) return '1h'
|
if (ms <= 24 * 3600 * 1000) return '1h'
|
||||||
if (ms <= 72 * 3600 * 1000) return '3h'
|
return '3h'
|
||||||
return '1d'
|
}
|
||||||
|
|
||||||
|
const formatBytes = (val) => {
|
||||||
|
if (!val && val !== 0) return '0 B'
|
||||||
|
val = Math.abs(Number(val))
|
||||||
|
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||||||
|
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||||||
|
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||||||
|
return val.toFixed(0) + ' B'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytesPerSec = (val) => formatBytes(val) + '/s'
|
||||||
|
|
||||||
|
const formatMemKiB = (kib) => {
|
||||||
|
if (!kib && kib !== 0) return '0'
|
||||||
|
kib = Math.abs(Number(kib))
|
||||||
|
if (kib >= 1048576) return (kib / 1048576).toFixed(1) + ' GB'
|
||||||
|
if (kib >= 1024) return (kib / 1024).toFixed(0) + ' MB'
|
||||||
|
return kib.toFixed(0) + ' KB'
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadVmList = async () => {
|
const loadVmList = async () => {
|
||||||
@@ -168,30 +169,25 @@ const loadVmList = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAllMetrics = async () => {
|
const handleRefresh = async () => {
|
||||||
if (!selectedVms.value.length) return
|
if (!selectedVms.value.length) return
|
||||||
if (!dateRange.value || dateRange.value.length < 2) {
|
|
||||||
ElMessage.warning('请选择时间范围')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
metricsLoading.value = true
|
metricsLoading.value = true
|
||||||
const startTime = new Date(dateRange.value[0])
|
const endTime = new Date()
|
||||||
const endTime = new Date(dateRange.value[1])
|
const startTime = new Date(endTime - timeRange.value * 60 * 1000)
|
||||||
const interval = calcInterval(startTime, endTime)
|
const interval = calcInterval(endTime - startTime)
|
||||||
|
|
||||||
const dataMap = {}
|
const dataMap = {}
|
||||||
try {
|
try {
|
||||||
const requests = selectedVms.value.map(async (vmName) => {
|
await Promise.all(selectedVms.value.map(async (vmName) => {
|
||||||
try {
|
try {
|
||||||
const params = {
|
const res = await getMetricsHistory({
|
||||||
service_id: serviceId.value,
|
service_id: serviceId.value,
|
||||||
host_id: hostId.value,
|
host_id: hostId.value,
|
||||||
vm_name: vmName,
|
vm_name: vmName,
|
||||||
start: startTime.toISOString(),
|
start: startTime.toISOString(),
|
||||||
end_time: endTime.toISOString(),
|
end_time: endTime.toISOString(),
|
||||||
interval
|
interval
|
||||||
}
|
})
|
||||||
const res = await getMetricsHistory(params)
|
|
||||||
const body = res?.data
|
const body = res?.data
|
||||||
if (body?.code === 200 && body?.data) {
|
if (body?.code === 200 && body?.data) {
|
||||||
dataMap[vmName] = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
dataMap[vmName] = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
||||||
@@ -199,8 +195,7 @@ const loadAllMetrics = async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`获取 ${vmName} 监控数据失败:`, e)
|
console.warn(`获取 ${vmName} 监控数据失败:`, e)
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
await Promise.all(requests)
|
|
||||||
metricsDataMap.value = dataMap
|
metricsDataMap.value = dataMap
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -213,99 +208,137 @@ const loadAllMetrics = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderCharts = () => {
|
const renderCharts = () => {
|
||||||
const spanMs = dateRange.value ? (new Date(dateRange.value[1]).getTime() - new Date(dateRange.value[0]).getTime()) : 0
|
const showDate = timeRange.value >= 720
|
||||||
const showDate = spanMs >= 12 * 3600 * 1000
|
|
||||||
const labelRotate = showDate ? 30 : 0
|
const labelRotate = showDate ? 30 : 0
|
||||||
|
|
||||||
for (const metric of selectedMetrics.value) {
|
for (const metric of selectedMetrics.value) {
|
||||||
for (const vmName of selectedVms.value) {
|
const chart = chartInstances[metric]
|
||||||
const key = `${metric}-${vmName}`
|
if (!chart) continue
|
||||||
const chart = chartInstances[key]
|
|
||||||
|
const allTimes = getUnifiedTimeline()
|
||||||
|
if (!allTimes.length) continue
|
||||||
|
|
||||||
|
const timeLabels = allTimes.map(t => {
|
||||||
|
const date = new Date(t)
|
||||||
|
if (showDate) return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const seriesList = []
|
||||||
|
selectedVms.value.forEach((vmName, idx) => {
|
||||||
const metrics = metricsDataMap.value[vmName]
|
const metrics = metricsDataMap.value[vmName]
|
||||||
if (!chart || !metrics || !metrics.length) continue
|
if (!metrics || !metrics.length) return
|
||||||
|
const color = vmColors[idx % vmColors.length]
|
||||||
|
const vmData = buildMetricData(metric, metrics, allTimes)
|
||||||
|
seriesList.push({ name: vmName, data: vmData, color })
|
||||||
|
})
|
||||||
|
|
||||||
const times = metrics.map(m => {
|
if (!seriesList.length) continue
|
||||||
const date = new Date(m.bucket)
|
|
||||||
if (showDate) return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
||||||
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
|
|
||||||
})
|
|
||||||
|
|
||||||
let option = null
|
let yAxisFormatter, tooltipUnit
|
||||||
if (metric === 'cpu') {
|
if (metric === 'cpu') {
|
||||||
option = buildLineOption(times, [
|
yAxisFormatter = v => v.toFixed(0) + '%'
|
||||||
{ name: 'CPU使用率', data: metrics.map(m => +(m.cpu_usage ?? 0).toFixed(1)), color: '#409eff' }
|
tooltipUnit = '%'
|
||||||
], '%', labelRotate)
|
} else if (metric === 'memory') {
|
||||||
} else if (metric === 'memory') {
|
yAxisFormatter = v => formatMemKiB(v)
|
||||||
const memTotal = Math.max(...metrics.map(m => m.mem_total ?? 0))
|
tooltipUnit = 'KiB'
|
||||||
const unit = memTotal >= 1048576 ? 'GB' : memTotal >= 1024 ? 'MB' : 'KB'
|
} else if (metric === 'disk') {
|
||||||
const divisor = memTotal >= 1048576 ? 1048576 : memTotal >= 1024 ? 1024 : 1
|
yAxisFormatter = v => formatBytesPerSec(v)
|
||||||
option = buildLineOption(times, [
|
tooltipUnit = '/s'
|
||||||
{ name: '已用内存', data: metrics.map(m => +((m.mem_used ?? 0) / divisor).toFixed(2)), color: '#e6a23c' },
|
} else {
|
||||||
{ name: '总内存', data: metrics.map(m => +((m.mem_total ?? 0) / divisor).toFixed(2)), color: '#c0c4cc', lineStyle: { type: 'dashed' } }
|
yAxisFormatter = v => formatBytesPerSec(v)
|
||||||
], unit, labelRotate)
|
tooltipUnit = '/s'
|
||||||
} else if (metric === 'disk') {
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
option = buildLineOption(times, [
|
|
||||||
{ name: '读取', data: diskReadRate, color: '#67c23a' },
|
|
||||||
{ name: '写入', data: diskWriteRate, color: '#f56c6c' }
|
|
||||||
], 'KB/s', labelRotate)
|
|
||||||
} else if (metric === 'network') {
|
|
||||||
const netRxRate = []; const netTxRate = []
|
|
||||||
for (let i = 0; i < metrics.length; i++) {
|
|
||||||
if (i === 0) { netRxRate.push(0); netTxRate.push(0); continue }
|
|
||||||
const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000
|
|
||||||
if (dt > 0) {
|
|
||||||
netRxRate.push(+Math.max(0, ((metrics[i].net_rx ?? 0) - (metrics[i - 1].net_rx ?? 0)) / dt / 1024).toFixed(2))
|
|
||||||
netTxRate.push(+Math.max(0, ((metrics[i].net_tx ?? 0) - (metrics[i - 1].net_tx ?? 0)) / dt / 1024).toFixed(2))
|
|
||||||
} else { netRxRate.push(0); netTxRate.push(0) }
|
|
||||||
}
|
|
||||||
option = buildLineOption(times, [
|
|
||||||
{ name: '接收', data: netRxRate, color: '#409eff' },
|
|
||||||
{ name: '发送', data: netTxRate, color: '#e6a23c' }
|
|
||||||
], 'KB/s', labelRotate)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option) {
|
|
||||||
chart.setOption(option, true)
|
|
||||||
chart.resize()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params) => {
|
||||||
|
let s = params[0]?.axisValue || ''
|
||||||
|
params.forEach(p => {
|
||||||
|
let val
|
||||||
|
if (metric === 'cpu') val = (p.value ?? 0).toFixed(1) + '%'
|
||||||
|
else if (metric === 'memory') val = formatMemKiB(p.value)
|
||||||
|
else val = formatBytesPerSec(p.value)
|
||||||
|
s += `<br/>${p.marker} ${p.seriesName}: ${val}`
|
||||||
|
})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: { top: 4, right: 8, textStyle: { fontSize: 11 }, type: 'scroll' },
|
||||||
|
grid: { top: 40, left: 70, right: 16, bottom: labelRotate > 0 ? 55 : 35 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category', data: timeLabels, boundaryGap: false,
|
||||||
|
axisLabel: { fontSize: 10, rotate: labelRotate, color: '#86909c' },
|
||||||
|
axisLine: { lineStyle: { color: '#e8e8e8' } }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value', min: 0,
|
||||||
|
max: metric === 'cpu' ? 100 : undefined,
|
||||||
|
axisLabel: { fontSize: 10, formatter: yAxisFormatter, color: '#86909c' },
|
||||||
|
splitLine: { lineStyle: { color: '#f0f0f0' } }
|
||||||
|
},
|
||||||
|
series: seriesList.map(s => ({
|
||||||
|
name: s.name,
|
||||||
|
type: 'line',
|
||||||
|
data: s.data,
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { width: 1.5 },
|
||||||
|
itemStyle: { color: s.color },
|
||||||
|
areaStyle: seriesList.length <= 3
|
||||||
|
? { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: s.color + '20' }, { offset: 1, color: s.color + '02' }]) }
|
||||||
|
: undefined
|
||||||
|
}))
|
||||||
|
}, true)
|
||||||
|
chart.resize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildLineOption = (xData, series, yUnit, labelRotate = 0) => ({
|
const getUnifiedTimeline = () => {
|
||||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
const timeSet = new Set()
|
||||||
legend: { top: 4, right: 8, textStyle: { fontSize: 11 } },
|
for (const vmName of selectedVms.value) {
|
||||||
grid: { top: 36, left: 50, right: 16, bottom: labelRotate > 0 ? 50 : 30 },
|
const data = metricsDataMap.value[vmName]
|
||||||
xAxis: {
|
if (data) data.forEach(m => timeSet.add(m.bucket))
|
||||||
type: 'category', data: xData, boundaryGap: false,
|
}
|
||||||
axisLabel: { fontSize: 10, rotate: labelRotate, color: '#86909c' },
|
return Array.from(timeSet).sort()
|
||||||
axisLine: { lineStyle: { color: '#e8e8e8' } }
|
}
|
||||||
},
|
|
||||||
yAxis: {
|
const buildMetricData = (metric, metrics, allTimes) => {
|
||||||
type: 'value',
|
const timeMap = new Map()
|
||||||
axisLabel: { fontSize: 10, formatter: v => v + (yUnit ? ` ${yUnit}` : ''), color: '#86909c' },
|
metrics.forEach((m, i) => timeMap.set(m.bucket, { ...m, _idx: i }))
|
||||||
splitLine: { lineStyle: { color: '#f0f0f0' } }
|
|
||||||
},
|
return allTimes.map((t, tIdx) => {
|
||||||
series: series.map(s => ({
|
const m = timeMap.get(t)
|
||||||
name: s.name,
|
if (!m) return null
|
||||||
type: 'line',
|
|
||||||
data: s.data,
|
if (metric === 'cpu') {
|
||||||
smooth: true,
|
return +(m.cpu_usage ?? 0).toFixed(1)
|
||||||
symbol: 'none',
|
} else if (metric === 'memory') {
|
||||||
lineStyle: { width: 1.5, ...(s.lineStyle || {}) },
|
return +(m.mem_used ?? 0)
|
||||||
itemStyle: { color: s.color },
|
} else if (metric === 'disk') {
|
||||||
areaStyle: s.lineStyle?.type === 'dashed' ? undefined : { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: s.color + '30' }, { offset: 1, color: s.color + '05' }]) }
|
// disk_read/disk_write are cumulative Bytes, compute rate via diff
|
||||||
}))
|
if (m._idx === 0) return 0
|
||||||
})
|
const prev = metrics[m._idx - 1]
|
||||||
|
const dt = (new Date(m.bucket) - new Date(prev.bucket)) / 1000
|
||||||
|
if (dt <= 0) return 0
|
||||||
|
const readRate = Math.max(0, ((m.disk_read ?? 0) - (prev.disk_read ?? 0)) / dt)
|
||||||
|
const writeRate = Math.max(0, ((m.disk_write ?? 0) - (prev.disk_write ?? 0)) / dt)
|
||||||
|
return +(readRate + writeRate).toFixed(0)
|
||||||
|
} else if (metric === 'network') {
|
||||||
|
// net_rx/net_tx are already Bytes/s
|
||||||
|
return +((m.net_rx ?? 0) + (m.net_tx ?? 0)).toFixed(0)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAutoRefresh = () => {
|
||||||
|
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null }
|
||||||
|
if (autoRefreshInterval.value > 0) {
|
||||||
|
autoRefreshTimer = setInterval(() => handleRefresh(), autoRefreshInterval.value * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const disposeCharts = () => {
|
const disposeCharts = () => {
|
||||||
Object.values(chartInstances).forEach(c => { try { c.dispose() } catch {} })
|
Object.values(chartInstances).forEach(c => { try { c.dispose() } catch {} })
|
||||||
@@ -322,6 +355,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer)
|
||||||
disposeCharts()
|
disposeCharts()
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
})
|
})
|
||||||
@@ -364,44 +398,26 @@ defineExpose({ loadVmList, loadList: loadVmList })
|
|||||||
|
|
||||||
.charts-area { margin-top: 8px; }
|
.charts-area { margin-top: 8px; }
|
||||||
|
|
||||||
.metric-section { margin-bottom: 24px; }
|
.chart-section { margin-bottom: 20px; }
|
||||||
|
|
||||||
.metric-section-title {
|
.chart-section-title {
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1d2129;
|
color: #1d2129;
|
||||||
margin: 0 0 12px;
|
margin: 0 0 8px;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
border-left: 3px solid #409eff;
|
border-left: 3px solid #409eff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.charts-grid {
|
.chart-wrapper {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card {
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e8e8e8;
|
border: 1px solid #e8e8e8;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-vm-name {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-box {
|
.chart-box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 200px;
|
height: 260px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user