fix: 提交修改
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 1m9s

This commit is contained in:
2026-04-15 16:02:36 +08:00
parent 2f06aa9f5f
commit b3ed406f84
61 changed files with 7476 additions and 7226 deletions
+477 -196
View File
@@ -17,6 +17,7 @@
<h2 class="instance-name">{{ detail.name || '-' }} <span class="instance-id">{{ detail.id }}</span></h2>
</div>
<div class="overview-actions">
<!-- <el-button type="warning" plain @click="openTokenDialog"><el-icon><Key /></el-icon>创建注册令牌</el-button> -->
<el-button type="primary" plain @click="handleEdit">编辑宿主机</el-button>
<el-button type="danger" plain @click="handleDelete">删除</el-button>
</div>
@@ -135,51 +136,69 @@
<el-tab-pane label="监控" name="monitor">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">实时指标</h3>
<h3 class="section-title">监控指标</h3>
<div style="display: flex; align-items: center; gap: 8px;">
<el-tag v-if="pollingActive" type="success" size="small" effect="plain">自动刷新中</el-tag>
<el-button size="small" :icon="Refresh" @click="loadMetrics" :loading="metricsLoading">刷新指标</el-button>
<el-select v-model="historyTimeRange" size="small" style="width: 120px" @change="loadHistoricalMetrics">
<el-option v-for="option in historyTimeOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-button size="small" :icon="Refresh" @click="loadHistoricalMetrics" :loading="historicalMetricsLoading">刷新</el-button>
</div>
</div>
<template v-if="metricsData">
<template v-if="latestMetrics">
<div class="metric-summary-row">
<div class="metric-summary-card">
<div class="metric-summary-label">CPU 使用率</div>
<div class="metric-summary-value">{{ latestMetrics.cpu_usage?.toFixed(1) }}%</div>
<div class="metric-summary-sub">{{ latestMetrics.cpu_count }} </div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">内存使用率</div>
<div class="metric-summary-value">{{ latestMetrics.mem_percent?.toFixed(1) }}%</div>
<div class="metric-summary-sub">{{ formatBytesRaw(latestMetrics.mem_used) }} / {{ formatBytesRaw(latestMetrics.mem_total) }}</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">公网流量</div>
<div class="metric-summary-value">{{ formatNetLabel(latestMetrics.inet_rx) }}</div>
<div class="metric-summary-sub">{{ formatNetLabel(latestMetrics.inet_tx) }}</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">内网流量</div>
<div class="metric-summary-value">{{ formatBytesRaw(latestMetrics.net_rx) }}</div>
<div class="metric-summary-sub">{{ formatBytesRaw(latestMetrics.net_tx) }}</div>
</div>
</div>
</template>
<template v-if="historicalMetricsData">
<el-row :gutter="16">
<el-col :span="12" v-if="metricsData.cpu">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU 使用率 {{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}% ({{ metricsData.cpu.cpu_count ?? '-' }})</span></template>
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU 使用率</span></template>
<div ref="cpuChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12" v-if="metricsData.memory">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存 {{ formatBytesRaw(metricsData.memory.used) }} / {{ formatBytesRaw(metricsData.memory.total) }} ({{ metricsData.memory.percent ?? 0 }}%)</span></template>
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存使用率</span></template>
<div ref="memChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12" v-if="metricsData.disk">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Box /></el-icon> 磁盘</span></template>
<div v-for="(info, path) in metricsData.disk" :key="path" class="disk-item">
<div class="disk-path">{{ path }}</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="总计">{{ formatBytesRaw(info.total) }}</el-descriptions-item>
<el-descriptions-item label="已用">{{ formatBytesRaw(info.used) }}</el-descriptions-item>
<el-descriptions-item label="空闲">{{ formatBytesRaw(info.free) }}</el-descriptions-item>
<el-descriptions-item label="使用率">{{ info.percent ?? '-' }}%</el-descriptions-item>
</el-descriptions>
</div>
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 公网流量</span></template>
<div ref="inetChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12" v-if="metricsData.network || metricsData.internet_speed">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 内网流量</span></template>
<div ref="netChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
</template>
<el-empty v-else description="加载指标数据中..." />
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
</div>
</el-tab-pane>
@@ -287,45 +306,55 @@
</el-dialog>
<!-- 创建组网弹窗 -->
<el-dialog v-model="nwCreateVisible" title="创建组网" width="480px" destroy-on-close>
<el-dialog v-model="nwCreateVisible" title="创建组网" width="480px" destroy-on-close class="tk-dialog">
<el-form ref="nwCreateFormRef" :model="nwCreateForm" :rules="nwCreateRules" label-width="100px">
<el-form-item label="用户" prop="user_id">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="nwCreateForm.user_id ? `${nwCreateUserName} (ID: ${nwCreateForm.user_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showNwUserSelector = true">选择</el-button>
<el-button v-if="nwCreateForm.user_id" @click="nwCreateForm.user_id = 0; nwCreateUserName = ''">清除</el-button>
</div>
</el-form-item>
<el-form-item label="网桥名称">
<el-input v-model="nwCreateForm.bridge_name" placeholder="可选" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="nwCreateForm.gateway" placeholder="可选 10.0.0.1" />
</el-form-item>
<div class="tk-section">
<div class="tk-section-title">组网信息</div>
<el-form-item label="用户" prop="user_id">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="nwCreateForm.user_id ? `${nwCreateUserName} (ID: ${nwCreateForm.user_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showNwUserSelector = true">选择</el-button>
<el-button v-if="nwCreateForm.user_id" @click="nwCreateForm.user_id = 0; nwCreateUserName = ''">清除</el-button>
</div>
</el-form-item>
<el-form-item label="网桥名称">
<el-input v-model="nwCreateForm.bridge_name" placeholder="可选" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="nwCreateForm.gateway" placeholder="可选 10.0.0.1" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="nwCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwCreate">创建</el-button>
<div class="tk-dialog-footer">
<el-button @click="nwCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwCreate">创建</el-button>
</div>
</template>
</el-dialog>
<!-- 分配IP弹窗 -->
<el-dialog v-model="nwAssignVisible" title="为虚拟机分配组网IP" width="480px" destroy-on-close>
<el-dialog v-model="nwAssignVisible" title="为虚拟机分配组网IP" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px">
<el-form-item label="组网">{{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }})</el-form-item>
<el-form-item label="虚拟机" required>
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="nwAssignVmId ? `${nwAssignVmName} (ID: ${nwAssignVmId})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showNwVmSelector = true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="指定IP">
<el-input v-model="nwAssignIp" placeholder="留空自动分配" />
</el-form-item>
<div class="tk-section">
<div class="tk-section-title">分配信息</div>
<el-form-item label="组网">{{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }})</el-form-item>
<el-form-item label="虚拟机" required>
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="nwAssignVmId ? `${nwAssignVmName} (ID: ${nwAssignVmId})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showNwVmSelector = true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="指定IP">
<el-input v-model="nwAssignIp" placeholder="留空自动分配" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="nwAssignVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwAssign" :disabled="!nwAssignVmId">分配</el-button>
<div class="tk-dialog-footer">
<el-button @click="nwAssignVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwAssign" :disabled="!nwAssignVmId">分配</el-button>
</div>
</template>
</el-dialog>
@@ -334,61 +363,163 @@
</div>
<!-- 编辑弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑宿主机" width="890px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item>
<el-form-item label="服务地址" prop="base_url"><el-input v-model="formData.base_url" /></el-form-item>
<el-form-item label="IP 地址" prop="ip"><el-input v-model="formData.ip" /></el-form-item>
<el-form-item label="认证Token"><el-input v-model="formData.token" show-password /></el-form-item>
<el-divider content-position="left">SSH 配置</el-divider>
<el-form-item label="SSH 端口"><el-input-number v-model="formData.port" :min="0" :max="65535" style="width: 100%" /></el-form-item>
<el-form-item label="SSH 用户名"><el-input v-model="formData.user" /></el-form-item>
<el-form-item label="SSH 密码"><el-input v-model="formData.password" show-password /></el-form-item>
<el-form-item label="私钥"><el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容" /></el-form-item>
<el-divider content-position="left">资源限制</el-divider>
<el-form-item label="最大CPU"><el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" style="width: 100%" /></el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="最大内存">
<div class="unit-input-row">
<el-select v-model="memoryUnit" style="width: 70px; flex-shrink: 0;" size="default">
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="wide-number" />
</div>
<el-dialog v-model="editDialogVisible" title="编辑宿主机" width="890px" destroy-on-close class="tk-dialog">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item>
<el-form-item label="服务地址" prop="base_url"><el-input v-model="formData.base_url" /></el-form-item>
<el-form-item label="IP 地址" prop="ip"><el-input v-model="formData.ip" /></el-form-item>
<el-form-item label="认证Token"><el-input v-model="formData.token" show-password /></el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">SSH 配置</div>
<el-form-item label="端口"><el-input-number v-model="formData.port" :min="0" :max="65535" controls-position="right" style="width: 100%" /></el-form-item>
<el-form-item label="用户名"><el-input v-model="formData.user" /></el-form-item>
<el-form-item label="密码"><el-input v-model="formData.password" show-password /></el-form-item>
<el-form-item label="私钥"><el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容" /></el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">资源限制</div>
<div class="tk-resource-grid">
<el-form-item label="CPU"><el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span></el-form-item>
<el-form-item label="内存">
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" />
<el-select v-model="memoryUnit" class="tk-unit-select">
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大磁盘">
<div class="unit-input-row">
<el-select v-model="diskUnit" style="width: 70px; flex-shrink: 0;" size="default">
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" class="wide-number" />
</div>
<el-form-item label="磁盘">
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
<el-select v-model="diskUnit" class="tk-unit-select">
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="下行带宽(Mbps)"><el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="上行带宽(Mbps)"><el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
</el-row>
<el-form-item label="宿主机组">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showGroupSelector = true">选择</el-button>
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0">清除</el-button>
<el-form-item label="下行带宽"><el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span></el-form-item>
<el-form-item label="上行带宽"><el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span></el-form-item>
</div>
</el-form-item>
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">其他配置</div>
<el-form-item label="宿主机组">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showGroupSelector = true">选择</el-button>
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0">清除</el-button>
</div>
</el-form-item>
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
<div class="tk-dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</template>
</el-dialog>
<HostGroupSelectorPopup v-model="showGroupSelector" :service-id="serviceId" :current-id="formData.host_group_id" @confirm="g => formData.host_group_id = g.id" />
<!-- 创建注册令牌弹窗 -->
<el-dialog v-model="tokenDialogVisible" title="创建宿主机注册令牌" width="700px" destroy-on-close class="token-dialog">
<el-form ref="tokenFormRef" :model="tokenForm" :rules="tokenRules" label-width="120px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="宿主机名称" prop="name">
<el-input v-model="tokenForm.name" placeholder="为该宿主机命名" />
</el-form-item>
<el-form-item label="所属宿主机组" prop="host_group_id">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="tokenForm.host_group_id ? `宿主机组 #${tokenForm.host_group_id}` : ''" placeholder="请选择宿主机组" disabled style="flex: 1" />
<el-button type="primary" @click="showTokenGroupSelector = true">选择</el-button>
<el-button v-if="tokenForm.host_group_id" @click="tokenForm.host_group_id = 0">清除</el-button>
</div>
</el-form-item>
<el-form-item label="宿主机描述">
<el-input v-model="tokenForm.description" type="textarea" :rows="2" placeholder="宿主机描述可选" />
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">资源配额</div>
<div class="tk-resource-grid">
<el-form-item label="CPU" prop="max_cpu" class="tk-res-item">
<el-input-number v-model="tokenForm.max_cpu" :min="1" controls-position="right" /><span class="tk-res-unit">核</span>
</el-form-item>
<el-form-item label="内存" prop="max_memory" class="tk-res-item">
<el-input-number v-model="tokenMemDisplay" :min="0" controls-position="right" />
<el-select v-model="tokenMemUnit" class="tk-unit-select">
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</el-form-item>
<el-form-item label="磁盘" prop="max_disk" class="tk-res-item">
<el-input-number v-model="tokenDiskDisplay" :min="0" controls-position="right" />
<el-select v-model="tokenDiskUnit" class="tk-unit-select">
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</el-form-item>
<el-form-item label="下行带宽" class="tk-res-item">
<el-input-number v-model="tokenForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
<el-form-item label="上行带宽" class="tk-res-item">
<el-input-number v-model="tokenForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title">令牌有效期</div>
<el-form-item label="有效期" prop="expire_hours">
<el-input-number v-model="tokenForm.expire_hours" :min="1" :max="8760" controls-position="right" style="width: 100%" />
<div class="form-hint">单位:小时。默认 24 小时,最大 8760 小时(365天)</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="tokenDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="tokenSubmitLoading" @click="handleTokenSubmit">
<el-icon><Key /></el-icon>创建令牌
</el-button>
</div>
</template>
</el-dialog>
<!-- 令牌结果弹窗 -->
<el-dialog v-model="tokenResultVisible" title="注册令牌已生成" width="560px" :close-on-click-modal="false" class="token-result-dialog">
<div class="tk-result-wrapper">
<div class="tk-result-header">
<el-icon class="tk-result-icon"><Key /></el-icon>
<div>
<div class="tk-result-name">{{ tokenResultInfo.name }}</div>
<div class="tk-result-meta">有效期 {{ tokenResultInfo.expire_hours }} 小时</div>
</div>
</div>
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">
<template #title>请立即复制并保存此令牌,关闭后将无法再次查看</template>
</el-alert>
<div class="tk-token-block">
<div class="tk-token-label">后端地址</div>
<div class="tk-token-value">{{ baseUrl }}</div>
</div>
<div class="tk-token-block">
<div class="tk-token-label">service_id(主控服务ID</div>
<div class="tk-token-value">{{ tokenResultInfo.service_id }}</div>
</div>
<div class="tk-token-block">
<div class="tk-token-label">注册令牌</div>
<div class="tk-token-value">{{ tokenResultInfo.token }}</div>
</div>
<el-button type="primary" class="tk-copy-btn" @click="copyToken">
<el-icon><CopyDocument /></el-icon>复制令牌到剪贴板
</el-button>
</div>
<template #footer>
<el-button @click="tokenResultVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 令牌用宿主机组选择器 -->
<HostGroupSelectorPopup v-model="showTokenGroupSelector" :service-id="serviceId" :current-id="tokenForm.host_group_id" @confirm="handleTokenGroupSelected" />
</div>
</template>
@@ -396,13 +527,15 @@
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, Refresh, Edit, Delete, Monitor, Coin, Box, Connection, Search, Plus } from '@element-plus/icons-vue'
import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue'
import {
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost,
getRemoteHostDetail, updateRemoteHost, deleteRemoteHost,
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
assignUserNetworking, removeUserNetworkingNetwork
assignUserNetworking, removeUserNetworkingNetwork,
createHostToken, getMetricsHistory
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { baseUrl } from '@/config/env'
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
import ImageManage from '@/views/virtualization/ImageManage.vue'
import NetworkManage from '@/views/virtualization/NetworkManage.vue'
@@ -442,14 +575,16 @@ watch(activeTab, (tab) => {
nextTick(() => { tabRefMap[tab]?.value?.loadList?.() })
}
}
if (tab === 'monitor' && detail.value) { loadMetrics(); startPolling() }
else stopPolling()
if (tab === 'monitor' && detail.value) {
if (!historicalMetricsData.value) {
loadHistoricalMetrics()
}
}
if (tab === 'networking') loadNetworkingList()
})
const loading = ref(false)
const submitLoading = ref(false)
const metricsLoading = ref(false)
const detail = ref(null)
provide('embedded', true)
@@ -480,7 +615,8 @@ const fallbackCopy = (text) => {
} catch { ElMessage.error('复制失败') }
document.body.removeChild(ta)
}
const metricsData = ref(null)
const historicalMetricsData = ref(null)
const historicalMetricsLoading = ref(false)
const editDialogVisible = ref(false)
const showGroupSelector = ref(false)
const formRef = ref(null)
@@ -561,65 +697,77 @@ const loadDetail = async () => {
const cpuChartRef = ref(null)
const memChartRef = ref(null)
const netChartRef = ref(null)
const inetChartRef = ref(null)
let cpuChart = null
let memChart = null
let netChart = null
const MAX_HISTORY = 60
const metricsHistory = reactive({
times: [],
cpu: [],
memPercent: [],
netRx: [],
netTx: []
})
const pollingActive = ref(false)
let pollTimer = null
let inetChart = null
let isPageActive = false
const loadMetrics = async () => {
if (!serviceId.value || !hostId.value || !isPageActive) return
metricsLoading.value = true
const latestMetrics = computed(() => {
const arr = historicalMetricsData.value
if (!Array.isArray(arr) || !arr.length) return null
return arr[arr.length - 1]
})
// 历史指标时间范围
const historyTimeRange = ref('1m') // 1m, 5m, 1h, 1d
const historyTimeOptions = [
{ label: '最近1分钟', value: '1m' },
{ label: '最近5分钟', value: '5m' },
{ label: '最近1小时', value: '1h' },
{ label: '最近1天', value: '1d' },
]
// 加载历史指标数据
const loadHistoricalMetrics = async () => {
if (!serviceId.value || !hostId.value) return
historicalMetricsLoading.value = true
try {
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: hostId.value })
// 计算时间范围
const now = new Date()
let startTime = new Date()
switch (historyTimeRange.value) {
case '1m':
startTime.setMinutes(now.getMinutes() - 1)
break
case '5m':
startTime.setMinutes(now.getMinutes() - 5)
break
case '1h':
startTime.setHours(now.getHours() - 1)
break
case '1d':
startTime.setDate(now.getDate() - 1)
break
}
const params = {
service_id: serviceId.value,
host_id: hostId.value,
start: startTime.toISOString(),
end_time: now.toISOString(),
interval: { '1m': '1m', '5m': '5m', '1h': '1h', '1d': '1d' }[historyTimeRange.value] || '5m'
}
const res = await getMetricsHistory(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
metricsData.value = body.data.data ?? body.data
pushHistory(metricsData.value)
historicalMetricsData.value = Array.isArray(body.data) ? body.data : (body.data.data || [])
await nextTick()
renderCharts()
renderHistoricalCharts()
} else {
ElMessage.error(extractApiError(body, '加载历史指标失败'))
}
} catch { /* silent for polling */ } finally { metricsLoading.value = false }
}
const pushHistory = (d) => {
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false })
metricsHistory.times.push(now)
metricsHistory.cpu.push(d.cpu?.cpu_usage_percent ?? 0)
metricsHistory.memPercent.push(d.memory?.percent ?? 0)
metricsHistory.netRx.push(d.internet_speed?.rx_bytes ?? 0)
metricsHistory.netTx.push(d.internet_speed?.tx_bytes ?? 0)
if (metricsHistory.times.length > MAX_HISTORY) {
metricsHistory.times.shift()
metricsHistory.cpu.shift()
metricsHistory.memPercent.shift()
metricsHistory.netRx.shift()
metricsHistory.netTx.shift()
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '加载历史指标失败'))
} finally {
historicalMetricsLoading.value = false
}
}
const makeLineOption = (title, seriesData, color, yFormatter) => ({
tooltip: { trigger: 'axis', formatter: (params) => {
const p = params[0]
return `${p.axisValue}<br/>${p.marker} ${p.seriesName}: ${yFormatter ? yFormatter(p.value) : p.value}`
}},
grid: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: metricsHistory.times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: yFormatter || (v => v) } },
series: Array.isArray(seriesData)
? seriesData.map(s => ({ name: s.name, type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2 }, data: s.data, itemStyle: { color: s.color } }))
: [{ name: title, type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color }, itemStyle: { color }, data: seriesData }]
})
const formatNetLabel = (v) => {
if (!v) return '0 B/s'
@@ -629,33 +777,78 @@ const formatNetLabel = (v) => {
return v + ' B/s'
}
const renderCharts = () => {
const times = [...metricsHistory.times]
const cpuData = [...metricsHistory.cpu]
const memData = [...metricsHistory.memPercent]
const rxData = [...metricsHistory.netRx]
const txData = [...metricsHistory.netTx]
// 渲染历史指标图表
const renderHistoricalCharts = () => {
const metrics = historicalMetricsData.value
if (!Array.isArray(metrics) || !metrics.length) return
const range = historyTimeRange.value
const showDate = range === '7d' || range === '24h'
const symbolType = range === '7d' ? 'circle' : 'none'
const labelRotate = showDate ? 45 : 0
const times = metrics.map(m => {
const date = new Date(m.bucket)
if (range === '7d') 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' })
})
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
const memData = metrics.map(m => m.mem_percent ?? 0)
const inetRxData = metrics.map(m => m.inet_rx ?? 0)
const inetTxData = metrics.map(m => m.inet_tx ?? 0)
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))
netTxRate.push(Math.max(0, ((metrics[i].net_tx ?? 0) - (metrics[i - 1].net_tx ?? 0)) / dt))
} else {
netRxRate.push(0); netTxRate.push(0)
}
}
const baseGrid = { top: 10, right: 16, bottom: 24, left: 50 }
const makeXAxis = () => ({ type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10, rotate: labelRotate } })
const makeSeries = (name, data, color) => ({ name, type: 'line', smooth: true, symbol: symbolType, areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color }, itemStyle: { color }, data })
if (cpuChartRef.value) {
if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value)
cpuChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} CPU: ${p[0].value.toFixed(1)}%` },
grid: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
series: [{ name: 'CPU', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#409eff' }, itemStyle: { color: '#409eff' }, data: cpuData }]
series: [makeSeries('CPU', cpuData, '#409eff')]
}, true)
}
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: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
series: [{ name: '内存', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#67c23a' }, itemStyle: { color: '#67c23a' }, data: memData }]
series: [makeSeries('内存', memData, '#67c23a')]
}, true)
}
if (inetChartRef.value) {
if (!inetChart) inetChart = echarts.init(inetChartRef.value)
inetChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => {
let s = params[0].axisValue
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
return s
}},
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: formatNetLabel } },
series: [makeSeries('接收', inetRxData, '#409eff'), makeSeries('发送', inetTxData, '#e6a23c')]
}, true)
}
if (netChartRef.value) {
if (!netChart) netChart = echarts.init(netChartRef.value)
netChart.setOption({
@@ -664,33 +857,18 @@ const renderCharts = () => {
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
return s
}},
grid: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: formatNetLabel } },
series: [
{ name: '接收', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#409eff' }, itemStyle: { color: '#409eff' }, data: rxData },
{ name: '发送', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#e6a23c' }, itemStyle: { color: '#e6a23c' }, data: txData }
]
series: [makeSeries('接收', netRxRate, '#409eff'), makeSeries('发送', netTxRate, '#e6a23c')]
}, true)
}
}
const startPolling = () => {
if (!serviceId.value || !hostId.value || !isPageActive) return
stopPolling()
pollingActive.value = true
pollTimer = setInterval(() => { loadMetrics() }, 3000)
}
const stopPolling = () => {
pollingActive.value = false
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
const disposeCharts = () => {
cpuChart?.dispose(); cpuChart = null
memChart?.dispose(); memChart = null
netChart?.dispose(); netChart = null
inetChart?.dispose(); inetChart = null
}
const handleEdit = () => {
@@ -736,6 +914,115 @@ const handleDelete = () => {
}).catch(() => {})
}
// ========== 创建注册令牌 ==========
const tokenDialogVisible = ref(false)
const tokenSubmitLoading = ref(false)
const tokenResultVisible = ref(false)
const showTokenGroupSelector = ref(false)
const tokenFormRef = ref(null)
const tokenMemUnit = ref('GB')
const tokenDiskUnit = ref('GB')
const tokenForm = reactive({
name: '', host_group_id: 0, max_cpu: 4,
max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24
})
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
const tokenRules = {
name: [{ required: true, message: '请输入宿主机名称', trigger: 'blur' }],
host_group_id: [{ required: true, type: 'number', min: 1, message: '请选择宿主机组', trigger: 'change' }],
max_cpu: [{ required: true, type: 'number', min: 1, message: '请设置最大CPU核数', trigger: 'change' }],
max_memory: [{ required: true, type: 'number', min: 1, message: '请设置最大内存', trigger: 'change' }],
max_disk: [{ required: true, type: 'number', min: 1, message: '请设置最大磁盘', trigger: 'change' }],
expire_hours: [{ required: true, type: 'number', min: 1, message: '请设置有效期', trigger: 'change' }]
}
const getTokenMemFactor = () => memoryUnitOptions.find(u => u.label === tokenMemUnit.value)?.factor || 1048576
const getTokenDiskFactor = () => diskUnitOptions.find(u => u.label === tokenDiskUnit.value)?.factor || 1
const tokenMemDisplay = computed({
get: () => tokenForm.max_memory ? +(tokenForm.max_memory / getTokenMemFactor()).toFixed(2) : 0,
set: (v) => { tokenForm.max_memory = Math.round((v || 0) * getTokenMemFactor()) }
})
const tokenDiskDisplay = computed({
get: () => tokenForm.max_disk ? +(tokenForm.max_disk / getTokenDiskFactor()).toFixed(2) : 0,
set: (v) => { tokenForm.max_disk = Math.round((v || 0) * getTokenDiskFactor()) }
})
const openTokenDialog = () => {
const d = detail.value
Object.assign(tokenForm, {
name: '', host_group_id: d?.host_group_id || 0,
max_cpu: d?.max_cpu || 4,
max_memory: d?.max_memory || 4194304,
max_disk: d?.max_disk || 100,
rx_bandwidth: d?.rx_bandwidth || 100,
tx_bandwidth: d?.tx_bandwidth || 100,
description: '', expire_hours: 24
})
tokenMemUnit.value = 'GB'
tokenDiskUnit.value = 'GB'
tokenDialogVisible.value = true
}
const handleTokenGroupSelected = (group) => {
tokenForm.host_group_id = group.id
}
const handleTokenSubmit = () => {
tokenFormRef.value?.validate(async (valid) => {
if (!valid) return
tokenSubmitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('name', tokenForm.name)
fd.append('host_group_id', tokenForm.host_group_id)
fd.append('max_cpu', tokenForm.max_cpu)
fd.append('max_memory', tokenForm.max_memory)
fd.append('max_disk', tokenForm.max_disk)
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
fd.append('description', tokenForm.description || '')
fd.append('expire_hours', tokenForm.expire_hours)
const res = await createHostToken(fd)
const body = res?.data
if (body?.code === 200 && body?.data) {
tokenResultInfo.name = tokenForm.name
tokenResultInfo.expire_hours = tokenForm.expire_hours
tokenResultInfo.token = body.data.token || body.data.Token || JSON.stringify(body.data)
tokenResultInfo.service_id = serviceId.value
tokenDialogVisible.value = false
tokenResultVisible.value = true
ElMessage.success('注册令牌创建成功')
} else {
ElMessage.error(extractApiError(body, '创建令牌失败'))
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '创建令牌失败'))
} finally {
tokenSubmitLoading.value = false
}
})
}
const copyToken = async () => {
const text = `后端地址:${baseUrl}\nservice_id${tokenResultInfo.service_id}\n注册令牌:${tokenResultInfo.token}`
try {
await navigator.clipboard.writeText(text)
ElMessage.success('令牌信息已复制到剪贴板')
} catch {
const ta = document.createElement('textarea')
ta.value = text
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
ElMessage.success('令牌信息已复制到剪贴板')
}
}
const goBack = () => {
tagsViewStore.delVisitedView(route)
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
@@ -916,26 +1203,20 @@ const initPage = () => {
showToken.value = false
showPassword.value = false
showPrivateKey.value = false
metricsData.value = null
metricsHistory.times.length = 0
metricsHistory.cpu.length = 0
metricsHistory.memPercent.length = 0
metricsHistory.netRx.length = 0
metricsHistory.netTx.length = 0
historicalMetricsData.value = null
disposeCharts()
loadDetail()
if (activeTab.value === 'monitor') loadMetrics().then(() => startPolling())
if (activeTab.value === 'monitor') loadHistoricalMetrics()
}
watch(hostId, () => { if (isPageActive) initPage() })
onActivated(() => {
isPageActive = true
if (loadedHostId !== hostId.value) initPage()
else if (activeTab.value === 'monitor') startPolling()
})
onMounted(() => { isPageActive = true; initPage() })
onDeactivated(() => { isPageActive = false; stopPolling() })
onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() })
onDeactivated(() => { isPageActive = false })
onBeforeUnmount(() => { isPageActive = false; disposeCharts() })
</script>
<style scoped>
@@ -981,16 +1262,16 @@ onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() })
.secret-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.secret-cell code { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mono-text { font-family: 'Consolas', 'Monaco', monospace; }
.text-muted { color: #c0c4cc; }
.metrics-card { margin-bottom: 0; }
.metrics-title { font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
.chart-container { width: 100%; height: 220px; }
.disk-item { margin-bottom: 8px; }
.disk-path { font-weight: 500; color: #409eff; font-size: 13px; margin-bottom: 4px; font-family: 'Consolas', monospace; }
.unit-input-row { display: flex; gap: 6px; width: 100%; }
.wide-number { flex: 1; min-width: 140px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.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; }
.metric-summary-value { font-size: 22px; font-weight: 600; color: #1d2129; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.metric-summary-sub { font-size: 12px; color: #86909c; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>