feat: 对接主控服务接口
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
<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>
|
||||
</el-card>
|
||||
|
||||
<!-- 实时指标 -->
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑宿主机" width="640px" 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_path" /></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="最大内存(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-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>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<HostGroupSelectorPopup v-model="showGroupSelector" :service-id="serviceId" :current-id="formData.host_group_id" @confirm="g => formData.host_group_id = g.id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } 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 HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
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 loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const metricsLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const metricsData = ref(null)
|
||||
const editDialogVisible = ref(false)
|
||||
const showGroupSelector = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key_path: '',
|
||||
max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: ''
|
||||
})
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
base_url: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
|
||||
ip: [{ required: true, message: '请输入IP', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
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 formatBytesRaw = (val) => {
|
||||
if (!val && val !== 0) return '-'; val = Number(val)
|
||||
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
||||
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||||
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||||
return val + ' B'
|
||||
}
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!serviceId.value || !hostId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRemoteHostDetail({ service_id: serviceId.value, id: hostId.value })
|
||||
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 }
|
||||
}
|
||||
|
||||
const loadMetrics = async () => {
|
||||
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 }
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!detail.value) return
|
||||
const d = detail.value
|
||||
Object.assign(formData, {
|
||||
name: d.name || '', base_url: d.base_url || '', ip: d.ip || '', token: d.token || '',
|
||||
port: d.port || 22, user: d.user || '', password: d.password || '', private_key_path: d.private_key_path || '',
|
||||
max_cpu: d.max_cpu || 0, max_memory: d.max_memory || 0, max_disk: d.max_disk || 0,
|
||||
rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0,
|
||||
host_group_id: d.host_group_id || 0, description: d.description || ''
|
||||
})
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { ...formData, service_id: serviceId.value, id: hostId.value }
|
||||
if (!payload.token) delete payload.token
|
||||
if (!payload.password) delete payload.password
|
||||
if (!payload.private_key_path) delete payload.private_key_path
|
||||
if (!payload.description) delete payload.description
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
ElMessageBox.confirm(`确定要删除宿主机「${detail.value?.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
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('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
tagsViewStore.delVisitedView(route)
|
||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||||
}
|
||||
|
||||
onMounted(() => { loadDetail(); loadMetrics() })
|
||||
</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; }
|
||||
.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; }
|
||||
.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 .el-icon { font-size: 16px; color: #409eff; }
|
||||
.disk-item { margin-bottom: 8px; }
|
||||
.disk-path { font-weight: 500; color: #409eff; font-size: 13px; margin-bottom: 4px; font-family: 'Consolas', monospace; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user