feat: extend SMS signature and setting file support #28

Merged
shiran merged 7 commits from master into deploy 2026-07-24 22:07:30 +08:00
2 changed files with 278 additions and 2 deletions
Showing only changes of commit 816771f151 - Show all commits
+5
View File
@@ -145,6 +145,11 @@ export const getRemoteHostMetrics = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/metrics', { params })
}
/** 获取宿主机内虚拟机指标 Top 榜单 */
export const getHostMetricsTop = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/metrics/top', { params })
}
/** 查询历史指标(宿主机或虚拟机) */
export const getMetricsHistory = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/metrics_history', { params })
+273 -2
View File
@@ -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>