feat(admin): KSM内存去重管理+监控图表增强+额度统计UI重构+流量管理合并 -- 缘由: 后端新增KSM状态/配置接口,监控数据改为绝对值,额度统计需可视化 -- 预期: HostDetail支持KSM查看/启停/调参,内存图表改为绝对值+磁盘IOPS图+流量趋势图,额度统计改为环形进度卡片,流量策略与统计合并为流量管理tab,订单代金券改为非必填,VmManage显示累计流量
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - 数据卷管理
|
||||
|
||||
@@ -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 })
|
||||
|
||||
// ========== 流量策略 ==========
|
||||
// 测试未通过(接口新增,待联调)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -439,6 +439,11 @@
|
||||
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
|
||||
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
|
||||
</div>
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">累计流量</div>
|
||||
<div class="metric-summary-value">{{ latestMetrics.traffic_used_mb != null ? latestMetrics.traffic_used_mb + ' MB' : '-' }}</div>
|
||||
<div class="metric-summary-sub"> </div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="metricsData">
|
||||
@@ -451,7 +456,7 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<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="memChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -470,13 +475,27 @@
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘 IOPS</span></template>
|
||||
<div ref="diskIopsChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 流量使用趋势</span></template>
|
||||
<div ref="trafficUsedChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
<el-empty v-else-if="!metricsLoading" description="暂无监控数据" :image-size="80" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 流量策略 -->
|
||||
<el-tab-pane v-if="isVmGoods" label="流量策略" name="trafficPolicy">
|
||||
<!-- 流量管理(合并流量策略 + 流量统计) -->
|
||||
<el-tab-pane v-if="isVmGoods" label="流量管理" name="trafficManage">
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">流量策略</h3>
|
||||
@@ -494,6 +513,30 @@
|
||||
</el-descriptions>
|
||||
<el-empty v-else-if="!trafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
|
||||
</div>
|
||||
<div class="section-block" style="margin-top:16px">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">每小时流量</h3>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<el-date-picker
|
||||
v-model="trafficHourlyRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
size="small"
|
||||
style="width: 360px"
|
||||
:shortcuts="monitorShortcuts"
|
||||
@change="loadTrafficHourly"
|
||||
/>
|
||||
<el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card v-if="trafficHourlyData.length" shadow="hover" class="metrics-card" style="margin-top:12px">
|
||||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 每小时流量(MB)</span></template>
|
||||
<div ref="trafficHourlyChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!trafficHourlyLoading" description="暂无流量统计数据" :image-size="60" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
@@ -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('<br/>') },
|
||||
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}<br/>${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}<br/>${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 += `<br/>${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}<br/>${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() })
|
||||
|
||||
@@ -151,6 +151,42 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">KSM 内存去重</h3>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button size="small" :icon="Refresh" @click="loadKsmStatus" :loading="ksmLoading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="ksmStats">
|
||||
<el-descriptions :column="3" border size="small" style="margin-top:12px">
|
||||
<el-descriptions-item label="内核支持">
|
||||
<el-tag :type="ksmStats.available ? 'success' : 'danger'" size="small">{{ ksmStats.available ? '支持' : '不支持' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="启用状态">
|
||||
<el-tag :type="ksmStats.enabled ? 'success' : 'info'" size="small">{{ ksmStats.enabled ? '已启用' : '未启用' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="跨 NUMA 合并">
|
||||
<el-tag :type="ksmStats.merge_across_nodes ? 'warning' : 'info'" size="small">{{ ksmStats.merge_across_nodes ? '是' : '否' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="扫描页面数">{{ ksmStats.pages_to_scan ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="扫描间隔">{{ ksmStats.sleep_millisecs != null ? ksmStats.sleep_millisecs + ' ms' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="完整扫描次数">{{ ksmStats.full_scans ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已合并唯一页面">{{ ksmStats.pages_shared ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="共享总页面">{{ ksmStats.pages_sharing ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="未合并页面">{{ ksmStats.pages_unshared ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="节省内存">
|
||||
<span style="font-weight:600;color:#67c23a">{{ formatMemKB(ksmStats.memory_saved_kb) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div style="margin-top:12px;display:flex;gap:8px">
|
||||
<el-button v-if="!ksmStats.enabled" type="success" size="small" :loading="ksmActionLoading" @click="handleKsmAction('enable')">启用 KSM</el-button>
|
||||
<el-button v-if="ksmStats.enabled" type="danger" size="small" :loading="ksmActionLoading" @click="handleKsmAction('disable')">关闭 KSM</el-button>
|
||||
<el-button v-if="ksmStats.available" type="primary" size="small" @click="openKsmTuneDialog">调参</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else-if="!ksmLoading" description="点击刷新加载 KSM 状态" :image-size="60" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="额度统计" name="quotaStats">
|
||||
@@ -160,29 +196,58 @@
|
||||
<el-button size="small" :icon="Refresh" @click="loadQuotaStats" :loading="quotaStatsLoading">刷新</el-button>
|
||||
</div>
|
||||
<template v-if="quotaStats">
|
||||
<el-descriptions :column="3" border size="small" style="margin-top:12px">
|
||||
<el-descriptions-item label="虚拟机数量">{{ quotaStats.vm_count ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规划 CPU">{{ quotaStats.planned_cpu ?? '-' }} 核</el-descriptions-item>
|
||||
<el-descriptions-item label="已分配 CPU">{{ quotaStats.allocated_cpu ?? '-' }} 核</el-descriptions-item>
|
||||
<el-descriptions-item label="规划内存">{{ formatQuotaMem(quotaStats.planned_memory) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已分配内存">{{ formatQuotaMem(quotaStats.allocated_memory) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实时内存">{{ formatQuotaBytes(quotaStats.actual_memory_used) }} / {{ formatQuotaBytes(quotaStats.actual_memory_total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规划磁盘">{{ quotaStats.planned_disk ?? '-' }} GB</el-descriptions-item>
|
||||
<el-descriptions-item label="已分配磁盘">{{ quotaStats.allocated_disk ?? '-' }} GB</el-descriptions-item>
|
||||
<el-descriptions-item label="实时 CPU">{{ quotaStats.actual_cpu_percent != null ? quotaStats.actual_cpu_percent.toFixed(1) + '%' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规划下行带宽">{{ quotaStats.planned_rx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="已分配下行带宽">{{ quotaStats.allocated_rx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="规划上行带宽">{{ quotaStats.planned_tx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="已分配上行带宽">{{ quotaStats.allocated_tx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="quota-grid">
|
||||
<div class="quota-card quota-card-vm">
|
||||
<div class="quota-card-icon"><el-icon :size="28" color="#409eff"><Monitor /></el-icon></div>
|
||||
<div class="quota-card-body">
|
||||
<div class="quota-card-label">虚拟机数量</div>
|
||||
<div class="quota-card-number">{{ quotaStats.vm_count ?? 0 }}<span class="quota-card-unit">台</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-card" v-for="item in quotaAllocationItems" :key="item.label">
|
||||
<el-progress type="circle" :percentage="Math.min(100, item.ratio)" :width="80" :stroke-width="7" :color="quotaColor(item.ratio)">
|
||||
<span class="quota-ring-pct" :style="{ color: quotaColor(item.ratio) }">{{ item.ratio }}%</span>
|
||||
</el-progress>
|
||||
<div class="quota-card-body">
|
||||
<div class="quota-card-label">{{ item.label }}</div>
|
||||
<div class="quota-card-alloc">{{ item.allocatedText }} <span class="quota-card-sep">/</span> {{ item.plannedText }}</div>
|
||||
<div class="quota-card-sub">已分配 / 规划</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-actual-section">
|
||||
<h4 class="quota-subtitle">实时使用</h4>
|
||||
<div class="quota-actual-grid">
|
||||
<div class="quota-actual-card">
|
||||
<div class="quota-actual-header">
|
||||
<span class="quota-actual-label">CPU 使用率</span>
|
||||
<span class="quota-actual-value" :style="{ color: quotaColor(quotaStats.actual_cpu_percent ?? 0) }">{{ quotaStats.actual_cpu_percent != null ? quotaStats.actual_cpu_percent.toFixed(1) + '%' : '-' }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="Math.min(100, quotaStats.actual_cpu_percent ?? 0)" :show-text="false" :stroke-width="10" :color="quotaColor(quotaStats.actual_cpu_percent ?? 0)" />
|
||||
</div>
|
||||
<div class="quota-actual-card">
|
||||
<div class="quota-actual-header">
|
||||
<span class="quota-actual-label">内存使用</span>
|
||||
<span class="quota-actual-value" :style="{ color: quotaColor(actualMemPercent) }">{{ formatQuotaBytes(quotaStats.actual_memory_used) }} / {{ formatQuotaBytes(quotaStats.actual_memory_total) }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="actualMemPercent" :show-text="false" :stroke-width="10" :color="quotaColor(actualMemPercent)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="quotaStatsDisk.length">
|
||||
<h4 style="margin:16px 0 8px;font-size:13px;color:#606266">磁盘使用</h4>
|
||||
<el-table :data="quotaStatsDisk" size="small" stripe>
|
||||
<el-table-column prop="path" label="路径" min-width="160" />
|
||||
<el-table-column label="总量" width="100"><template #default="{row}">{{ formatQuotaBytes(row.total) }}</template></el-table-column>
|
||||
<el-table-column label="已用" width="100"><template #default="{row}">{{ formatQuotaBytes(row.used) }}</template></el-table-column>
|
||||
<el-table-column label="使用率" width="100"><template #default="{row}">{{ row.total ? ((row.used / row.total) * 100).toFixed(1) + '%' : '-' }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div class="quota-disk-section">
|
||||
<h4 class="quota-subtitle">磁盘使用详情</h4>
|
||||
<div class="quota-disk-list">
|
||||
<div class="quota-disk-item" v-for="disk in quotaStatsDisk" :key="disk.path">
|
||||
<div class="quota-disk-header">
|
||||
<code class="quota-disk-path">{{ disk.path }}</code>
|
||||
<span class="quota-disk-detail">{{ formatQuotaBytes(disk.used) }} / {{ formatQuotaBytes(disk.total) }}</span>
|
||||
<span class="quota-disk-pct" :style="{ color: quotaColor(disk.percent ?? 0) }">{{ (disk.percent ?? 0).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="Math.min(100, disk.percent ?? 0)" :show-text="false" :stroke-width="8" :color="quotaColor(disk.percent ?? 0)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<el-empty v-else-if="!quotaStatsLoading" description="暂无额度统计数据" :image-size="60" />
|
||||
@@ -637,6 +702,25 @@
|
||||
|
||||
<!-- 令牌用宿主机组选择器 -->
|
||||
<HostGroupSelectorPopup v-model="showTokenGroupSelector" :service-id="serviceId" :current-id="tokenForm.host_group_id" @confirm="handleTokenGroupSelected" />
|
||||
|
||||
<!-- KSM 调参弹窗 -->
|
||||
<el-dialog v-model="ksmTuneVisible" title="KSM 调参" width="480px" destroy-on-close>
|
||||
<el-form label-width="140px" style="padding: 8px 0">
|
||||
<el-form-item label="每次扫描页面数">
|
||||
<el-input-number v-model="ksmTuneForm.pages_to_scan" :min="0" :step="100" controls-position="right" style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="扫描间隔 (ms)">
|
||||
<el-input-number v-model="ksmTuneForm.sleep_millisecs" :min="0" :step="10" controls-position="right" style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="跨 NUMA 节点合并">
|
||||
<el-switch v-model="ksmTuneForm.merge_across_nodes" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="ksmTuneVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="ksmActionLoading" @click="submitKsmTune">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -631,6 +631,11 @@
|
||||
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
|
||||
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
|
||||
</div>
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">累计流量</div>
|
||||
<div class="metric-summary-value">{{ latestMetrics.traffic_used_mb != null ? latestMetrics.traffic_used_mb + ' MB' : '-' }}</div>
|
||||
<div class="metric-summary-sub"> </div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="historicalMetricsData">
|
||||
@@ -643,7 +648,7 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<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="memChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -662,13 +667,44 @@
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘 IOPS</span></template>
|
||||
<div ref="diskIopsChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 流量使用趋势</span></template>
|
||||
<div ref="trafficUsedChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 每小时流量 -->
|
||||
<el-tab-pane label="流量统计" name="trafficHourly">
|
||||
<!-- 流量管理(合并流量策略 + 流量统计) -->
|
||||
<el-tab-pane label="流量管理" name="trafficManage">
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">流量策略</h3>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button size="small" type="primary" @click="openVmTrafficPolicyDialog">修改流量策略</el-button>
|
||||
<el-button size="small" type="success" @click="openVmAddTrafficDialog('fixed')">增加固定流量</el-button>
|
||||
<el-button size="small" type="warning" @click="openVmAddTrafficDialog('temporary')">增加临时流量</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="loadVmTrafficPolicy" :loading="vmTrafficPolicyLoading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions v-if="vmTrafficPolicy" :column="3" border size="small" style="margin-top:12px">
|
||||
<el-descriptions-item label="流量上限">{{ vmTrafficPolicy.traffic_max_mb != null ? (vmTrafficPolicy.traffic_max_mb === 0 ? '不限' : vmTrafficPolicy.traffic_max_mb + ' MB') : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="耗尽下行限速">{{ vmTrafficPolicy.exhausted_rx_mbps != null ? (vmTrafficPolicy.exhausted_rx_mbps === 0 ? '不限' : vmTrafficPolicy.exhausted_rx_mbps + ' Mbps') : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="耗尽上行限速">{{ vmTrafficPolicy.exhausted_tx_mbps != null ? (vmTrafficPolicy.exhausted_tx_mbps === 0 ? '不限' : vmTrafficPolicy.exhausted_tx_mbps + ' Mbps') : '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-empty v-else-if="!vmTrafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
|
||||
</div>
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">每小时流量</h3>
|
||||
@@ -695,27 +731,6 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 流量策略 -->
|
||||
<el-tab-pane label="流量策略" name="vmTrafficPolicy">
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">流量策略</h3>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button size="small" type="primary" @click="openVmTrafficPolicyDialog">修改流量策略</el-button>
|
||||
<el-button size="small" type="success" @click="openVmAddTrafficDialog('fixed')">增加固定流量</el-button>
|
||||
<el-button size="small" type="warning" @click="openVmAddTrafficDialog('temporary')">增加临时流量</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="loadVmTrafficPolicy" :loading="vmTrafficPolicyLoading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions v-if="vmTrafficPolicy" :column="3" border size="small" style="margin-top:12px">
|
||||
<el-descriptions-item label="流量上限">{{ vmTrafficPolicy.traffic_max_mb != null ? (vmTrafficPolicy.traffic_max_mb === 0 ? '不限' : vmTrafficPolicy.traffic_max_mb + ' MB') : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="耗尽下行限速">{{ vmTrafficPolicy.exhausted_rx_mbps != null ? (vmTrafficPolicy.exhausted_rx_mbps === 0 ? '不限' : vmTrafficPolicy.exhausted_rx_mbps + ' Mbps') : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="耗尽上行限速">{{ vmTrafficPolicy.exhausted_tx_mbps != null ? (vmTrafficPolicy.exhausted_tx_mbps === 0 ? '不限' : vmTrafficPolicy.exhausted_tx_mbps + ' Mbps') : '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-empty v-else-if="!vmTrafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
@@ -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}<br/>${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}<br/>${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 += `<br/>${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}<br/>${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 字段
|
||||
|
||||
@@ -391,6 +391,7 @@
|
||||
<el-descriptions-item label="磁盘写入">{{ formatBytesRaw(vmMetricsData.disk_write) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网络接收">{{ formatNetSpeed(vmMetricsData.net_rx) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网络发送">{{ formatNetSpeed(vmMetricsData.net_tx) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="累计流量">{{ vmMetricsData.traffic_used_mb != null ? vmMetricsData.traffic_used_mb + ' MB' : '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user