Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/HostTreeManage.vue
T

950 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="host-tree-container">
<div class="page-header" v-if="!embedded">
<div class="header-left">
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
<div class="header-info">
<h3>宿主机管理</h3>
<span class="sub-info" v-if="serviceName">主控服务{{ serviceName }}</span>
</div>
</div>
</div>
<div class="toolbar">
<el-button type="primary" @click="handleAddGroup"><el-icon><FolderAdd /></el-icon>新建宿主机组</el-button>
<el-button type="success" @click="handleAddHost"><el-icon><Plus /></el-icon>新增宿主机</el-button>
<el-button type="warning" @click="openTokenDialog"><el-icon><Key /></el-icon>创建注册令牌</el-button>
<el-button @click="loadTreeData"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<!-- 树形表格 -->
<el-table :data="treeDisplayData" v-loading="loading" row-key="_rowKey" style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }">
<el-table-column label="名称" min-width="280">
<template #default="{ row }">
<div class="tree-name-cell" :style="{ paddingLeft: (row._depth * 24) + 'px' }">
<span v-if="row._isGroup" class="expand-icon" @click="toggleExpand(row)">
<el-icon v-if="row._loading"><Loading /></el-icon>
<el-icon v-else :class="{ 'is-expanded': row._expanded }"><ArrowRight /></el-icon>
</span>
<span v-else class="expand-placeholder"></span>
<el-tag v-if="row._isGroup && row._isUngrouped" type="info" size="small">未分组</el-tag>
<el-tag v-else-if="row._isGroup" type="warning" size="small">宿主机组</el-tag>
<el-tag v-else type="primary" size="small">宿主机</el-tag>
<span class="row-name">{{ row.name || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="ID" width="70">
<template #default="{ row }">{{ row._isUngrouped ? '-' : row.id }}</template>
</el-table-column>
<el-table-column label="信息" min-width="260">
<template #default="{ row }">
<template v-if="row._isGroup">
<span class="text-muted">{{ row.note || '-' }}</span>
</template>
<template v-else>
<div class="host-addr">{{ row.ip || '-' }}</div>
<div class="host-url" v-if="row.base_url">{{ row.base_url }}</div>
</template>
</template>
</el-table-column>
<el-table-column label="资源/父级" min-width="220">
<template #default="{ row }">
<template v-if="row._isGroup">
<span v-if="row.parent_id" class="text-muted">父级: {{ getGroupName(row.parent_id) }}</span>
<span v-else class="text-muted">顶级分组</span>
</template>
<template v-else>
<div class="resource-info">
<el-tag size="small" type="info" v-if="row.max_cpu">CPU: {{ row.max_cpu }}</el-tag>
<el-tag size="small" type="info" v-if="row.max_memory">内存: {{ formatMemKBDisplay(row.max_memory) }}</el-tag>
<el-tag size="small" type="info" v-if="row.max_disk">磁盘: {{ formatDiskGB(row.max_disk) }}</el-tag>
</div>
</template>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag v-if="row._isHost" :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '在线' : '离线' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<template v-if="row._isGroup && !row._isUngrouped">
<el-button link type="primary" size="small" @click="handleViewGroupDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleEditGroup(row)">编辑</el-button>
<el-button link type="success" size="small" @click="handleAddHostToGroup(row)">新增主机</el-button>
<el-button link type="primary" size="small" @click="handleOptimalHost(row)">最优主机</el-button>
<el-button link type="danger" size="small" @click="handleDeleteGroup(row)">删除</el-button>
</template>
<template v-else-if="row._isHost">
<el-button link type="primary" size="small" @click="handleGoHostDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleEditHost(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDeleteHost(row)">删除</el-button>
</template>
</template>
</el-table-column>
</el-table>
<!-- 创建注册令牌弹窗 -->
<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">
<el-select v-model="tokenForm.host_group_id" placeholder="请选择宿主机组" filterable style="width: 100%">
<el-option :value="0" label="请选择" disabled />
<el-option v-for="g in allGroups" :key="g.id" :value="g.id" :label="`${g.name} (ID: ${g.id})`" />
</el-select>
</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 clickable" @click="showTokenDiskIo = !showTokenDiskIo">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showTokenDiskIo }"><ArrowRight /></el-icon>
<span class="section-hint">可选不展开则使用默认值</span>
</div>
<div v-show="showTokenDiskIo">
<div class="io-sub-title">
带宽限制
<el-select v-model="tokenIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number :model-value="+(tokenForm[f.key] / getTokenIoBwFactor()).toFixed(2)" @update:model-value="v => tokenForm[f.key] = Math.round((v || 0) * getTokenIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label" class="tk-res-item">
<el-input-number v-model="tokenForm[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</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>
<!-- 新建/编辑宿主机组弹窗 -->
<el-dialog v-model="groupDialogVisible" :title="groupDialogType === 'add' ? '新建宿主机组' : '编辑宿主机组'" width="480px" destroy-on-close class="tk-dialog">
<el-form ref="groupFormRef" :model="groupForm" :rules="groupFormRules" label-width="80px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="名称" prop="name">
<el-input v-model="groupForm.name" placeholder="宿主机组名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="groupForm.note" type="textarea" :rows="3" placeholder="备注可选" />
</el-form-item>
<el-form-item label="父级组">
<el-select v-model="groupForm.parent_id" placeholder="选择父级" style="width: 100%" clearable @clear="groupForm.parent_id = 0">
<el-option :value="0" label="顶级分组" />
<el-option v-for="g in parentGroupOptions" :key="g.id" :value="g.id" :label="`${g.name} (ID: ${g.id})`" :disabled="g.id === groupForm.id" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="groupDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitGroupForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 宿主机组详情弹窗 -->
<el-dialog v-model="groupDetailVisible" title="宿主机组详情" width="560px" destroy-on-close>
<div v-loading="groupDetailLoading">
<el-descriptions title="基本信息" :column="2" border v-if="groupDetailData">
<el-descriptions-item label="ID">{{ groupDetailData.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ groupDetailData.name }}</el-descriptions-item>
<el-descriptions-item label="父级">{{ getGroupName(groupDetailData.parent_id) }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ groupDetailData.note || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(groupDetailData.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(groupDetailData.updated_at) }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="父级宿主机组" :column="2" border v-if="groupParentData" style="margin-top: 20px;">
<el-descriptions-item label="ID">{{ groupParentData.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ groupParentData.name }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer><el-button @click="groupDetailVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 最优主机弹窗 -->
<el-dialog v-model="optimalVisible" title="最优主机配置" width="700px" destroy-on-close>
<div v-loading="optimalLoading">
<template v-if="optimalData">
<el-descriptions :column="2" border>
<el-descriptions-item label="主机ID">{{ optimalData.host_id }}</el-descriptions-item>
<el-descriptions-item label="主机名称">{{ optimalData.host_name }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin: 16px 0 8px; color: #303133;">资源字段配置</h4>
<el-table :data="optimalData.fields || []" border stripe size="small">
<el-table-column prop="name" label="名称" width="110" />
<el-table-column prop="key" label="Key" width="120">
<template #default="{ row }"><code>{{ row.key }}</code></template>
</el-table-column>
<el-table-column prop="type" label="类型" width="80" align="center">
<template #default="{ row }"><el-tag size="small" :type="row.type === 'select' ? 'warning' : ''">{{ row.type }}</el-tag></template>
</el-table-column>
<el-table-column label="范围" min-width="200">
<template #default="{ row }">
<template v-if="row.type === 'number'">
<span>{{ formatOptimalRange(row) }}</span>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="必填" width="60" align="center">
<template #default="{ row }">
<el-tag v-if="row.must" type="danger" size="small">是</el-tag>
<span v-else class="text-muted">否</span>
</template>
</el-table-column>
</el-table>
</template>
<el-empty v-else :description="optimalError || '暂无数据'" />
</div>
<template #footer><el-button @click="optimalVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 新建/编辑宿主机弹窗 -->
<el-dialog v-model="hostDialogVisible" :title="hostDialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close class="tk-dialog">
<el-form ref="hostFormRef" :model="hostForm" :rules="hostFormRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="名称" prop="name">
<el-input v-model="hostForm.name" placeholder="宿主机名称" />
</el-form-item>
<el-form-item label="服务地址" prop="base_url">
<el-input v-model="hostForm.base_url" placeholder="宿主机服务 URL" />
</el-form-item>
<el-form-item label="IP 地址" prop="ip">
<el-input v-model="hostForm.ip" placeholder="宿主机 IP" />
</el-form-item>
<el-form-item label="认证Token">
<el-input v-model="hostForm.token" placeholder="可选" 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="hostForm.port" :min="0" :max="65535" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="hostForm.user" placeholder="默认 tunneluser" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="hostForm.password" placeholder="可选" show-password />
</el-form-item>
<el-form-item label="私钥">
<el-input v-model="hostForm.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="hostForm.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-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-form-item label="下行带宽">
<el-input-number v-model="hostForm.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="hostForm.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 clickable" @click="showHostDiskIo = !showHostDiskIo">
硬盘 IO 限制
<el-icon class="section-arrow" :class="{ expanded: showHostDiskIo }"><ArrowRight /></el-icon>
<span class="section-hint">可选,不展开则使用默认值</span>
</div>
<div v-show="showHostDiskIo">
<div class="io-sub-title">
带宽限制
<el-select v-model="hostIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label">
<el-input-number :model-value="+(hostForm[f.key] / getHostIoBwFactor()).toFixed(2)" @update:model-value="v => hostForm[f.key] = Math.round((v || 0) * getHostIoBwFactor())" :min="0" controls-position="right" />
</el-form-item>
</div>
<div class="io-sub-title">IOPS 限制</div>
<div class="tk-resource-grid">
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label">
<el-input-number v-model="hostForm[f.key]" :min="0" controls-position="right" />
</el-form-item>
</div>
</div>
</div>
<div class="tk-section">
<div class="tk-section-title">其他配置</div>
<el-form-item label="宿主机组">
<el-select v-model="hostForm.host_group_id" placeholder="选择宿主机组" clearable filterable style="width: 100%">
<el-option :value="0" label="不选择" />
<el-option v-for="g in allGroups" :key="g.id" :value="g.id" :label="`${g.name} (ID: ${g.id})`" />
</el-select>
</el-form-item>
<el-form-item label="介绍">
<el-input v-model="hostForm.description" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="tk-dialog-footer">
<el-button @click="hostDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitHostForm">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, ArrowLeft, ArrowRight, Loading, FolderAdd, Key, CopyDocument } from '@element-plus/icons-vue'
import {
getRemoteHostGroupList, getRemoteHostGroupTree, getRemoteHostGroupDetail,
createRemoteHostGroup, updateRemoteHostGroup, deleteRemoteHostGroup,
getOptimalHostInfo,
getRemoteHostList, getRemoteHostDetail,
addRemoteHost, updateRemoteHost, deleteRemoteHost,
createHostToken
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { baseUrl } from '@/config/env'
const route = useRoute()
const router = useRouter()
const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
const loading = ref(false)
const submitLoading = ref(false)
const allGroups = ref([])
const allHosts = ref([])
const expandedGroupIds = ref(new Set())
const formatMemKBDisplay = (val) => {
if (!val) return '-'; val = Number(val)
if (val >= 1073741824) return (val / 1073741824).toFixed(1) + ' TB'
if (val >= 1048576) return (val / 1048576).toFixed(1) + ' GB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' MB'
return val + ' KB'
}
const formatDiskGB = (val) => {
if (!val) return '-'; val = Number(val)
if (val >= 1024) return (val / 1024).toFixed(1) + ' TB'
return val.toFixed(1) + ' GB'
}
const formatMemKB = (val) => {
if (!val) return '-'; val = Number(val)
if (val >= 1073741824) return (val / 1073741824).toFixed(1) + ' TB'
if (val >= 1048576) return (val / 1048576).toFixed(1) + ' GB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' MB'
return val + ' KB'
}
const formatDiskMB = (val) => {
if (!val) return '-'; val = Number(val)
if (val >= 1048576) return (val / 1048576).toFixed(1) + ' TB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' GB'
return val.toFixed(1) + ' MB'
}
const formatOptimalRange = (field) => {
if (!field || field.type !== 'number') return '-'
const parts = []
const fmt = (v) => {
if (field.key === 'memory') return formatMemKB(v)
if (field.key === 'system_size') return formatDiskMB(v)
return v
}
if (field.min != null) parts.push('最小: ' + fmt(field.min))
if (field.max != null) parts.push('最大: ' + fmt(field.max))
return parts.join(' / ') || '-'
}
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: () => hostForm.max_memory ? +(hostForm.max_memory / getMemFactor()).toFixed(2) : 0,
set: (v) => { hostForm.max_memory = Math.round((v || 0) * getMemFactor()) }
})
const diskDisplay = computed({
get: () => hostForm.max_disk ? +(hostForm.max_disk / getDiskFactor()).toFixed(2) : 0,
set: (v) => { hostForm.max_disk = Math.round((v || 0) * getDiskFactor()) }
})
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 getGroupName = (gid) => {
if (!gid) return '-'
const g = allGroups.value.find(x => x.id === gid)
return g ? `${g.name} (ID: ${gid})` : `ID: ${gid}`
}
const parentGroupOptions = computed(() => allGroups.value.filter(g => g.id !== groupForm.id))
const UNGROUPED_ID = '__ungrouped__'
const treeDisplayData = computed(() => {
const rows = []
const groupIds = new Set(allGroups.value.map(g => g.id))
const topGroups = allGroups.value.filter(g => !g.parent_id || !groupIds.has(g.parent_id))
const childGroupsOf = (pid) => allGroups.value.filter(g => g.parent_id === pid)
const hostsOf = (gid) => allHosts.value.filter(h => h.host_group_id === gid)
const ungroupedHosts = allHosts.value.filter(h => !h.host_group_id || !groupIds.has(h.host_group_id))
const addGroup = (group, depth) => {
const isExpanded = expandedGroupIds.value.has(group.id)
rows.push({ ...group, _rowKey: `group-${group.id}`, _isGroup: true, _isHost: false, _expanded: isExpanded, _depth: depth })
if (isExpanded) {
childGroupsOf(group.id).forEach(c => addGroup(c, depth + 1))
hostsOf(group.id).forEach(h => {
rows.push({ ...h, _rowKey: `host-${h.id}`, _isGroup: false, _isHost: true, _depth: depth + 1 })
})
}
}
topGroups.forEach(g => addGroup(g, 0))
if (ungroupedHosts.length) {
const isExpanded = expandedGroupIds.value.has(UNGROUPED_ID)
rows.push({ id: UNGROUPED_ID, name: '未分组宿主机', note: `${ungroupedHosts.length}`, _rowKey: 'group-ungrouped', _isGroup: true, _isHost: false, _expanded: isExpanded, _depth: 0, _isUngrouped: true })
if (isExpanded) {
ungroupedHosts.forEach(h => {
rows.push({ ...h, _rowKey: `host-${h.id}`, _isGroup: false, _isHost: true, _depth: 1 })
})
}
}
return rows
})
const toggleExpand = (row) => {
if (expandedGroupIds.value.has(row.id)) {
expandedGroupIds.value.delete(row.id)
} else {
expandedGroupIds.value.add(row.id)
}
}
const flattenTree = (nodes, parentId = 0) => {
const result = []
if (!Array.isArray(nodes)) return result
for (const node of nodes) {
const { children, ...group } = node
if (parentId) group.parent_id = parentId
result.push(group)
if (children && children.length) {
result.push(...flattenTree(children, group.id))
}
}
return result
}
const loadTreeData = async () => {
if (!serviceId.value) return
loading.value = true
try {
const [groupRes, hostRes] = await Promise.all([
getRemoteHostGroupTree({ service_id: serviceId.value }),
getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
])
if (groupRes?.data?.code === 200 && groupRes?.data?.data) {
const inner = groupRes.data.data
const tree = inner.tree || inner.host_groups || inner.groups || inner.data || (Array.isArray(inner) ? inner : [])
allGroups.value = flattenTree(tree)
}
if (hostRes?.data?.code === 200 && hostRes?.data?.data) {
const inner = hostRes.data.data
allHosts.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '加载数据失败'))
} finally { loading.value = false }
}
// ---- 宿主机组 CRUD ----
const groupDialogVisible = ref(false)
const groupDialogType = ref('add')
const groupFormRef = ref(null)
const groupForm = reactive({ id: undefined, name: '', note: '', parent_id: 0 })
const groupFormRules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }] }
const groupDetailVisible = ref(false)
const groupDetailLoading = ref(false)
const groupDetailData = ref(null)
const groupParentData = ref(null)
const optimalVisible = ref(false)
const optimalLoading = ref(false)
const optimalData = ref(null)
const optimalError = ref('')
const handleAddGroup = () => {
groupDialogType.value = 'add'
Object.assign(groupForm, { id: undefined, name: '', note: '', parent_id: 0 })
groupDialogVisible.value = true
}
const handleEditGroup = (row) => {
groupDialogType.value = 'edit'
Object.assign(groupForm, { id: row.id, name: row.name, note: row.note || '', parent_id: row.parent_id || 0 })
groupDialogVisible.value = true
}
const submitGroupForm = () => {
groupFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const payload = { service_id: serviceId.value, name: groupForm.name, note: groupForm.note, parent_id: groupForm.parent_id || 0 }
let res
if (groupDialogType.value === 'add') {
res = await createRemoteHostGroup(payload)
} else {
payload.id = groupForm.id
res = await updateRemoteHostGroup(payload)
}
if (res?.data?.code === 200) { ElMessage.success(groupDialogType.value === 'add' ? '创建成功' : '修改成功'); groupDialogVisible.value = false; loadTreeData() }
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
})
}
const handleDeleteGroup = (row) => {
ElMessageBox.confirm(`确定要删除宿主机组「${row.name}」吗?`, '删除确认', { type: 'warning' }).then(async () => {
try {
const res = await deleteRemoteHostGroup({ service_id: serviceId.value, id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadTreeData() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const handleViewGroupDetail = async (row) => {
groupDetailVisible.value = true
groupDetailLoading.value = true
groupDetailData.value = null
groupParentData.value = null
try {
const res = await getRemoteHostGroupDetail({ service_id: serviceId.value, id: row.id })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
groupDetailData.value = d.host_group || d
groupParentData.value = d.parent_host_group || null
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取详情失败')) }
finally { groupDetailLoading.value = false }
}
const handleOptimalHost = async (row) => {
optimalVisible.value = true
optimalLoading.value = true
optimalData.value = null
optimalError.value = ''
try {
const res = await getOptimalHostInfo({ service_id: serviceId.value, host_group_id: row.id })
if (res?.data?.code === 200 && res?.data?.data) optimalData.value = res.data.data
else optimalError.value = extractApiError(res?.data, '暂无数据')
} catch (e) { optimalError.value = extractApiError(e?.response?.data, '获取失败') }
finally { optimalLoading.value = false }
}
// ---- 宿主机 CRUD ----
const diskIoDefaults = {
read_bytes_sec: 314572800, write_bytes_sec: 314572800,
read_iops_sec: 1000, write_iops_sec: 1000,
read_bytes_sec_max: 314572800, write_bytes_sec_max: 314572800,
read_iops_sec_max: 1000, write_iops_sec_max: 1000
}
const diskIoBwFields = [
{ key: 'read_bytes_sec', label: '读取' },
{ key: 'write_bytes_sec', label: '写入' },
{ key: 'read_bytes_sec_max', label: '突发读取' },
{ key: 'write_bytes_sec_max', label: '突发写入' }
]
const diskIoIopsFields = [
{ key: 'read_iops_sec', label: '读取' },
{ key: 'write_iops_sec', label: '写入' },
{ key: 'read_iops_sec_max', label: '突发读取' },
{ key: 'write_iops_sec_max', label: '突发写入' }
]
const diskIoFields = [
...diskIoBwFields.map(f => ({ ...f, isBandwidth: true })),
...diskIoIopsFields.map(f => ({ ...f, isBandwidth: false }))
]
const ioBwUnitOptions = [
{ label: 'B/s', factor: 1 },
{ label: 'KB/s', factor: 1024 },
{ label: 'MB/s', factor: 1048576 },
{ label: 'GB/s', factor: 1073741824 }
]
const hostIoBwUnit = ref('MB/s')
const tokenIoBwUnit = ref('MB/s')
const getHostIoBwFactor = () => ioBwUnitOptions.find(u => u.label === hostIoBwUnit.value)?.factor || 1048576
const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBwUnit.value)?.factor || 1048576
const showHostDiskIo = ref(false)
const showTokenDiskIo = ref(false)
const hostDialogVisible = ref(false)
const hostDialogType = ref('add')
const hostFormRef = ref(null)
const hostForm = reactive({
id: undefined, name: '', base_url: '', ip: '', token: '',
port: 22, user: '', password: '', private_key: '',
max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
...diskIoDefaults
})
const hostFormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
base_url: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
ip: [{ required: true, message: '请输入IP地址', trigger: 'blur' }]
}
const handleAddHost = () => {
hostDialogType.value = 'add'
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', ...diskIoDefaults })
showHostDiskIo.value = false
hostIoBwUnit.value = 'MB/s'
hostDialogVisible.value = true
}
const handleAddHostToGroup = (group) => {
hostDialogType.value = 'add'
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '', ...diskIoDefaults })
showHostDiskIo.value = false
hostIoBwUnit.value = 'MB/s'
hostDialogVisible.value = true
}
const handleEditHost = (row) => {
hostDialogType.value = 'edit'
Object.assign(hostForm, {
id: row.id, name: row.name, base_url: row.base_url || '', ip: row.ip || '', token: row.token || '',
port: row.port || 22, user: row.user || '', password: row.password || '', private_key: row.private_key || '',
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
host_group_id: row.host_group_id || 0, description: row.description || '',
...Object.fromEntries(diskIoFields.map(f => [f.key, row[f.key] ?? diskIoDefaults[f.key]]))
})
showHostDiskIo.value = diskIoFields.some(f => row[f.key] && row[f.key] !== diskIoDefaults[f.key])
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data.host ?? res.data.data.data ?? res.data.data
if (d.password) hostForm.password = d.password
if (d.token) hostForm.token = d.token
if (d.private_key) hostForm.private_key = d.private_key
diskIoFields.forEach(f => { if (d[f.key] !== undefined) hostForm[f.key] = d[f.key] })
}
}).catch(() => {})
hostDialogVisible.value = true
}
const submitHostForm = () => {
hostFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const payload = { ...hostForm, service_id: serviceId.value }
delete payload.id
if (!payload.token) delete payload.token
if (!payload.password) delete payload.password
if (!payload.private_key) delete payload.private_key
if (!payload.host_group_id) delete payload.host_group_id
if (!payload.description) delete payload.description
let res
if (hostDialogType.value === 'add') {
res = await addRemoteHost(payload)
} else {
payload.id = hostForm.id
res = await updateRemoteHost(payload)
}
if (res?.data?.code === 200) { ElMessage.success(hostDialogType.value === 'add' ? '新增成功' : '修改成功'); hostDialogVisible.value = false; loadTreeData() }
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
})
}
const handleGoHostDetail = (row) => {
router.push({ path: '/virtualization/host-detail', query: { service_id: serviceId.value, id: row.id, service_name: serviceName.value } })
}
const handleDeleteHost = (row) => {
ElMessageBox.confirm(`确定要删除宿主机「${row.name}」吗?`, '删除确认', { type: 'warning' }).then(async () => {
try {
const res = await deleteRemoteHost({ service_id: serviceId.value, id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadTreeData() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
// ========== 创建注册令牌 ==========
const tokenDialogVisible = ref(false)
const tokenSubmitLoading = ref(false)
const tokenResultVisible = 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,
...diskIoDefaults
})
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 = () => {
Object.assign(tokenForm, {
name: '', host_group_id: 0, max_cpu: 4,
max_memory: 4194304, max_disk: 100,
rx_bandwidth: 100, tx_bandwidth: 100,
description: '', expire_hours: 24,
...diskIoDefaults
})
tokenMemUnit.value = 'GB'
tokenDiskUnit.value = 'GB'
showTokenDiskIo.value = false
tokenIoBwUnit.value = 'MB/s'
tokenDialogVisible.value = true
}
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)
diskIoFields.forEach(f => { if (tokenForm[f.key] !== undefined) fd.append(f.key, tokenForm[f.key]) })
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 = () => { router.push('/virtualization/kvm-service') }
onMounted(() => { if (serviceId.value) loadTreeData() })
</script>
<style scoped>
.host-tree-container { padding: 0; }
.tree-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.expand-icon {
cursor: pointer;
display: inline-flex;
align-items: center;
width: 20px;
justify-content: center;
transition: transform 0.2s;
}
.expand-icon .el-icon { font-size: 14px; color: #606266; transition: transform 0.2s; }
.expand-icon .is-expanded { transform: rotate(90deg); }
.expand-placeholder { width: 20px; display: inline-block; }
.row-name { font-weight: 500; color: #303133; }
.host-addr { color: #409eff; font-size: 13px; }
.host-url { color: #909399; font-size: 12px; }
.tk-section-title.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
.tk-section-title.clickable:hover { color: #409eff; }
.section-arrow { transition: transform 0.2s; font-size: 14px; }
.section-arrow.expanded { transform: rotate(90deg); }
.section-hint { font-size: 12px; color: #909399; font-weight: 400; }
.io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; }
</style>