feat(host-detail): 新增虚拟机监控 tab
支持多选虚拟机和监控指标(CPU/内存/磁盘IO/网络),基于 metrics_history 接口渲染 ECharts 图表;时间范围可选。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -350,6 +350,9 @@
|
||||
<el-tab-pane label="备份管理" name="backup">
|
||||
<BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="虚拟机监控" name="vmMonitor">
|
||||
<VmMonitor v-if="hostTabLoaded['vmMonitor']" ref="vmMonitorRef" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="组网管理" name="networking">
|
||||
<div class="section-block">
|
||||
@@ -745,6 +748,7 @@ import VolumeManage from '@/views/virtualization/VolumeManage.vue'
|
||||
import VmManage from '@/views/virtualization/VmManage.vue'
|
||||
import SnapshotManage from '@/views/virtualization/SnapshotManage.vue'
|
||||
import BackupManage from '@/views/virtualization/BackupManage.vue'
|
||||
import VmMonitor from '@/views/virtualization/VmMonitor.vue'
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||
@@ -759,7 +763,7 @@ const serviceName = computed(() => route.query.service_name || '')
|
||||
const hostId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const activeTab = ref('info')
|
||||
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, networking: false })
|
||||
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, vmMonitor: false, networking: false })
|
||||
|
||||
const imageManageRef = ref(null)
|
||||
const networkManageRef = ref(null)
|
||||
@@ -767,7 +771,8 @@ const volumeManageRef = ref(null)
|
||||
const vmManageRef = ref(null)
|
||||
const snapshotManageRef = ref(null)
|
||||
const backupManageRef = ref(null)
|
||||
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef }
|
||||
const vmMonitorRef = ref(null)
|
||||
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef, vmMonitor: vmMonitorRef }
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (!['info', 'monitor', 'networking'].includes(tab)) {
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
<template>
|
||||
<div class="vm-monitor-container">
|
||||
<!-- 顶部选择器 -->
|
||||
<div class="monitor-toolbar">
|
||||
<div class="toolbar-row">
|
||||
<div class="toolbar-item">
|
||||
<span class="toolbar-label">选择虚拟机</span>
|
||||
<el-select
|
||||
v-model="selectedVms"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="选择要监控的虚拟机"
|
||||
style="width: 360px"
|
||||
filterable
|
||||
:loading="vmListLoading"
|
||||
>
|
||||
<el-option v-for="vm in vmOptions" :key="vm.name" :label="`${vm.name} (ID:${vm.id})`" :value="vm.name" />
|
||||
</el-select>
|
||||
<el-button link type="primary" @click="selectAllVms" v-if="vmOptions.length > 0">全选</el-button>
|
||||
<el-button link @click="selectedVms = []" v-if="selectedVms.length > 0">清空</el-button>
|
||||
</div>
|
||||
<div class="toolbar-item">
|
||||
<span class="toolbar-label">监控指标</span>
|
||||
<el-checkbox-group v-model="selectedMetrics">
|
||||
<el-checkbox label="cpu">CPU</el-checkbox>
|
||||
<el-checkbox label="memory">内存</el-checkbox>
|
||||
<el-checkbox label="disk">磁盘 IO</el-checkbox>
|
||||
<el-checkbox label="network">网络</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-row">
|
||||
<div class="toolbar-item">
|
||||
<span class="toolbar-label">时间范围</span>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
:shortcuts="dateShortcuts"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
size="default"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar-item">
|
||||
<el-button type="primary" @click="loadAllMetrics" :loading="metricsLoading" :disabled="selectedVms.length === 0">
|
||||
开始监控
|
||||
</el-button>
|
||||
<el-button @click="loadAllMetrics" :loading="metricsLoading" :disabled="selectedVms.length === 0">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<div class="charts-area" v-if="hasData">
|
||||
<template v-for="metric in selectedMetrics" :key="metric">
|
||||
<div class="metric-section">
|
||||
<h3 class="metric-section-title">{{ metricLabels[metric] }}</h3>
|
||||
<div class="charts-grid">
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<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" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getVmList, getMetricsHistory } from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const serviceId = inject('serviceId')
|
||||
const hostId = inject('hostId')
|
||||
|
||||
const vmListLoading = ref(false)
|
||||
const vmOptions = ref([])
|
||||
const selectedVms = ref([])
|
||||
const selectedMetrics = ref(['cpu', 'memory'])
|
||||
const metricsLoading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const metricsDataMap = ref({})
|
||||
|
||||
const metricLabels = {
|
||||
cpu: 'CPU 使用率',
|
||||
memory: '内存使用',
|
||||
disk: '磁盘 IO 速率',
|
||||
network: '网络流量速率'
|
||||
}
|
||||
|
||||
const dateShortcuts = [
|
||||
{ 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 chartInstances = {}
|
||||
|
||||
const setChartRef = (metric, vmName, el) => {
|
||||
const key = `${metric}-${vmName}`
|
||||
if (el) {
|
||||
nextTick(() => {
|
||||
if (!chartInstances[key]) {
|
||||
chartInstances[key] = echarts.init(el)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectAllVms = () => {
|
||||
selectedVms.value = vmOptions.value.map(v => v.name)
|
||||
}
|
||||
|
||||
const calcInterval = (start, end) => {
|
||||
const ms = end - start
|
||||
if (ms <= 10 * 60 * 1000) return '1m'
|
||||
if (ms <= 30 * 60 * 1000) return '3m'
|
||||
if (ms <= 60 * 60 * 1000) return '5m'
|
||||
if (ms <= 3 * 3600 * 1000) return '10m'
|
||||
if (ms <= 6 * 3600 * 1000) return '20m'
|
||||
if (ms <= 12 * 3600 * 1000) return '30m'
|
||||
if (ms <= 24 * 3600 * 1000) return '1h'
|
||||
if (ms <= 72 * 3600 * 1000) return '3h'
|
||||
return '1d'
|
||||
}
|
||||
|
||||
const loadVmList = async () => {
|
||||
if (!serviceId.value || !hostId.value) return
|
||||
vmListLoading.value = true
|
||||
try {
|
||||
const res = await getVmList({ service_id: serviceId.value, host_id: hostId.value, page: 1, count: 500 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
vmOptions.value = inner.data || inner.vms || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e?.response?.data, '获取虚拟机列表失败'))
|
||||
} finally {
|
||||
vmListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadAllMetrics = async () => {
|
||||
if (!selectedVms.value.length) return
|
||||
if (!dateRange.value || dateRange.value.length < 2) {
|
||||
ElMessage.warning('请选择时间范围')
|
||||
return
|
||||
}
|
||||
metricsLoading.value = true
|
||||
const startTime = new Date(dateRange.value[0])
|
||||
const endTime = new Date(dateRange.value[1])
|
||||
const interval = calcInterval(startTime, endTime)
|
||||
|
||||
const dataMap = {}
|
||||
try {
|
||||
const requests = selectedVms.value.map(async (vmName) => {
|
||||
try {
|
||||
const params = {
|
||||
service_id: serviceId.value,
|
||||
host_id: hostId.value,
|
||||
vm_name: vmName,
|
||||
start: startTime.toISOString(),
|
||||
end_time: endTime.toISOString(),
|
||||
interval
|
||||
}
|
||||
const res = await getMetricsHistory(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
dataMap[vmName] = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`获取 ${vmName} 监控数据失败:`, e)
|
||||
}
|
||||
})
|
||||
await Promise.all(requests)
|
||||
metricsDataMap.value = dataMap
|
||||
loaded.value = true
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (e) {
|
||||
ElMessage.error('加载监控数据失败')
|
||||
} finally {
|
||||
metricsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const renderCharts = () => {
|
||||
const spanMs = dateRange.value ? (new Date(dateRange.value[1]).getTime() - new Date(dateRange.value[0]).getTime()) : 0
|
||||
const showDate = spanMs >= 12 * 3600 * 1000
|
||||
const labelRotate = showDate ? 30 : 0
|
||||
|
||||
for (const metric of selectedMetrics.value) {
|
||||
for (const vmName of selectedVms.value) {
|
||||
const key = `${metric}-${vmName}`
|
||||
const chart = chartInstances[key]
|
||||
const metrics = metricsDataMap.value[vmName]
|
||||
if (!chart || !metrics || !metrics.length) continue
|
||||
|
||||
const times = metrics.map(m => {
|
||||
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
|
||||
if (metric === 'cpu') {
|
||||
option = buildLineOption(times, [
|
||||
{ name: 'CPU使用率', data: metrics.map(m => +(m.cpu_usage ?? 0).toFixed(1)), color: '#409eff' }
|
||||
], '%', labelRotate)
|
||||
} else if (metric === 'memory') {
|
||||
const memTotal = Math.max(...metrics.map(m => m.mem_total ?? 0))
|
||||
const unit = memTotal >= 1048576 ? 'GB' : memTotal >= 1024 ? 'MB' : 'KB'
|
||||
const divisor = memTotal >= 1048576 ? 1048576 : memTotal >= 1024 ? 1024 : 1
|
||||
option = buildLineOption(times, [
|
||||
{ name: '已用内存', data: metrics.map(m => +((m.mem_used ?? 0) / divisor).toFixed(2)), color: '#e6a23c' },
|
||||
{ name: '总内存', data: metrics.map(m => +((m.mem_total ?? 0) / divisor).toFixed(2)), color: '#c0c4cc', lineStyle: { type: 'dashed' } }
|
||||
], unit, labelRotate)
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildLineOption = (xData, series, yUnit, labelRotate = 0) => ({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
legend: { top: 4, right: 8, textStyle: { fontSize: 11 } },
|
||||
grid: { top: 36, left: 50, right: 16, bottom: labelRotate > 0 ? 50 : 30 },
|
||||
xAxis: {
|
||||
type: 'category', data: xData, boundaryGap: false,
|
||||
axisLabel: { fontSize: 10, rotate: labelRotate, color: '#86909c' },
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { fontSize: 10, formatter: v => v + (yUnit ? ` ${yUnit}` : ''), color: '#86909c' },
|
||||
splitLine: { lineStyle: { color: '#f0f0f0' } }
|
||||
},
|
||||
series: series.map(s => ({
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
data: s.data,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 1.5, ...(s.lineStyle || {}) },
|
||||
itemStyle: { color: s.color },
|
||||
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' }]) }
|
||||
}))
|
||||
})
|
||||
|
||||
const disposeCharts = () => {
|
||||
Object.values(chartInstances).forEach(c => { try { c.dispose() } catch {} })
|
||||
Object.keys(chartInstances).forEach(k => delete chartInstances[k])
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
Object.values(chartInstances).forEach(c => { try { c.resize() } catch {} })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVmList()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposeCharts()
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
defineExpose({ loadVmList, loadList: loadVmList })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vm-monitor-container { padding: 0; }
|
||||
|
||||
.monitor-toolbar {
|
||||
background: #f7f8fa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-row + .toolbar-row { margin-top: 12px; }
|
||||
|
||||
.toolbar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.charts-area { margin-top: 8px; }
|
||||
|
||||
.metric-section { margin-bottom: 24px; }
|
||||
|
||||
.metric-section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
margin: 0 0 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user