feat: 对接虚拟化平台管理
This commit is contained in:
@@ -1,131 +1,186 @@
|
||||
<template>
|
||||
<div class="host-detail-page">
|
||||
<!-- 顶部返回栏 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回宿主机列表</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">宿主机详情</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" plain :icon="Edit" @click="handleEdit">编辑</el-button>
|
||||
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" @click="handleDelete">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content" v-loading="loading">
|
||||
<!-- 基本信息卡片 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">基本信息</span></template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP">{{ detail.ip || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务地址">{{ detail.base_url || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="SSH 端口">{{ detail.port || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="SSH 用户">{{ detail.user || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="认证Token" :span="2">
|
||||
<el-input v-if="detail.token" :model-value="detail.token" readonly show-password style="max-width: 300px" />
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="SSH 密码">
|
||||
<el-input v-if="detail.password" :model-value="detail.password" readonly show-password style="max-width: 200px" />
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="私钥路径">{{ detail.private_key_path || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机组">{{ detail.host_group_id ? `#${detail.host_group_id}` : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="detail.is_active ? 'success' : 'danger'" size="small">{{ detail.is_active ? '启用' : '禁用' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="介绍" :span="2">{{ detail.description || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 资源限制 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">资源限制</span></template>
|
||||
<div class="resource-cards">
|
||||
<div class="res-item"><div class="res-label">最大CPU</div><div class="res-value">{{ detail.max_cpu ? detail.max_cpu + ' 核' : '-' }}</div></div>
|
||||
<div class="res-item"><div class="res-label">最大内存</div><div class="res-value">{{ formatMemMB(detail.max_memory) }}</div></div>
|
||||
<div class="res-item"><div class="res-label">最大磁盘</div><div class="res-value">{{ formatDiskGB(detail.max_disk) }}</div></div>
|
||||
<div class="res-item"><div class="res-label">下行带宽</div><div class="res-value">{{ detail.rx_bandwidth || 0 }} Mbps</div></div>
|
||||
<div class="res-item"><div class="res-label">上行带宽</div><div class="res-value">{{ detail.tx_bandwidth || 0 }} Mbps</div></div>
|
||||
<!-- 实例概览栏 -->
|
||||
<div class="instance-overview" v-if="detail">
|
||||
<div class="overview-left">
|
||||
<h2 class="instance-name">{{ detail.name || '-' }} <span class="instance-id">{{ detail.id }}</span></h2>
|
||||
</div>
|
||||
</el-card>
|
||||
<div class="overview-actions">
|
||||
<el-button type="primary" plain @click="handleEdit">编辑宿主机</el-button>
|
||||
<el-button type="danger" plain @click="handleDelete">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时指标 -->
|
||||
<el-card shadow="never" class="info-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span class="card-title">实时指标</span>
|
||||
<el-button size="small" :icon="Refresh" @click="loadMetrics" :loading="metricsLoading">刷新指标</el-button>
|
||||
<!-- 状态概览条 -->
|
||||
<div class="status-bar" v-if="detail">
|
||||
<div class="status-item">
|
||||
<span class="status-label">状态</span>
|
||||
<span class="status-value">
|
||||
<span class="status-dot" :class="detail.is_active ? 'dot-running' : 'dot-other'"></span>
|
||||
{{ detail.is_active ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">IP 地址</span>
|
||||
<span class="status-value">{{ detail.ip || '-' }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">资源</span>
|
||||
<span class="status-value">{{ detail.max_cpu || 0 }}核 | {{ formatMemKB(detail.max_memory) }} | {{ formatDiskGB(detail.max_disk) }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">带宽</span>
|
||||
<span class="status-value">↓{{ detail.rx_bandwidth || 0 }} / ↑{{ detail.tx_bandwidth || 0 }} Mbps</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">创建时间</span>
|
||||
<span class="status-value">{{ formatTimestamp(detail.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<el-tabs v-model="activeTab" class="detail-tabs" v-if="detail">
|
||||
<el-tab-pane label="基本信息" name="info">
|
||||
<div class="section-block">
|
||||
<h3 class="section-title">配置信息</h3>
|
||||
<div class="config-grid">
|
||||
<div class="config-row">
|
||||
<div class="config-cell">
|
||||
<span class="config-label">服务地址</span>
|
||||
<span class="config-value mono-text">{{ detail.base_url || '-' }}</span>
|
||||
</div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">SSH 端口</span>
|
||||
<span class="config-value">{{ detail.port || '-' }}</span>
|
||||
</div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">SSH 用户</span>
|
||||
<span class="config-value">{{ detail.user || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-cell">
|
||||
<span class="config-label">认证Token</span>
|
||||
<span class="config-value">
|
||||
<template v-if="detail.token">
|
||||
<code>{{ showToken ? detail.token : '••••••••••••' }}</code>
|
||||
<el-button link type="primary" size="small" @click="showToken = !showToken">{{ showToken ? '隐藏' : '显示' }}</el-button>
|
||||
</template>
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">SSH 密码</span>
|
||||
<span class="config-value">
|
||||
<template v-if="detail.password">
|
||||
<code>{{ showPassword ? detail.password : '••••••••' }}</code>
|
||||
<el-button link type="primary" size="small" @click="showPassword = !showPassword">{{ showPassword ? '隐藏' : '显示' }}</el-button>
|
||||
</template>
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">私钥路径</span>
|
||||
<span class="config-value mono-text">{{ detail.private_key_path || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-cell">
|
||||
<span class="config-label">宿主机组</span>
|
||||
<span class="config-value">{{ detail.host_group_id ? `#${detail.host_group_id}` : '-' }}</span>
|
||||
</div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">介绍</span>
|
||||
<span class="config-value">{{ detail.description || '-' }}</span>
|
||||
</div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">更新时间</span>
|
||||
<span class="config-value">{{ formatTimestamp(detail.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="metricsLoading">
|
||||
<template v-if="metricsData">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12" v-if="metricsData.cpu">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="使用率">{{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="核心数">{{ metricsData.cpu.cpu_count ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="metricsData.memory">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.memory.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.memory.used) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(metricsData.memory.free) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ metricsData.memory.percent ?? '-' }}%</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12" v-if="metricsData.disk">
|
||||
<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>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="metricsData.network || metricsData.internet_speed">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<template v-if="metricsData.network">
|
||||
<el-descriptions-item label="接收">{{ formatBytesRaw(metricsData.network.rx_bytes) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发送">{{ formatBytesRaw(metricsData.network.tx_bytes) }}</el-descriptions-item>
|
||||
</template>
|
||||
<template v-if="metricsData.internet_speed">
|
||||
<el-descriptions-item label="实时接收">{{ formatBytesRaw(metricsData.internet_speed.rx_bytes) }}/s</el-descriptions-item>
|
||||
<el-descriptions-item label="实时发送">{{ formatBytesRaw(metricsData.internet_speed.tx_bytes) }}/s</el-descriptions-item>
|
||||
</template>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
<el-empty v-else description="暂无指标数据,点击刷新加载" />
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="监控" name="monitor">
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="metricsData">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12" v-if="metricsData.cpu">
|
||||
<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>
|
||||
<div ref="cpuChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="metricsData.memory">
|
||||
<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>
|
||||
<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-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>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="metricsData.network || metricsData.internet_speed">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<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="加载指标数据中..." />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="镜像管理" name="image">
|
||||
<ImageManage v-if="hostTabLoaded['image']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="网络管理" name="network">
|
||||
<NetworkManage v-if="hostTabLoaded['network']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="数据卷管理" name="volume">
|
||||
<VolumeManage v-if="hostTabLoaded['volume']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="虚拟机管理" name="vm">
|
||||
<VmManage v-if="hostTabLoaded['vm']" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑宿主机" width="640px" destroy-on-close>
|
||||
<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>
|
||||
@@ -139,8 +194,26 @@
|
||||
<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="最大内存(MB)"><el-input-number v-model="formData.max_memory" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="最大磁盘(GB)"><el-input-number v-model="formData.max_disk" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
|
||||
<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-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>
|
||||
</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>
|
||||
@@ -166,15 +239,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
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 } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||||
import ImageManage from '@/views/virtualization/ImageManage.vue'
|
||||
import NetworkManage from '@/views/virtualization/NetworkManage.vue'
|
||||
import VolumeManage from '@/views/virtualization/VolumeManage.vue'
|
||||
import VmManage from '@/views/virtualization/VmManage.vue'
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -184,10 +263,26 @@ const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => route.query.service_name || '')
|
||||
const hostId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const activeTab = ref('info')
|
||||
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false })
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (!['info', 'monitor'].includes(tab) && !hostTabLoaded[tab]) hostTabLoaded[tab] = true
|
||||
if (tab === 'monitor' && detail.value) { loadMetrics(); startPolling() }
|
||||
else stopPolling()
|
||||
})
|
||||
|
||||
provide('embedded', true)
|
||||
provide('serviceId', serviceId)
|
||||
provide('serviceName', serviceName)
|
||||
provide('hostId', hostId)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const metricsLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const showToken = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const metricsData = ref(null)
|
||||
const editDialogVisible = ref(false)
|
||||
const showGroupSelector = ref(false)
|
||||
@@ -209,8 +304,42 @@ const formatTimestamp = (ts) => {
|
||||
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
|
||||
return '-'
|
||||
}
|
||||
const formatMemMB = (v) => { if (!v) return '-'; return v >= 1024 ? (v / 1024).toFixed(1) + ' GB' : v + ' MB' }
|
||||
const formatDiskGB = (v) => { if (!v) return '-'; return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB' }
|
||||
const formatMemKB = (v) => {
|
||||
if (!v) return '-'; v = Number(v)
|
||||
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 formatDiskGB = (v) => {
|
||||
if (!v) return '-'; v = Number(v)
|
||||
if (v >= 1024) return (v / 1024).toFixed(1) + ' TB'
|
||||
return v.toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
const memoryUnit = ref('GB')
|
||||
const diskUnit = ref('GB')
|
||||
const memoryUnitOptions = [
|
||||
{ label: 'KB', factor: 1 },
|
||||
{ label: 'MB', factor: 1024 },
|
||||
{ label: 'GB', factor: 1048576 },
|
||||
{ label: 'TB', factor: 1073741824 }
|
||||
]
|
||||
const diskUnitOptions = [
|
||||
{ label: 'GB', factor: 1 },
|
||||
{ label: 'TB', factor: 1024 }
|
||||
]
|
||||
const getMemFactor = () => memoryUnitOptions.find(u => u.label === memoryUnit.value)?.factor || 1048576
|
||||
const getDiskFactor = () => diskUnitOptions.find(u => u.label === diskUnit.value)?.factor || 1
|
||||
|
||||
const memoryDisplay = computed({
|
||||
get: () => formData.max_memory ? +(formData.max_memory / getMemFactor()).toFixed(2) : 0,
|
||||
set: (v) => { formData.max_memory = Math.round((v || 0) * getMemFactor()) }
|
||||
})
|
||||
const diskDisplay = computed({
|
||||
get: () => formData.max_disk ? +(formData.max_disk / getDiskFactor()).toFixed(2) : 0,
|
||||
set: (v) => { formData.max_disk = Math.round((v || 0) * getDiskFactor()) }
|
||||
})
|
||||
const formatBytesRaw = (val) => {
|
||||
if (!val && val !== 0) return '-'; val = Number(val)
|
||||
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
||||
@@ -228,19 +357,143 @@ const loadDetail = async () => {
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
detail.value = body.data.host ?? body.data.data ?? body.data
|
||||
} else { ElMessage.error(body?.message || '加载失败') }
|
||||
} catch (e) { ElMessage.error('加载失败') } finally { loading.value = false }
|
||||
} else { ElMessage.error(extractApiError(body, '加载失败')) }
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const cpuChartRef = ref(null)
|
||||
const memChartRef = ref(null)
|
||||
const netChartRef = 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 isPageActive = false
|
||||
|
||||
const loadMetrics = async () => {
|
||||
if (!serviceId.value || !hostId.value || !isPageActive) return
|
||||
metricsLoading.value = true
|
||||
metricsData.value = null
|
||||
try {
|
||||
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: hostId.value })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) { metricsData.value = body.data.data ?? body.data }
|
||||
else ElMessage.warning('暂无指标数据')
|
||||
} catch { ElMessage.error('获取指标失败') } finally { metricsLoading.value = false }
|
||||
if (body?.code === 200 && body?.data) {
|
||||
metricsData.value = body.data.data ?? body.data
|
||||
pushHistory(metricsData.value)
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
|
||||
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
|
||||
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
|
||||
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]
|
||||
|
||||
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 } },
|
||||
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 }]
|
||||
}, 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 } },
|
||||
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 }]
|
||||
}, true)
|
||||
}
|
||||
if (netChartRef.value) {
|
||||
if (!netChart) netChart = echarts.init(netChartRef.value)
|
||||
netChart.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: { top: 10, right: 16, bottom: 24, left: 50 },
|
||||
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
|
||||
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 }
|
||||
]
|
||||
}, 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
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
@@ -269,8 +522,8 @@ const handleSubmit = () => {
|
||||
if (!payload.host_group_id) delete payload.host_group_id
|
||||
const res = await updateRemoteHost(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '修改失败')
|
||||
} catch (e) { ElMessage.error('修改失败') } finally { submitLoading.value = false }
|
||||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -281,8 +534,8 @@ const handleDelete = () => {
|
||||
try {
|
||||
const res = await deleteRemoteHost({ service_id: serviceId.value, id: hostId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -291,29 +544,88 @@ const goBack = () => {
|
||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||||
}
|
||||
|
||||
onMounted(() => { loadDetail(); loadMetrics() })
|
||||
let loadedHostId = null
|
||||
|
||||
const initPage = () => {
|
||||
if (!hostId.value || loadedHostId === hostId.value) return
|
||||
loadedHostId = hostId.value
|
||||
activeTab.value = 'info'
|
||||
Object.keys(hostTabLoaded).forEach(k => hostTabLoaded[k] = false)
|
||||
detail.value = null
|
||||
showToken.value = false
|
||||
showPassword.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
|
||||
disposeCharts()
|
||||
loadDetail()
|
||||
if (activeTab.value === 'monitor') loadMetrics().then(() => startPolling())
|
||||
}
|
||||
|
||||
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() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.host-detail-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; background: #fff; border-bottom: 1px solid #e8e8e8; }
|
||||
.header-left { display: flex; align-items: center; }
|
||||
.back-btn { font-size: 14px; color: #606266; }
|
||||
.back-btn:hover { color: #409eff; }
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.main-content { padding: 20px; }
|
||||
.info-card { margin-bottom: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
|
||||
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
|
||||
.main-content { padding: 20px 24px; }
|
||||
|
||||
.instance-overview { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 20px 24px; border-radius: 4px 4px 0 0; border: 1px solid #e8e8e8; border-bottom: none; }
|
||||
.instance-name { margin: 0; font-size: 18px; font-weight: 600; color: #1d2129; }
|
||||
.instance-id { font-size: 14px; font-weight: 400; color: #86909c; margin-left: 8px; }
|
||||
.overview-actions { display: flex; gap: 8px; }
|
||||
|
||||
.status-bar { display: flex; background: #fff; padding: 16px 24px; border: 1px solid #e8e8e8; border-top: 1px solid #f0f0f0; border-radius: 0 0 4px 4px; margin-bottom: 16px; }
|
||||
.status-item { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||
.status-item + .status-item { border-left: 1px solid #e8e8e8; padding-left: 24px; }
|
||||
.status-label { font-size: 12px; color: #86909c; }
|
||||
.status-value { font-size: 14px; color: #1d2129; font-weight: 500; display: flex; align-items: center; gap: 6px; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.dot-running { background: #00b42a; }
|
||||
.dot-other { background: #c9cdd4; }
|
||||
|
||||
.detail-tabs { background: #fff; border-radius: 4px; border: 1px solid #e8e8e8; padding: 0 24px; }
|
||||
:deep(.detail-tabs > .el-tabs__header) { margin-bottom: 0; }
|
||||
:deep(.detail-tabs > .el-tabs__content) { padding: 0 0 24px; }
|
||||
|
||||
.section-block { margin-top: 20px; }
|
||||
.section-title { font-size: 15px; font-weight: 600; color: #1d2129; margin: 0 0 16px; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.section-header .section-title { margin-bottom: 0; }
|
||||
|
||||
.config-grid { border: 1px solid #e8e8e8; border-radius: 4px; overflow: hidden; }
|
||||
.config-row { display: flex; border-bottom: 1px solid #e8e8e8; }
|
||||
.config-row:last-child { border-bottom: none; }
|
||||
.config-cell { flex: 1; padding: 12px 16px; border-right: 1px solid #e8e8e8; }
|
||||
.config-cell:last-child { border-right: none; }
|
||||
.config-label { display: block; font-size: 12px; color: #86909c; margin-bottom: 4px; }
|
||||
.config-value { display: block; font-size: 14px; color: #1d2129; word-break: break-all; }
|
||||
.mono-text { font-family: 'Consolas', 'Monaco', monospace; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
.resource-cards { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||
.res-item { text-align: center; min-width: 120px; padding: 12px 16px; background: #f5f7fa; border-radius: 8px; }
|
||||
.res-label { font-size: 12px; color: #909399; margin-bottom: 4px; }
|
||||
.res-value { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
|
||||
.metrics-card { margin-bottom: 0; }
|
||||
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user