feat: 对接虚拟化平台管理
This commit is contained in:
@@ -427,10 +427,8 @@ export const exitRescueVm = (data) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 删除虚拟机 */
|
/** 删除虚拟机 */
|
||||||
export const deleteVm = (data) => {
|
export const deleteVm = (params) => {
|
||||||
return http2.post('/api/v1/admin/server/host_service/point/vm/delete', data, {
|
return http2.delete('/api/v1/admin/server/host_service/point/vm/delete', { params })
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -456,6 +454,13 @@ export const createSecurityGroup = (data) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 修改安全组 */
|
||||||
|
export const updateSecurityGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 同步安全组 */
|
/** 同步安全组 */
|
||||||
export const syncSecurityGroup = (data) => {
|
export const syncSecurityGroup = (data) => {
|
||||||
return http2.post('/api/v1/admin/server/host_service/point/post_group/sync', data, {
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/sync', data, {
|
||||||
@@ -563,3 +568,74 @@ export const updateVncNode = (data) => {
|
|||||||
export const deleteVncNode = (params) => {
|
export const deleteVncNode = (params) => {
|
||||||
return http2.delete('/api/v1/admin/server/host_service/point/vnc/delete', { params })
|
return http2.delete('/api/v1/admin/server/host_service/point/vnc/delete', { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置安全组共享状态 */
|
||||||
|
export const setSecurityGroupShared = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/post_group/set_shared', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 快照管理 ==========
|
||||||
|
/** 获取快照列表 */
|
||||||
|
export const getSnapshotList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/snapshot/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取快照任务进度 */
|
||||||
|
export const getSnapshotProgress = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/snapshot/progress', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建快照 */
|
||||||
|
export const createSnapshot = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/snapshot/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复快照 */
|
||||||
|
export const restoreSnapshot = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/snapshot/restore', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除快照 */
|
||||||
|
export const deleteSnapshot = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/snapshot/delete', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 备份管理 ==========
|
||||||
|
/** 获取备份列表 */
|
||||||
|
export const getBackupList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/backup/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取备份任务进度 */
|
||||||
|
export const getBackupProgress = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/backup/progress', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建备份 */
|
||||||
|
export const createBackup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/backup/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复备份 */
|
||||||
|
export const restoreBackup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/backup/restore', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除备份 */
|
||||||
|
export const deleteBackup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/backup/delete', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ export const menus = [
|
|||||||
{
|
{
|
||||||
path: '/virtualization/kvm-service',
|
path: '/virtualization/kvm-service',
|
||||||
title: '主控服务管理'
|
title: '主控服务管理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/virtualization/host-group-mapping',
|
||||||
|
title: '宿主机组映射管理'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-3
@@ -422,9 +422,7 @@ const routes = [
|
|||||||
name: 'HostGroupMapping',
|
name: 'HostGroupMapping',
|
||||||
component: () => import('../views/virtualization/HostGroupMapping.vue'),
|
component: () => import('../views/virtualization/HostGroupMapping.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '宿主机组映射管理',
|
title: '宿主机组映射管理'
|
||||||
hidden: true,
|
|
||||||
activeMenu: '/virtualization/kvm-service'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
const ERROR_CODE_MAP = {
|
||||||
|
// 主控服务
|
||||||
|
kvm_service_list_error: '获取主控服务列表失败',
|
||||||
|
kvm_service_detail_error: '获取主控服务详情失败',
|
||||||
|
kvm_service_create_error: '创建主控服务失败',
|
||||||
|
kvm_service_update_error: '修改主控服务失败',
|
||||||
|
kvm_service_delete_error: '删除主控服务失败',
|
||||||
|
|
||||||
|
// 宿主机组(本地)
|
||||||
|
kvm_host_group_list_error: '获取宿主机组列表失败',
|
||||||
|
kvm_host_group_sync_error: '同步宿主机组失败',
|
||||||
|
kvm_host_group_bind_error: '绑定宿主机组失败',
|
||||||
|
kvm_host_group_update_error: '修改宿主机组失败',
|
||||||
|
kvm_host_group_delete_error: '删除宿主机组失败',
|
||||||
|
kvm_host_group_generate_error: '生成商品失败',
|
||||||
|
kvm_host_group_optimal_error: '获取最优主机失败',
|
||||||
|
|
||||||
|
// 宿主机组(远程)
|
||||||
|
kvm_remote_host_group_list_error: '获取远程宿主机组列表失败',
|
||||||
|
kvm_remote_host_group_detail_error: '获取远程宿主机组详情失败',
|
||||||
|
kvm_remote_host_group_tree_error: '获取远程宿主机组树失败',
|
||||||
|
kvm_remote_host_group_create_error: '创建远程宿主机组失败',
|
||||||
|
kvm_remote_host_group_update_error: '修改远程宿主机组失败',
|
||||||
|
kvm_remote_host_group_delete_error: '删除远程宿主机组失败',
|
||||||
|
|
||||||
|
// 宿主机
|
||||||
|
kvm_host_list_error: '获取宿主机列表失败',
|
||||||
|
kvm_host_detail_error: '获取宿主机详情失败',
|
||||||
|
kvm_host_metrics_error: '获取宿主机指标失败',
|
||||||
|
kvm_host_add_error: '新增宿主机失败',
|
||||||
|
kvm_host_update_error: '修改宿主机失败',
|
||||||
|
kvm_host_delete_error: '删除宿主机失败',
|
||||||
|
|
||||||
|
// 镜像
|
||||||
|
kvm_image_list_error: '获取镜像列表失败',
|
||||||
|
kvm_image_detail_error: '获取镜像详情失败',
|
||||||
|
kvm_image_host_status_error: '获取镜像宿主机状态失败',
|
||||||
|
kvm_image_create_error: '创建镜像失败',
|
||||||
|
kvm_image_update_error: '修改镜像失败',
|
||||||
|
kvm_image_delete_error: '删除镜像失败',
|
||||||
|
kvm_image_reload_error: '重新下载镜像失败',
|
||||||
|
kvm_image_sync_error: '同步镜像到宿主机失败',
|
||||||
|
kvm_image_reload_host_error: '重新下载镜像到宿主机失败',
|
||||||
|
|
||||||
|
// 网络
|
||||||
|
kvm_network_list_error: '获取网络列表失败',
|
||||||
|
kvm_network_detail_error: '获取网络详情失败',
|
||||||
|
kvm_network_create_error: '创建网络失败',
|
||||||
|
kvm_network_update_error: '修改网络失败',
|
||||||
|
kvm_network_delete_error: '删除网络失败',
|
||||||
|
|
||||||
|
// 数据卷
|
||||||
|
kvm_volume_list_error: '获取数据卷列表失败',
|
||||||
|
kvm_volume_detail_error: '获取数据卷详情失败',
|
||||||
|
kvm_volume_create_error: '创建数据卷失败',
|
||||||
|
kvm_volume_resize_error: '调整数据卷大小失败',
|
||||||
|
kvm_volume_mount_error: '挂载数据卷失败',
|
||||||
|
kvm_volume_unmount_error: '卸载数据卷失败',
|
||||||
|
kvm_volume_transfer_error: '迁移数据卷失败',
|
||||||
|
kvm_volume_delete_error: '删除数据卷失败',
|
||||||
|
|
||||||
|
// 虚拟机
|
||||||
|
kvm_vm_list_error: '获取虚拟机列表失败',
|
||||||
|
kvm_vm_detail_error: '获取虚拟机详情失败',
|
||||||
|
kvm_vm_status_error: '获取虚拟机状态失败',
|
||||||
|
kvm_vm_metrics_error: '获取虚拟机指标失败',
|
||||||
|
kvm_vm_create_error: '创建虚拟机失败',
|
||||||
|
kvm_vm_update_error: '修改虚拟机失败',
|
||||||
|
kvm_vm_rebuild_error: '重建虚拟机失败',
|
||||||
|
kvm_vm_refactor_error: '重构虚拟机失败',
|
||||||
|
kvm_vm_update_traffic_error: '修改虚拟机带宽失败',
|
||||||
|
kvm_vm_start_error: '启动虚拟机失败',
|
||||||
|
kvm_vm_stop_error: '停止虚拟机失败',
|
||||||
|
kvm_vm_reboot_error: '重启虚拟机失败',
|
||||||
|
kvm_vm_suspend_error: '暂停虚拟机失败',
|
||||||
|
kvm_vm_resume_error: '恢复虚拟机失败',
|
||||||
|
kvm_vm_rescue_error: '进入救援系统失败',
|
||||||
|
kvm_vm_exit_rescue_error: '退出救援系统失败',
|
||||||
|
kvm_vm_delete_error: '删除虚拟机失败',
|
||||||
|
|
||||||
|
// 安全组
|
||||||
|
kvm_post_group_list_error: '获取安全组列表失败',
|
||||||
|
kvm_post_group_detail_error: '获取安全组详情失败',
|
||||||
|
kvm_post_group_create_error: '创建安全组失败',
|
||||||
|
kvm_post_group_update_error: '修改安全组失败',
|
||||||
|
kvm_post_group_sync_error: '同步安全组失败',
|
||||||
|
kvm_post_group_bind_error: '绑定安全组失败',
|
||||||
|
kvm_post_group_unbind_error: '解绑安全组失败',
|
||||||
|
kvm_post_group_delete_error: '删除安全组失败',
|
||||||
|
kvm_post_group_enable_whitelist_error: '开启安全组白名单失败',
|
||||||
|
kvm_post_group_disable_whitelist_error: '关闭安全组白名单失败',
|
||||||
|
kvm_post_group_create_rule_error: '新增安全组规则失败',
|
||||||
|
kvm_post_group_update_rule_error: '修改安全组规则失败',
|
||||||
|
kvm_post_group_delete_rule_error: '删除安全组规则失败',
|
||||||
|
kvm_post_group_apply_error: '应用安全组失败',
|
||||||
|
kvm_security_group_list_error: '获取安全组列表失败',
|
||||||
|
kvm_security_group_detail_error: '获取安全组详情失败',
|
||||||
|
kvm_security_group_create_error: '创建安全组失败',
|
||||||
|
kvm_security_group_update_error: '修改安全组失败',
|
||||||
|
kvm_security_group_delete_error: '删除安全组失败',
|
||||||
|
|
||||||
|
// VNC
|
||||||
|
kvm_vnc_list_error: '获取VNC节点列表失败',
|
||||||
|
kvm_vnc_add_error: '新增VNC节点失败',
|
||||||
|
kvm_vnc_test_error: '测试VNC节点连接失败',
|
||||||
|
kvm_vnc_update_error: '修改VNC节点失败',
|
||||||
|
kvm_vnc_delete_error: '删除VNC节点失败',
|
||||||
|
kvm_vnc_vm_vnc_error: '获取VNC连接信息失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从嵌套的 RPC 错误字符串中提取有意义的中文描述
|
||||||
|
*/
|
||||||
|
function parseRpcError(err) {
|
||||||
|
if (!err) return ''
|
||||||
|
const descMatch = err.match(/desc\s*=\s*(.+)/)
|
||||||
|
if (descMatch) {
|
||||||
|
const descContent = descMatch[1]
|
||||||
|
const jsonMatch = descContent.match(/body=(\{.+\})/)
|
||||||
|
if (jsonMatch) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonMatch[1])
|
||||||
|
if (parsed.message) return parsed.message
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
const clean = descContent.trim()
|
||||||
|
if (clean && !clean.startsWith('http')) return clean
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一提取 API 响应中的错误信息
|
||||||
|
* @param {object} body - axios response.data (即 { code, message, error, data })
|
||||||
|
* @param {string} fallback - 兜底文案
|
||||||
|
* @returns {string} 中文错误描述
|
||||||
|
*/
|
||||||
|
export function extractApiError(body, fallback = '操作失败') {
|
||||||
|
if (!body) return fallback
|
||||||
|
|
||||||
|
const rpcMsg = parseRpcError(body.error)
|
||||||
|
if (rpcMsg) return rpcMsg
|
||||||
|
|
||||||
|
const mapped = ERROR_CODE_MAP[body.message]
|
||||||
|
if (mapped) return mapped
|
||||||
|
|
||||||
|
if (body.message && !/^[a-z_]+$/.test(body.message)) return body.message
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div class="backup-manage">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="handleCreate"><el-icon><Plus /></el-icon>创建备份</el-button>
|
||||||
|
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe size="small" style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="vm_id" label="虚拟机ID" width="90" />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'completed' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'" size="small">
|
||||||
|
{{ statusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatTs(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="handleRestore(row)">恢复</el-button>
|
||||||
|
<el-button link type="info" size="small" @click="handleProgress(row)">进度</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!list.length && !loading" description="暂无备份数据" />
|
||||||
|
|
||||||
|
<el-dialog v-model="createVisible" title="创建备份" width="480px" destroy-on-close>
|
||||||
|
<el-form :model="createForm" label-width="100px">
|
||||||
|
<el-form-item label="虚拟机" required>
|
||||||
|
<el-select v-model="createForm.vm_id" placeholder="请选择虚拟机" filterable style="width: 100%" :loading="vmOptionsLoading">
|
||||||
|
<el-option v-for="vm in vmOptions" :key="vm.id" :label="`${vm.name} (ID: ${vm.id})`" :value="vm.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备份名称" required>
|
||||||
|
<el-input v-model="createForm.name" placeholder="请输入备份名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选描述" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="createVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="progressVisible" title="备份任务进度" width="420px" destroy-on-close>
|
||||||
|
<div v-loading="progressLoading">
|
||||||
|
<el-descriptions :column="1" border size="small" v-if="progressData">
|
||||||
|
<el-descriptions-item v-for="(val, key) in progressData" :key="key" :label="key">{{ val }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-empty v-else description="暂无进度信息" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="progressVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress, getVmList } from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
|
|
||||||
|
const serviceId = inject('serviceId')
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
|
||||||
|
const statusLabel = (s) => ({ completed: '完成', pending: '等待', running: '运行中', failed: '失败' }[s] || s || '-')
|
||||||
|
const formatTs = (ts) => {
|
||||||
|
if (!ts) return '-'
|
||||||
|
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||||
|
return typeof ts === 'string' ? ts : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getBackupList({ service_id: serviceId.value })
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const d = res.data.data
|
||||||
|
list.value = d.backups || d.data || d.list || (Array.isArray(d) ? d : [])
|
||||||
|
} else list.value = []
|
||||||
|
} catch { list.value = [] } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmOptions = ref([])
|
||||||
|
const vmOptionsLoading = ref(false)
|
||||||
|
|
||||||
|
const loadVmOptions = async () => {
|
||||||
|
vmOptionsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getVmList({ service_id: serviceId.value, page: 1, page_size: 500 })
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const inner = res.data.data
|
||||||
|
vmOptions.value = inner.vms || inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||||
|
}
|
||||||
|
} catch { /* */ } finally { vmOptionsLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const createVisible = ref(false)
|
||||||
|
const createForm = reactive({ vm_id: null, name: '', description: '' })
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
Object.assign(createForm, { vm_id: null, name: '', description: '' })
|
||||||
|
if (!vmOptions.value.length) await loadVmOptions()
|
||||||
|
createVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (!createForm.vm_id || !createForm.name) { ElMessage.warning('请填写必填项'); return }
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('vm_id', createForm.vm_id)
|
||||||
|
fd.append('name', createForm.name)
|
||||||
|
if (createForm.description) fd.append('description', createForm.description)
|
||||||
|
const res = await createBackup(fd)
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('备份创建成功'); createVisible.value = false; loadList() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { submitLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = (row) => {
|
||||||
|
ElMessageBox.confirm(`确定要恢复备份「${row.name}」到虚拟机(ID:${row.vm_id})吗?`, '恢复确认', {
|
||||||
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('backup_id', row.id)
|
||||||
|
fd.append('vm_id', row.vm_id)
|
||||||
|
const res = await restoreBackup(fd)
|
||||||
|
if (res?.data?.code === 200) ElMessage.success('恢复操作已提交')
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '恢复失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复失败')) }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (row) => {
|
||||||
|
ElMessageBox.confirm(`确定要删除备份「${row.name}」吗?`, '删除确认', {
|
||||||
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('backup_id', row.id)
|
||||||
|
fd.append('vm_id', row.vm_id)
|
||||||
|
const res = await deleteBackup(fd)
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressVisible = ref(false)
|
||||||
|
const progressLoading = ref(false)
|
||||||
|
const progressData = ref(null)
|
||||||
|
|
||||||
|
const handleProgress = async (row) => {
|
||||||
|
progressData.value = null
|
||||||
|
progressVisible.value = true
|
||||||
|
progressLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getBackupProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) })
|
||||||
|
if (res?.data?.code === 200) progressData.value = res.data.data
|
||||||
|
else ElMessage.warning('暂无进度信息')
|
||||||
|
} catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadList() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backup-manage { padding: 0; }
|
||||||
|
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
|
</style>
|
||||||
@@ -1,131 +1,186 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="host-detail-page">
|
<div class="host-detail-page">
|
||||||
|
<!-- 顶部返回栏 -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回宿主机列表</el-button>
|
<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>
|
||||||
<div class="header-right">
|
<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 plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
||||||
<el-button type="danger" plain :icon="Delete" @click="handleDelete">删除</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="main-content" v-loading="loading">
|
<div class="main-content" v-loading="loading">
|
||||||
<!-- 基本信息卡片 -->
|
<!-- 实例概览栏 -->
|
||||||
<el-card shadow="never" class="info-card" v-if="detail">
|
<div class="instance-overview" v-if="detail">
|
||||||
<template #header><span class="card-title">基本信息</span></template>
|
<div class="overview-left">
|
||||||
<el-descriptions :column="2" border>
|
<h2 class="instance-name">{{ detail.name || '-' }} <span class="instance-id">{{ detail.id }}</span></h2>
|
||||||
<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>
|
</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">
|
<div class="status-bar" v-if="detail">
|
||||||
<template #header>
|
<div class="status-item">
|
||||||
<div class="card-header-row">
|
<span class="status-label">状态</span>
|
||||||
<span class="card-title">实时指标</span>
|
<span class="status-value">
|
||||||
<el-button size="small" :icon="Refresh" @click="loadMetrics" :loading="metricsLoading">刷新指标</el-button>
|
<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>
|
</div>
|
||||||
</template>
|
</el-tab-pane>
|
||||||
<div v-loading="metricsLoading">
|
|
||||||
<template v-if="metricsData">
|
<el-tab-pane label="监控" name="monitor">
|
||||||
<el-row :gutter="16">
|
<div class="section-block">
|
||||||
<el-col :span="12" v-if="metricsData.cpu">
|
<div class="section-header">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<h3 class="section-title">实时指标</h3>
|
||||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU</span></template>
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
<el-descriptions :column="2" border size="small">
|
<el-tag v-if="pollingActive" type="success" size="small" effect="plain">自动刷新中</el-tag>
|
||||||
<el-descriptions-item label="使用率">{{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
|
<el-button size="small" :icon="Refresh" @click="loadMetrics" :loading="metricsLoading">刷新指标</el-button>
|
||||||
<el-descriptions-item label="核心数">{{ metricsData.cpu.cpu_count ?? '-' }}</el-descriptions-item>
|
</div>
|
||||||
</el-descriptions>
|
</div>
|
||||||
</el-card>
|
<template v-if="metricsData">
|
||||||
</el-col>
|
<el-row :gutter="16">
|
||||||
<el-col :span="12" v-if="metricsData.memory">
|
<el-col :span="12" v-if="metricsData.cpu">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<el-card shadow="hover" class="metrics-card">
|
||||||
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存</span></template>
|
<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>
|
||||||
<el-descriptions :column="2" border size="small">
|
<div ref="cpuChartRef" class="chart-container"></div>
|
||||||
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.memory.total) }}</el-descriptions-item>
|
</el-card>
|
||||||
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.memory.used) }}</el-descriptions-item>
|
</el-col>
|
||||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(metricsData.memory.free) }}</el-descriptions-item>
|
<el-col :span="12" v-if="metricsData.memory">
|
||||||
<el-descriptions-item label="使用率">{{ metricsData.memory.percent ?? '-' }}%</el-descriptions-item>
|
<el-card shadow="hover" class="metrics-card">
|
||||||
</el-descriptions>
|
<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>
|
||||||
</el-card>
|
<div ref="memChartRef" class="chart-container"></div>
|
||||||
</el-col>
|
</el-card>
|
||||||
</el-row>
|
</el-col>
|
||||||
<el-row :gutter="16" style="margin-top: 16px">
|
</el-row>
|
||||||
<el-col :span="12" v-if="metricsData.disk">
|
<el-row :gutter="16" style="margin-top: 16px">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<el-col :span="12" v-if="metricsData.disk">
|
||||||
<template #header><span class="metrics-title"><el-icon><Box /></el-icon> 磁盘</span></template>
|
<el-card shadow="hover" class="metrics-card">
|
||||||
<div v-for="(info, path) in metricsData.disk" :key="path" class="disk-item">
|
<template #header><span class="metrics-title"><el-icon><Box /></el-icon> 磁盘</span></template>
|
||||||
<div class="disk-path">{{ path }}</div>
|
<div v-for="(info, path) in metricsData.disk" :key="path" class="disk-item">
|
||||||
<el-descriptions :column="2" border size="small">
|
<div class="disk-path">{{ path }}</div>
|
||||||
<el-descriptions-item label="总计">{{ formatBytesRaw(info.total) }}</el-descriptions-item>
|
<el-descriptions :column="2" border size="small">
|
||||||
<el-descriptions-item label="已用">{{ formatBytesRaw(info.used) }}</el-descriptions-item>
|
<el-descriptions-item label="总计">{{ formatBytesRaw(info.total) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(info.free) }}</el-descriptions-item>
|
<el-descriptions-item label="已用">{{ formatBytesRaw(info.used) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="使用率">{{ info.percent ?? '-' }}%</el-descriptions-item>
|
<el-descriptions-item label="空闲">{{ formatBytesRaw(info.free) }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
<el-descriptions-item label="使用率">{{ info.percent ?? '-' }}%</el-descriptions-item>
|
||||||
</div>
|
</el-descriptions>
|
||||||
</el-card>
|
</div>
|
||||||
</el-col>
|
</el-card>
|
||||||
<el-col :span="12" v-if="metricsData.network || metricsData.internet_speed">
|
</el-col>
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<el-col :span="12" v-if="metricsData.network || metricsData.internet_speed">
|
||||||
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
|
<el-card shadow="hover" class="metrics-card">
|
||||||
<el-descriptions :column="2" border size="small">
|
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
|
||||||
<template v-if="metricsData.network">
|
<div ref="netChartRef" class="chart-container"></div>
|
||||||
<el-descriptions-item label="接收">{{ formatBytesRaw(metricsData.network.rx_bytes) }}</el-descriptions-item>
|
</el-card>
|
||||||
<el-descriptions-item label="发送">{{ formatBytesRaw(metricsData.network.tx_bytes) }}</el-descriptions-item>
|
</el-col>
|
||||||
</template>
|
</el-row>
|
||||||
<template v-if="metricsData.internet_speed">
|
</template>
|
||||||
<el-descriptions-item label="实时接收">{{ formatBytesRaw(metricsData.internet_speed.rx_bytes) }}/s</el-descriptions-item>
|
<el-empty v-else description="加载指标数据中..." />
|
||||||
<el-descriptions-item label="实时发送">{{ formatBytesRaw(metricsData.internet_speed.tx_bytes) }}/s</el-descriptions-item>
|
</div>
|
||||||
</template>
|
</el-tab-pane>
|
||||||
</el-descriptions>
|
|
||||||
</el-card>
|
<el-tab-pane label="镜像管理" name="image">
|
||||||
</el-col>
|
<ImageManage v-if="hostTabLoaded['image']" />
|
||||||
</el-row>
|
</el-tab-pane>
|
||||||
</template>
|
<el-tab-pane label="网络管理" name="network">
|
||||||
<el-empty v-else description="暂无指标数据,点击刷新加载" />
|
<NetworkManage v-if="hostTabLoaded['network']" />
|
||||||
</div>
|
</el-tab-pane>
|
||||||
</el-card>
|
<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>
|
</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 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="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="服务地址" 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-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-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-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-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-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>
|
||||||
<el-row :gutter="16">
|
<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.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
|
||||||
@@ -166,15 +239,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Box, Connection } from '@element-plus/icons-vue'
|
import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Box, Connection } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost
|
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
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 { useTagsViewStore } from '@/store/tagsViewStore'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -184,10 +263,26 @@ const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
|||||||
const serviceName = computed(() => route.query.service_name || '')
|
const serviceName = computed(() => route.query.service_name || '')
|
||||||
const hostId = computed(() => parseInt(route.query.id) || 0)
|
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 loading = ref(false)
|
||||||
const submitLoading = ref(false)
|
const submitLoading = ref(false)
|
||||||
const metricsLoading = ref(false)
|
const metricsLoading = ref(false)
|
||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
|
const showToken = ref(false)
|
||||||
|
const showPassword = ref(false)
|
||||||
const metricsData = ref(null)
|
const metricsData = ref(null)
|
||||||
const editDialogVisible = ref(false)
|
const editDialogVisible = ref(false)
|
||||||
const showGroupSelector = 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') }
|
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
|
||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
const formatMemMB = (v) => { if (!v) return '-'; return v >= 1024 ? (v / 1024).toFixed(1) + ' GB' : v + ' MB' }
|
const formatMemKB = (v) => {
|
||||||
const formatDiskGB = (v) => { if (!v) return '-'; return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB' }
|
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) => {
|
const formatBytesRaw = (val) => {
|
||||||
if (!val && val !== 0) return '-'; val = Number(val)
|
if (!val && val !== 0) return '-'; val = Number(val)
|
||||||
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
||||||
@@ -228,19 +357,143 @@ const loadDetail = async () => {
|
|||||||
const body = res?.data
|
const body = res?.data
|
||||||
if (body?.code === 200 && body?.data) {
|
if (body?.code === 200 && body?.data) {
|
||||||
detail.value = body.data.host ?? body.data.data ?? body.data
|
detail.value = body.data.host ?? body.data.data ?? body.data
|
||||||
} else { ElMessage.error(body?.message || '加载失败') }
|
} else { ElMessage.error(extractApiError(body, '加载失败')) }
|
||||||
} catch (e) { ElMessage.error('加载失败') } finally { loading.value = false }
|
} 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 () => {
|
const loadMetrics = async () => {
|
||||||
|
if (!serviceId.value || !hostId.value || !isPageActive) return
|
||||||
metricsLoading.value = true
|
metricsLoading.value = true
|
||||||
metricsData.value = null
|
|
||||||
try {
|
try {
|
||||||
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: hostId.value })
|
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: hostId.value })
|
||||||
const body = res?.data
|
const body = res?.data
|
||||||
if (body?.code === 200 && body?.data) { metricsData.value = body.data.data ?? body.data }
|
if (body?.code === 200 && body?.data) {
|
||||||
else ElMessage.warning('暂无指标数据')
|
metricsData.value = body.data.data ?? body.data
|
||||||
} catch { ElMessage.error('获取指标失败') } finally { metricsLoading.value = false }
|
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 = () => {
|
const handleEdit = () => {
|
||||||
@@ -269,8 +522,8 @@ const handleSubmit = () => {
|
|||||||
if (!payload.host_group_id) delete payload.host_group_id
|
if (!payload.host_group_id) delete payload.host_group_id
|
||||||
const res = await updateRemoteHost(payload)
|
const res = await updateRemoteHost(payload)
|
||||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||||
else ElMessage.error(res?.data?.message || '修改失败')
|
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||||||
} catch (e) { ElMessage.error('修改失败') } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { submitLoading.value = false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,8 +534,8 @@ const handleDelete = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await deleteRemoteHost({ service_id: serviceId.value, id: hostId.value })
|
const res = await deleteRemoteHost({ service_id: serviceId.value, id: hostId.value })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,29 +544,88 @@ const goBack = () => {
|
|||||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.host-detail-page { padding: 0; }
|
.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; }
|
.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; gap: 0; }
|
.header-left { display: flex; align-items: center; }
|
||||||
.back-btn { font-size: 14px; color: #606266; }
|
.back-btn { font-size: 14px; color: #606266; }
|
||||||
.back-btn:hover { color: #409eff; }
|
.back-btn:hover { color: #409eff; }
|
||||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
|
||||||
.header-right { display: flex; gap: 8px; }
|
.header-right { display: flex; gap: 8px; }
|
||||||
.main-content { padding: 20px; }
|
|
||||||
.info-card { margin-bottom: 20px; }
|
.main-content { padding: 20px 24px; }
|
||||||
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
|
|
||||||
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
|
.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; }
|
.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-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; }
|
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||||||
|
.chart-container { width: 100%; height: 220px; }
|
||||||
.disk-item { margin-bottom: 8px; }
|
.disk-item { margin-bottom: 8px; }
|
||||||
.disk-path { font-weight: 500; color: #409eff; font-size: 13px; margin-bottom: 4px; font-family: 'Consolas', monospace; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -3,17 +3,18 @@
|
|||||||
<!-- 顶部信息 -->
|
<!-- 顶部信息 -->
|
||||||
<div class="page-header" v-if="!embedded">
|
<div class="page-header" v-if="!embedded">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
|
||||||
<div class="header-info">
|
<div class="header-info">
|
||||||
<h3>宿主机组映射管理</h3>
|
<h3>宿主机组映射管理</h3>
|
||||||
<span class="sub-info" v-if="serviceName">所属主控服务:{{ serviceName }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<el-button type="primary" @click="handleSync" :loading="syncLoading">
|
<el-select v-model="selectedServiceId" placeholder="选择主控服务" filterable style="width: 240px" @change="handleServiceChange">
|
||||||
|
<el-option v-for="s in serviceOptions" :key="s.id" :label="`${s.name} (ID: ${s.id})`" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="handleSync" :loading="syncLoading" :disabled="!serviceId">
|
||||||
<el-icon><RefreshRight /></el-icon>从远程同步
|
<el-icon><RefreshRight /></el-icon>从远程同步
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="loadHostGroups">
|
<el-button @click="handleRefresh">
|
||||||
<el-icon><Refresh /></el-icon>刷新
|
<el-icon><Refresh /></el-icon>刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,88 +24,46 @@
|
|||||||
<el-button @click="loadHostGroups"><el-icon><Refresh /></el-icon>刷新</el-button>
|
<el-button @click="loadHostGroups"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 布局:左侧本地主机组列表 / 右侧详情&操作 -->
|
<!-- 本地主机组列表(树形折叠) -->
|
||||||
<div class="content-layout">
|
<div class="main-panel">
|
||||||
<!-- 左侧:本地主机组列表 -->
|
<div class="panel-header">
|
||||||
<div class="left-panel">
|
<h4>本地主机组列表</h4>
|
||||||
<div class="panel-header">
|
|
||||||
<h4>本地主机组列表</h4>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body" v-loading="loading">
|
|
||||||
<el-table :data="hostGroupList" stripe style="width: 100%" @row-click="handleRowClick"
|
|
||||||
:row-class-name="getRowClassName" highlight-current-row>
|
|
||||||
<el-table-column prop="id" label="ID" width="60" />
|
|
||||||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
|
||||||
<el-table-column label="远程ID" width="80">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row.remoteId || '-' }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="父级远程ID" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row.parentRemoteId || '-' }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="绑定商品组" min-width="120">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag v-if="row.goodGroupId" type="success" size="small">
|
|
||||||
商品组#{{ row.goodGroupId }}
|
|
||||||
</el-tag>
|
|
||||||
<span v-else class="text-muted">未绑定</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="绑定商品" min-width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag v-if="row.goodId" type="warning" size="small">
|
|
||||||
商品#{{ row.goodId }}
|
|
||||||
</el-tag>
|
|
||||||
<span v-else class="text-muted">未绑定</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ row.note || '-' }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="260" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button>
|
|
||||||
<el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button>
|
|
||||||
<el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button>
|
|
||||||
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-body" v-loading="loading">
|
||||||
<!-- 右侧:远程主机组树 -->
|
<el-table :data="treeGroupList" stripe style="width: 100%" row-key="_rowKey"
|
||||||
<div class="right-panel">
|
:tree-props="{ children: '_children', hasChildren: '_hasChildren' }">
|
||||||
<div class="panel-header">
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
<h4>远程主机组树</h4>
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
<el-button size="small" @click="loadRemoteTree" :loading="remoteTreeLoading">
|
<el-table-column label="远程ID" width="80">
|
||||||
<el-icon><Refresh /></el-icon>刷新
|
<template #default="{ row }">{{ row.remoteId || '-' }}</template>
|
||||||
</el-button>
|
</el-table-column>
|
||||||
</div>
|
<el-table-column label="父级远程ID" width="100">
|
||||||
<div class="panel-body" v-loading="remoteTreeLoading">
|
<template #default="{ row }">{{ row.parentRemoteId || '-' }}</template>
|
||||||
<el-tree
|
</el-table-column>
|
||||||
v-if="remoteTreeData.length > 0"
|
<el-table-column label="绑定商品组" min-width="120">
|
||||||
:data="remoteTreeData"
|
<template #default="{ row }">
|
||||||
:props="{ label: 'name', children: 'children' }"
|
<el-tag v-if="row.goodGroupId" type="success" size="small">商品组#{{ row.goodGroupId }}</el-tag>
|
||||||
node-key="id"
|
<span v-else class="text-muted">未绑定</span>
|
||||||
default-expand-all
|
|
||||||
:expand-on-click-node="false"
|
|
||||||
>
|
|
||||||
<template #default="{ node, data }">
|
|
||||||
<div class="tree-node-content">
|
|
||||||
<span class="tree-node-name">{{ data.name }}</span>
|
|
||||||
<span class="tree-node-id">ID: {{ data.id }}</span>
|
|
||||||
<span v-if="data.note" class="tree-node-note">{{ data.note }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</el-tree>
|
</el-table-column>
|
||||||
<el-empty v-else description="暂无远程主机组数据,请先同步" />
|
<el-table-column label="绑定商品" min-width="100">
|
||||||
</div>
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.goodId" type="warning" size="small">商品#{{ row.goodId }}</el-tag>
|
||||||
|
<span v-else class="text-muted">未绑定</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.note || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button>
|
||||||
|
<el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -219,8 +178,9 @@ import {
|
|||||||
updateHostGroup,
|
updateHostGroup,
|
||||||
generateGoodsByHostGroup,
|
generateGoodsByHostGroup,
|
||||||
deleteHostGroup,
|
deleteHostGroup,
|
||||||
getRemoteHostGroupTree
|
getKvmServiceList
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import ProductGroupSelector from '@/components/admin/ProductGroupSelector.vue'
|
import ProductGroupSelector from '@/components/admin/ProductGroupSelector.vue'
|
||||||
import ProductSelector from '@/components/admin/ProductSelector.vue'
|
import ProductSelector from '@/components/admin/ProductSelector.vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -230,16 +190,76 @@ const router = useRouter()
|
|||||||
const embedded = inject('embedded', false)
|
const embedded = inject('embedded', false)
|
||||||
const injectedServiceId = inject('serviceId', null)
|
const injectedServiceId = inject('serviceId', null)
|
||||||
const injectedServiceName = inject('serviceName', 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 selectedServiceId = ref(parseInt(route.query.service_id) || null)
|
||||||
|
const serviceOptions = ref([])
|
||||||
|
|
||||||
|
const serviceId = computed(() => injectedServiceId?.value || selectedServiceId.value || 0)
|
||||||
|
const serviceName = computed(() => {
|
||||||
|
if (injectedServiceName?.value) return injectedServiceName.value
|
||||||
|
const s = serviceOptions.value.find(x => x.id === selectedServiceId.value)
|
||||||
|
return s?.name || route.query.service_name || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeService = (s) => ({
|
||||||
|
id: s.Id ?? s.id,
|
||||||
|
name: s.Name ?? s.name,
|
||||||
|
host: s.Host ?? s.host,
|
||||||
|
port: s.Port ?? s.port,
|
||||||
|
note: s.Note ?? s.note
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadServiceOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getKvmServiceList({ page: 1, count: 100, key: '' })
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const inner = res.data.data
|
||||||
|
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||||
|
serviceOptions.value = raw.map(normalizeService)
|
||||||
|
}
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleServiceChange = () => {
|
||||||
|
hostGroupList.value = []
|
||||||
|
if (serviceId.value) {
|
||||||
|
loadHostGroups()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (!serviceId.value) {
|
||||||
|
ElMessage.warning('请先选择主控服务')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadHostGroups()
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const syncLoading = ref(false)
|
const syncLoading = ref(false)
|
||||||
const remoteTreeLoading = ref(false)
|
|
||||||
const hostGroupList = ref([])
|
const hostGroupList = ref([])
|
||||||
const remoteTreeData = ref([])
|
|
||||||
const selectedGroup = ref(null)
|
const selectedGroup = ref(null)
|
||||||
|
|
||||||
|
const treeGroupList = computed(() => {
|
||||||
|
const items = hostGroupList.value
|
||||||
|
if (!items.length) return []
|
||||||
|
const map = new Map()
|
||||||
|
items.forEach(item => {
|
||||||
|
map.set(item.remoteId, { ...item, _rowKey: `g-${item.id}`, _children: [], _hasChildren: false })
|
||||||
|
})
|
||||||
|
const roots = []
|
||||||
|
map.forEach(item => {
|
||||||
|
if (item.parentRemoteId && map.has(item.parentRemoteId)) {
|
||||||
|
const parent = map.get(item.parentRemoteId)
|
||||||
|
parent._children.push(item)
|
||||||
|
parent._hasChildren = true
|
||||||
|
} else {
|
||||||
|
roots.push(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return roots
|
||||||
|
})
|
||||||
|
|
||||||
// 规范化后端 PascalCase 字段为前端 camelCase
|
// 规范化后端 PascalCase 字段为前端 camelCase
|
||||||
// 同时保留原始字段以便在需要时直接访问
|
// 同时保留原始字段以便在需要时直接访问
|
||||||
const normalizeHostGroup = (item) => {
|
const normalizeHostGroup = (item) => {
|
||||||
@@ -286,27 +306,6 @@ const loadHostGroups = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 远程主机组树 ==========
|
|
||||||
const loadRemoteTree = async () => {
|
|
||||||
if (!serviceId.value) return
|
|
||||||
remoteTreeLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await getRemoteHostGroupTree({ service_id: serviceId.value })
|
|
||||||
const body = res?.data
|
|
||||||
const data = body?.data || body
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
remoteTreeData.value = data
|
|
||||||
} else {
|
|
||||||
remoteTreeData.value = []
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取远程主机组树失败:', error)
|
|
||||||
ElMessage.error('获取远程主机组树失败')
|
|
||||||
} finally {
|
|
||||||
remoteTreeLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 同步 ==========
|
// ========== 同步 ==========
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
if (!serviceId.value) {
|
if (!serviceId.value) {
|
||||||
@@ -330,24 +329,14 @@ const handleSync = async () => {
|
|||||||
ElMessage.warning(body?.message || '同步返回异常')
|
ElMessage.warning(body?.message || '同步返回异常')
|
||||||
}
|
}
|
||||||
loadHostGroups()
|
loadHostGroups()
|
||||||
loadRemoteTree()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('同步失败: ' + (error?.response?.data?.message || error.message))
|
ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
|
||||||
} finally {
|
} finally {
|
||||||
syncLoading.value = false
|
syncLoading.value = false
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 行点击选中 ==========
|
|
||||||
const handleRowClick = (row) => {
|
|
||||||
selectedGroup.value = row
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRowClassName = ({ row }) => {
|
|
||||||
return selectedGroup.value?.id === row.id ? 'selected-row' : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 编辑 ==========
|
// ========== 编辑 ==========
|
||||||
const editDialogVisible = ref(false)
|
const editDialogVisible = ref(false)
|
||||||
const editSubmitLoading = ref(false)
|
const editSubmitLoading = ref(false)
|
||||||
@@ -388,10 +377,10 @@ const submitEdit = () => {
|
|||||||
editDialogVisible.value = false
|
editDialogVisible.value = false
|
||||||
loadHostGroups()
|
loadHostGroups()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(body?.message || '修改失败')
|
ElMessage.error(extractApiError(body, '修改失败'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('修改失败: ' + (error?.response?.data?.message || error.message))
|
ElMessage.error(extractApiError(error?.response?.data, '修改失败'))
|
||||||
} finally {
|
} finally {
|
||||||
editSubmitLoading.value = false
|
editSubmitLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -465,10 +454,10 @@ const submitBind = async () => {
|
|||||||
bindDialogVisible.value = false
|
bindDialogVisible.value = false
|
||||||
loadHostGroups()
|
loadHostGroups()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(body?.message || '绑定失败')
|
ElMessage.error(extractApiError(body, '绑定失败'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('绑定失败: ' + (error?.response?.data?.message || error.message))
|
ElMessage.error(extractApiError(error?.response?.data, '绑定失败'))
|
||||||
} finally {
|
} finally {
|
||||||
bindSubmitLoading.value = false
|
bindSubmitLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -523,10 +512,10 @@ const submitGenerate = () => {
|
|||||||
generateDialogVisible.value = false
|
generateDialogVisible.value = false
|
||||||
loadHostGroups()
|
loadHostGroups()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(body?.message || '商品生成失败')
|
ElMessage.error(extractApiError(body, '商品生成失败'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('生成失败: ' + (error?.response?.data?.message || error.message))
|
ElMessage.error(extractApiError(error?.response?.data, '生成失败'))
|
||||||
} finally {
|
} finally {
|
||||||
generateSubmitLoading.value = false
|
generateSubmitLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -553,10 +542,10 @@ const handleDeleteGroup = (row) => {
|
|||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
loadHostGroups()
|
loadHostGroups()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(body?.message || '删除失败')
|
ElMessage.error(extractApiError(body, '删除失败'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('删除失败: ' + (error?.response?.data?.message || error.message))
|
ElMessage.error(extractApiError(error?.response?.data, '删除失败'))
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -567,9 +556,9 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (!embedded) loadServiceOptions()
|
||||||
if (serviceId.value) {
|
if (serviceId.value) {
|
||||||
loadHostGroups()
|
loadHostGroups()
|
||||||
loadRemoteTree()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -617,21 +606,7 @@ onMounted(() => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 布局 */
|
.main-panel {
|
||||||
.content-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 360px;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.content-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-panel,
|
|
||||||
.right-panel {
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
@@ -658,41 +633,6 @@ onMounted(() => {
|
|||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表格行选中高亮 */
|
|
||||||
:deep(.el-table .selected-row) {
|
|
||||||
background-color: #ecf5ff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 远程树节点 */
|
|
||||||
.tree-node-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node-id {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #909399;
|
|
||||||
background: #f0f2f5;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node-note {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #b0b3b8;
|
|
||||||
max-width: 120px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: #c0c4cc;
|
color: #c0c4cc;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="resource-info">
|
<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_cpu">CPU: {{ row.max_cpu }}核</el-tag>
|
||||||
<el-tag size="small" type="info" v-if="row.max_memory">内存: {{ formatMemMB(row.max_memory) }}</el-tag>
|
<el-tag size="small" type="info" v-if="row.max_memory">内存: {{ formatMemKB(row.max_memory) }}</el-tag>
|
||||||
<el-tag size="small" type="info" v-if="row.max_disk">磁盘: {{ formatDiskGB(row.max_disk) }}</el-tag>
|
<el-tag size="small" type="info" v-if="row.max_disk">磁盘: {{ formatDiskGB(row.max_disk) }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 新建/编辑弹窗 -->
|
<!-- 新建/编辑弹窗 -->
|
||||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="640px" destroy-on-close>
|
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close>
|
||||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
|
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
|
||||||
<el-form-item label="名称" prop="name">
|
<el-form-item label="名称" prop="name">
|
||||||
<el-input v-model="formData.name" placeholder="宿主机名称" />
|
<el-input v-model="formData.name" placeholder="宿主机名称" />
|
||||||
@@ -128,13 +128,23 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="最大内存(MB)">
|
<el-form-item label="最大内存">
|
||||||
<el-input-number v-model="formData.max_memory" :min="0" controls-position="right" style="width: 100%" />
|
<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-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="最大磁盘(GB)">
|
<el-form-item label="最大磁盘">
|
||||||
<el-input-number v-model="formData.max_disk" :min="0" controls-position="right" style="width: 100%" />
|
<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-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -198,7 +208,7 @@
|
|||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="私钥路径">{{ currentDetail.private_key_path || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="私钥路径">{{ currentDetail.private_key_path || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="最大CPU">{{ currentDetail.max_cpu ? currentDetail.max_cpu + ' 核' : '-' }}</el-descriptions-item>
|
<el-descriptions-item label="最大CPU">{{ currentDetail.max_cpu ? currentDetail.max_cpu + ' 核' : '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="最大内存">{{ formatMemMB(currentDetail.max_memory) }}</el-descriptions-item>
|
<el-descriptions-item label="最大内存">{{ formatMemKB(currentDetail.max_memory) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="最大磁盘">{{ formatDiskGB(currentDetail.max_disk) }}</el-descriptions-item>
|
<el-descriptions-item label="最大磁盘">{{ formatDiskGB(currentDetail.max_disk) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="带宽">↓{{ currentDetail.rx_bandwidth || 0 }} Mbps / ↑{{ currentDetail.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
<el-descriptions-item label="带宽">↓{{ currentDetail.rx_bandwidth || 0 }} Mbps / ↑{{ currentDetail.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
||||||
<el-descriptions-item label="介绍" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="介绍" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item>
|
||||||
@@ -279,6 +289,7 @@ import {
|
|||||||
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
||||||
getHostGroupList
|
getHostGroupList
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -325,20 +336,44 @@ const formRules = {
|
|||||||
ip: [{ required: true, message: '请输入 IP 地址', trigger: 'blur' }]
|
ip: [{ required: true, message: '请输入 IP 地址', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化内存(MB) */
|
const formatMemKB = (val) => {
|
||||||
const formatMemMB = (val) => {
|
if (!val) return '-'; val = Number(val)
|
||||||
if (!val) return '-'
|
if (val >= 1073741824) return (val / 1073741824).toFixed(1) + ' TB'
|
||||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' GB'
|
if (val >= 1048576) return (val / 1048576).toFixed(1) + ' GB'
|
||||||
return val + ' MB'
|
if (val >= 1024) return (val / 1024).toFixed(1) + ' MB'
|
||||||
|
return val + ' KB'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化磁盘(GB) */
|
|
||||||
const formatDiskGB = (val) => {
|
const formatDiskGB = (val) => {
|
||||||
if (!val) return '-'
|
if (!val) return '-'; val = Number(val)
|
||||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' TB'
|
if (val >= 1024) return (val / 1024).toFixed(1) + ' TB'
|
||||||
return val + ' GB'
|
return val.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) => {
|
const formatBytesRaw = (val) => {
|
||||||
if (!val && val !== 0) return '-'
|
if (!val && val !== 0) return '-'
|
||||||
@@ -481,10 +516,10 @@ const handleSubmit = () => {
|
|||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(body?.message || '操作失败')
|
ElMessage.error(extractApiError(body, '操作失败'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
ElMessage.error(extractApiError(e?.response?.data, '操作失败'))
|
||||||
} finally {
|
} finally {
|
||||||
submitLoading.value = false
|
submitLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -542,9 +577,9 @@ const handleDelete = (row) => {
|
|||||||
const res = await deleteRemoteHost({ service_id: serviceId.value, id: row.id })
|
const res = await deleteRemoteHost({ service_id: serviceId.value, id: row.id })
|
||||||
const body = res?.data
|
const body = res?.data
|
||||||
if (body?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
if (body?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||||
else ElMessage.error(body?.message || '删除失败')
|
else ElMessage.error(extractApiError(body, '删除失败'))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('删除失败: ' + (e?.response?.data?.message || e.message))
|
ElMessage.error(extractApiError(e?.response?.data, '删除失败'))
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -557,6 +592,8 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.unit-input-row { display: flex; gap: 6px; width: 100%; }
|
||||||
|
.wide-number { flex: 1; min-width: 140px; }
|
||||||
.host-manage-container { padding: 20px; }
|
.host-manage-container { padding: 20px; }
|
||||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||||
|
|||||||
@@ -0,0 +1,644 @@
|
|||||||
|
<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 @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' : 'danger'" 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="groupDialogVisible" :title="groupDialogType === 'add' ? '新建宿主机组' : '编辑宿主机组'" width="480px" destroy-on-close>
|
||||||
|
<el-form ref="groupFormRef" :model="groupForm" :rules="groupFormRules" label-width="80px">
|
||||||
|
<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>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="groupDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitLoading" @click="submitGroupForm">确定</el-button>
|
||||||
|
</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>
|
||||||
|
<el-form ref="hostFormRef" :model="hostForm" :rules="hostFormRules" label-width="120px">
|
||||||
|
<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>
|
||||||
|
<el-divider content-position="left">SSH 配置</el-divider>
|
||||||
|
<el-form-item label="SSH 端口">
|
||||||
|
<el-input-number v-model="hostForm.port" :min="0" :max="65535" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="SSH 用户名">
|
||||||
|
<el-input v-model="hostForm.user" placeholder="默认 tunneluser" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="SSH 密码">
|
||||||
|
<el-input v-model="hostForm.password" placeholder="可选" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-divider content-position="left">资源限制</el-divider>
|
||||||
|
<el-form-item label="最大CPU(核)">
|
||||||
|
<el-input-number v-model="hostForm.max_cpu" :min="0" controls-position="right" style="width: 240px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="最大内存">
|
||||||
|
<div class="unit-input-row">
|
||||||
|
<el-select v-model="memoryUnit" style="width: 70px; flex-shrink: 0;" size="default">
|
||||||
|
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||||
|
</el-select>
|
||||||
|
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="wide-number" />
|
||||||
|
</div>
|
||||||
|
</el-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="hostForm.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="hostForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<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>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="hostDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitLoading" @click="submitHostForm">确定</el-button>
|
||||||
|
</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 } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
getRemoteHostGroupList, getRemoteHostGroupTree, getRemoteHostGroupDetail,
|
||||||
|
createRemoteHostGroup, updateRemoteHostGroup, deleteRemoteHostGroup,
|
||||||
|
getOptimalHostInfo,
|
||||||
|
getRemoteHostList, getRemoteHostDetail,
|
||||||
|
addRemoteHost, updateRemoteHost, deleteRemoteHost
|
||||||
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
|
|
||||||
|
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: 500 })
|
||||||
|
])
|
||||||
|
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 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: '',
|
||||||
|
max_cpu: 0, max_memory: 0, max_disk: 0,
|
||||||
|
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: ''
|
||||||
|
})
|
||||||
|
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: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' })
|
||||||
|
hostDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddHostToGroup = (group) => {
|
||||||
|
hostDialogType.value = 'add'
|
||||||
|
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '' })
|
||||||
|
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 || '',
|
||||||
|
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 || ''
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}).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.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 goBack = () => { router.push('/virtualization/kvm-service') }
|
||||||
|
|
||||||
|
onMounted(() => { if (serviceId.value) loadTreeData() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.host-tree-container { padding: 0; }
|
||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||||
|
.sub-info { font-size: 13px; color: #909399; }
|
||||||
|
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.unit-input-row { display: flex; gap: 6px; width: 100%; }
|
||||||
|
.wide-number { flex: 1; min-width: 140px; }
|
||||||
|
.host-addr { color: #409eff; font-size: 13px; }
|
||||||
|
.host-url { color: #909399; font-size: 12px; }
|
||||||
|
.resource-info { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.text-muted { color: #c0c4cc; font-size: 12px; }
|
||||||
|
</style>
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<el-button type="primary" plain :icon="Edit" @click="handleEdit">编辑</el-button>
|
<el-button type="primary" plain :icon="Edit" @click="handleEdit">编辑</el-button>
|
||||||
<el-button type="success" plain @click="handleSyncToHost">同步到宿主机</el-button>
|
<el-button type="success" plain @click="handleSyncToHost">同步到宿主机</el-button>
|
||||||
<el-button type="warning" plain @click="handleReloadOnHost">重下载</el-button>
|
<el-button type="warning" plain @click="handleReloadMaster">主控端重新下载</el-button>
|
||||||
|
<el-button type="warning" plain @click="handleReloadOnHost">指定宿主机重新下载</el-button>
|
||||||
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
||||||
<el-button type="danger" plain :icon="Delete" @click="handleDelete">删除</el-button>
|
<el-button type="danger" plain :icon="Delete" @click="handleDelete">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
<el-table :data="hostStatusList" size="small" stripe border v-loading="statusLoading">
|
<el-table :data="hostStatusList" size="small" stripe border v-loading="statusLoading">
|
||||||
<el-table-column prop="host_id" label="宿主机ID" width="100" />
|
<el-table-column prop="host_id" label="宿主机ID" width="100" />
|
||||||
<el-table-column label="宿主机" min-width="120">
|
<el-table-column label="宿主机" min-width="120">
|
||||||
<template #default="{ row }">{{ getHostName(row.host_id) }}</template>
|
<template #default="{ row }">{{ row.host_name || getHostName(row.host_id) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="100">
|
<el-table-column label="状态" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -127,15 +128,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
getImageDetail, getImageHostStatus, updateImage, deleteImage,
|
getImageDetail, getImageHostStatus, updateImage, deleteImage,
|
||||||
syncImageToHost, reloadImageOnHost, getRemoteHostList
|
syncImageToHost, reloadImage, reloadImageOnHost, getRemoteHostList
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -165,8 +167,8 @@ const formRules = {
|
|||||||
path: [{ required: true, message: '请输入路径', trigger: 'blur' }]
|
path: [{ required: true, message: '请输入路径', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusType = (s) => ({ ready: 'success', downloading: 'warning', pending: 'info', error: 'danger' }[s] || 'info')
|
const statusType = (s) => ({ ready: 'success', success: 'success', downloading: 'warning', pending: 'info', error: 'danger', failed: 'danger', not_found: 'warning' }[s] || 'info')
|
||||||
const statusLabel = (s) => ({ ready: '就绪', downloading: '下载中', pending: '等待中', error: '错误' }[s] || s || '-')
|
const statusLabel = (s) => ({ ready: '就绪', success: '已同步', downloading: '下载中', pending: '等待中', error: '错误', failed: '失败', not_found: '未同步' }[s] || s || '-')
|
||||||
const formatSize = (bytes) => {
|
const formatSize = (bytes) => {
|
||||||
if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let size = Number(bytes)
|
if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let size = Number(bytes)
|
||||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
|
||||||
@@ -197,18 +199,34 @@ const loadDetail = async () => {
|
|||||||
const res = await getImageDetail({ service_id: serviceId.value, image_id: imageId.value })
|
const res = await getImageDetail({ service_id: serviceId.value, image_id: imageId.value })
|
||||||
if (res?.data?.code === 200 && res?.data?.data) {
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
detail.value = res.data.data.image ?? res.data.data.data ?? res.data.data
|
detail.value = res.data.data.image ?? res.data.data.data ?? res.data.data
|
||||||
} else ElMessage.error(res?.data?.message || '加载失败')
|
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
|
||||||
} catch { ElMessage.error('加载失败') } finally { loading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadHostStatus = async () => {
|
const loadHostStatus = async () => {
|
||||||
|
if (!hostOptions.value.length) await loadHostOptions()
|
||||||
|
if (!hostOptions.value.length) return
|
||||||
statusLoading.value = true
|
statusLoading.value = true
|
||||||
|
hostStatusList.value = []
|
||||||
try {
|
try {
|
||||||
const res = await getImageHostStatus({ service_id: serviceId.value, image_id: imageId.value })
|
const results = await Promise.allSettled(
|
||||||
if (res?.data?.code === 200 && res?.data?.data) {
|
hostOptions.value.map(h =>
|
||||||
const inner = res.data.data
|
getImageHostStatus({ service_id: serviceId.value, image_id: imageId.value, host_id: h.id })
|
||||||
hostStatusList.value = Array.isArray(inner) ? inner : (inner.hosts || inner.data || [])
|
.then(res => {
|
||||||
}
|
const body = res?.data
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const d = body.data
|
||||||
|
const img = d.image || {}
|
||||||
|
return { host_id: h.id, host_name: h.name || h.ip, status: d.status || 'not_found', path: img.path || '', image_name: img.name || '', image_status: img.status || '' }
|
||||||
|
}
|
||||||
|
return { host_id: h.id, host_name: h.name || h.ip, status: 'not_found' }
|
||||||
|
})
|
||||||
|
.catch(() => ({ host_id: h.id, host_name: h.name || h.ip, status: 'error' }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
hostStatusList.value = results
|
||||||
|
.filter(r => r.status === 'fulfilled' && r.value)
|
||||||
|
.map(r => r.value)
|
||||||
} catch { /* */ } finally { statusLoading.value = false }
|
} catch { /* */ } finally { statusLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,14 +246,30 @@ const handleSubmitEdit = () => {
|
|||||||
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
|
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
|
||||||
const res = await updateImage(payload)
|
const res = await updateImage(payload)
|
||||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||||
else ElMessage.error(res?.data?.message || '修改失败')
|
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||||||
} catch { ElMessage.error('修改失败') } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { submitLoading.value = false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSyncToHost = () => { syncHostId.value = ''; syncDialogVisible.value = true }
|
const handleSyncToHost = () => { syncHostId.value = ''; syncDialogVisible.value = true }
|
||||||
const handleReloadOnHost = () => { reloadHostId.value = ''; reloadDialogVisible.value = true }
|
const handleReloadOnHost = () => { reloadHostId.value = ''; reloadDialogVisible.value = true }
|
||||||
|
|
||||||
|
const handleReloadMaster = () => {
|
||||||
|
ElMessageBox.confirm(`确定要在主控端重新下载镜像「${detail.value?.name}」吗?`, '重新下载确认', {
|
||||||
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('image_id', imageId.value)
|
||||||
|
const res = await reloadImage(fd)
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('已触发主控端重新下载'); loadDetail() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const submitSync = async () => {
|
const submitSync = async () => {
|
||||||
if (!syncHostId.value) return ElMessage.warning('请选择宿主机')
|
if (!syncHostId.value) return ElMessage.warning('请选择宿主机')
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
@@ -243,8 +277,8 @@ const submitSync = async () => {
|
|||||||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('image_id', imageId.value); fd.append('host_id', syncHostId.value)
|
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('image_id', imageId.value); fd.append('host_id', syncHostId.value)
|
||||||
const res = await syncImageToHost(fd)
|
const res = await syncImageToHost(fd)
|
||||||
if (res?.data?.code === 200) { ElMessage.success('已触发同步'); syncDialogVisible.value = false; loadHostStatus() }
|
if (res?.data?.code === 200) { ElMessage.success('已触发同步'); syncDialogVisible.value = false; loadHostStatus() }
|
||||||
else ElMessage.error(res?.data?.message || '同步失败')
|
else ElMessage.error(extractApiError(res?.data, '同步失败'))
|
||||||
} catch { ElMessage.error('同步失败') } finally { actionLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '同步失败')) } finally { actionLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitReload = async () => {
|
const submitReload = async () => {
|
||||||
@@ -254,8 +288,8 @@ const submitReload = async () => {
|
|||||||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('image_id', imageId.value); fd.append('host_id', reloadHostId.value)
|
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('image_id', imageId.value); fd.append('host_id', reloadHostId.value)
|
||||||
const res = await reloadImageOnHost(fd)
|
const res = await reloadImageOnHost(fd)
|
||||||
if (res?.data?.code === 200) { ElMessage.success('已触发重下载'); reloadDialogVisible.value = false; loadHostStatus() }
|
if (res?.data?.code === 200) { ElMessage.success('已触发重下载'); reloadDialogVisible.value = false; loadHostStatus() }
|
||||||
else ElMessage.error(res?.data?.message || '操作失败')
|
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
} catch { ElMessage.error('操作失败') } finally { actionLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
@@ -265,8 +299,8 @@ const handleDelete = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await deleteImage({ service_id: serviceId.value, image_id: imageId.value })
|
const res = await deleteImage({ service_id: serviceId.value, image_id: imageId.value })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +309,21 @@ const goBack = () => {
|
|||||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadHostOptions(); loadDetail(); loadHostStatus() })
|
let loadedImageId = null
|
||||||
|
let isPageActive = false
|
||||||
|
|
||||||
|
const initPage = async () => {
|
||||||
|
if (!imageId.value || loadedImageId === imageId.value) return
|
||||||
|
loadedImageId = imageId.value
|
||||||
|
loadDetail()
|
||||||
|
await loadHostOptions()
|
||||||
|
loadHostStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(imageId, () => { if (isPageActive) initPage() })
|
||||||
|
onActivated(() => { isPageActive = true; if (loadedImageId !== imageId.value) initPage() })
|
||||||
|
onDeactivated(() => { isPageActive = false })
|
||||||
|
onMounted(() => { isPageActive = true; initPage() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -65,9 +65,12 @@
|
|||||||
<el-table-column label="大小" width="90">
|
<el-table-column label="大小" width="90">
|
||||||
<template #default="{ row }">{{ row.size ? formatSize(row.size) : '-' }}</template>
|
<template #default="{ row }">{{ row.size ? formatSize(row.size) : '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="120" fixed="right">
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="handleGoDetail(row)">编辑</el-button>
|
<el-button link type="primary" @click="handleGoDetail(row)">详情</el-button>
|
||||||
|
<el-button link type="success" @click="handleSyncToHost(row)">同步</el-button>
|
||||||
|
<el-button link type="warning" @click="handleReloadMaster(row)">主控重下载</el-button>
|
||||||
|
<el-button link type="warning" @click="handleReloadOnHost(row)">宿主机重下载</el-button>
|
||||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -218,6 +221,7 @@ import {
|
|||||||
getImageList, getImageDetail, getImageHostStatus, createImage, updateImage, deleteImage,
|
getImageList, getImageDetail, getImageHostStatus, createImage, updateImage, deleteImage,
|
||||||
reloadImage, syncImageToHost, reloadImageOnHost, getRemoteHostList
|
reloadImage, syncImageToHost, reloadImageOnHost, getRemoteHostList
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -269,8 +273,8 @@ const formRules = {
|
|||||||
path: [{ required: true, message: '请输入镜像路径', trigger: 'blur' }]
|
path: [{ required: true, message: '请输入镜像路径', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusType = (s) => ({ ready: 'success', downloading: 'warning', pending: 'info', error: 'danger' }[s] || 'info')
|
const statusType = (s) => ({ ready: 'success', success: 'success', downloading: 'warning', pending: 'info', error: 'danger', failed: 'danger', not_found: 'warning' }[s] || 'info')
|
||||||
const statusLabel = (s) => ({ ready: '就绪', downloading: '下载中', pending: '等待中', error: '错误' }[s] || s || '-')
|
const statusLabel = (s) => ({ ready: '就绪', success: '已同步', downloading: '下载中', pending: '等待中', error: '错误', failed: '失败', not_found: '未同步' }[s] || s || '-')
|
||||||
const formatSize = (bytes) => {
|
const formatSize = (bytes) => {
|
||||||
if (!bytes) return '0 B'
|
if (!bytes) return '0 B'
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
@@ -298,6 +302,7 @@ const getHostName = (hid) => {
|
|||||||
return h ? `${h.name}` : `#${hid}`
|
return h ? `${h.name}` : `#${hid}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 加载宿主机列表
|
// 加载宿主机列表
|
||||||
const loadHostOptions = async () => {
|
const loadHostOptions = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -386,7 +391,7 @@ const handleSubmit = () => {
|
|||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res?.data?.message || '操作失败')
|
ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
||||||
@@ -396,30 +401,45 @@ const handleSubmit = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchHostStatusList = async (imageId) => {
|
||||||
|
if (!hostOptions.value.length) await loadHostOptions()
|
||||||
|
if (!hostOptions.value.length) return []
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
hostOptions.value.map(h =>
|
||||||
|
getImageHostStatus({ service_id: serviceId.value, image_id: imageId, host_id: h.id })
|
||||||
|
.then(res => {
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const d = body.data
|
||||||
|
const img = d.image || {}
|
||||||
|
return { host_id: h.id, host_name: h.name || h.ip, status: d.status || 'not_found', path: img.path || '', image_name: img.name || '', image_status: img.status || '' }
|
||||||
|
}
|
||||||
|
return { host_id: h.id, host_name: h.name || h.ip, status: 'not_found' }
|
||||||
|
})
|
||||||
|
.catch(() => ({ host_id: h.id, host_name: h.name || h.ip, status: 'error' }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results.filter(r => r.status === 'fulfilled' && r.value).map(r => r.value)
|
||||||
|
}
|
||||||
|
|
||||||
const handleViewDetail = async (row) => {
|
const handleViewDetail = async (row) => {
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
currentDetail.value = row
|
currentDetail.value = row
|
||||||
hostStatusList.value = []
|
hostStatusList.value = []
|
||||||
try {
|
try {
|
||||||
const [detailRes, statusRes] = await Promise.allSettled([
|
const [detailRes, statusList] = await Promise.allSettled([
|
||||||
getImageDetail({ service_id: serviceId.value, image_id: row.id }),
|
getImageDetail({ service_id: serviceId.value, image_id: row.id }),
|
||||||
getImageHostStatus({ service_id: serviceId.value, image_id: row.id })
|
fetchHostStatusList(row.id)
|
||||||
])
|
])
|
||||||
// 详情 - API 返回 data.image 嵌套
|
|
||||||
if (detailRes.status === 'fulfilled') {
|
if (detailRes.status === 'fulfilled') {
|
||||||
const body = detailRes.value?.data
|
const body = detailRes.value?.data
|
||||||
if (body?.code === 200 && body?.data) {
|
if (body?.code === 200 && body?.data) {
|
||||||
currentDetail.value = body.data.image ?? body.data.data ?? body.data
|
currentDetail.value = body.data.image ?? body.data.data ?? body.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 宿主机同步状态
|
if (statusList.status === 'fulfilled') {
|
||||||
if (statusRes.status === 'fulfilled') {
|
hostStatusList.value = statusList.value || []
|
||||||
const body = statusRes.value?.data
|
|
||||||
if (body?.code === 200 && body?.data) {
|
|
||||||
const inner = body.data
|
|
||||||
hostStatusList.value = Array.isArray(inner) ? inner : (inner.hosts || inner.data || inner.list || [])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取镜像详情失败:', e)
|
console.error('获取镜像详情失败:', e)
|
||||||
@@ -449,7 +469,7 @@ const submitSyncToHost = async () => {
|
|||||||
syncDialogVisible.value = false
|
syncDialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res?.data?.message || '同步失败')
|
ElMessage.error(extractApiError(res?.data, '同步失败'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('同步失败: ' + (e?.response?.data?.message || e.message))
|
ElMessage.error('同步失败: ' + (e?.response?.data?.message || e.message))
|
||||||
@@ -458,7 +478,21 @@ const submitSyncToHost = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重下载镜像到宿主机
|
const handleReloadMaster = (row) => {
|
||||||
|
ElMessageBox.confirm(`确定要在主控端重新下载镜像「${row.name}」吗?`, '重新下载确认', {
|
||||||
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('image_id', row.id)
|
||||||
|
const res = await reloadImage(fd)
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('已触发主控端重新下载'); loadList() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const handleReloadOnHost = (row) => {
|
const handleReloadOnHost = (row) => {
|
||||||
reloadTarget.value = row
|
reloadTarget.value = row
|
||||||
reloadHostId.value = ''
|
reloadHostId.value = ''
|
||||||
@@ -479,7 +513,7 @@ const submitReloadOnHost = async () => {
|
|||||||
reloadDialogVisible.value = false
|
reloadDialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res?.data?.message || '操作失败')
|
ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
||||||
@@ -499,8 +533,8 @@ const handleDelete = (row) => {
|
|||||||
try {
|
try {
|
||||||
const res = await deleteImage({ service_id: serviceId.value, image_id: row.id })
|
const res = await deleteImage({ service_id: serviceId.value, image_id: row.id })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch (e) { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ import {
|
|||||||
updateKvmService,
|
updateKvmService,
|
||||||
deleteKvmService
|
deleteKvmService
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -293,11 +294,11 @@ const handleSubmit = () => {
|
|||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(body?.message || '操作失败')
|
ElMessage.error(extractApiError(body, '操作失败'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('操作失败:', error)
|
console.error('操作失败:', error)
|
||||||
ElMessage.error('操作失败: ' + (error?.response?.data?.message || error.message))
|
ElMessage.error(extractApiError(error?.response?.data, '操作失败'))
|
||||||
} finally {
|
} finally {
|
||||||
submitLoading.value = false
|
submitLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -323,10 +324,10 @@ const handleDelete = (row) => {
|
|||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
loadList()
|
loadList()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(body?.message || '删除失败')
|
ElMessage.error(extractApiError(body, '删除失败'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('删除失败: ' + (error?.response?.data?.message || error.message))
|
ElMessage.error(extractApiError(error?.response?.data, '删除失败'))
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,14 +69,8 @@
|
|||||||
<!-- 子模块Tab -->
|
<!-- 子模块Tab -->
|
||||||
<el-card class="tabs-card" shadow="never">
|
<el-card class="tabs-card" shadow="never">
|
||||||
<el-tabs v-model="activeTab" @tab-click="handleTabClick" class="custom-tabs">
|
<el-tabs v-model="activeTab" @tab-click="handleTabClick" class="custom-tabs">
|
||||||
<el-tab-pane label="宿主机组映射" name="host-group">
|
|
||||||
<HostGroupMapping v-if="tabLoaded['host-group']" />
|
|
||||||
</el-tab-pane>
|
|
||||||
<el-tab-pane label="远程宿主机组" name="remote-host-group">
|
|
||||||
<RemoteHostGroupManage v-if="tabLoaded['remote-host-group']" />
|
|
||||||
</el-tab-pane>
|
|
||||||
<el-tab-pane label="宿主机管理" name="host">
|
<el-tab-pane label="宿主机管理" name="host">
|
||||||
<HostManage v-if="tabLoaded['host']" />
|
<HostTreeManage v-if="tabLoaded['host']" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="镜像管理" name="image">
|
<el-tab-pane label="镜像管理" name="image">
|
||||||
<ImageManage v-if="tabLoaded['image']" />
|
<ImageManage v-if="tabLoaded['image']" />
|
||||||
@@ -96,6 +90,12 @@
|
|||||||
<el-tab-pane label="VNC节点" name="vnc">
|
<el-tab-pane label="VNC节点" name="vnc">
|
||||||
<VncNodeManage v-if="tabLoaded['vnc']" />
|
<VncNodeManage v-if="tabLoaded['vnc']" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="快照管理" name="snapshot">
|
||||||
|
<SnapshotManage v-if="tabLoaded['snapshot']" />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="备份管理" name="backup">
|
||||||
|
<BackupManage v-if="tabLoaded['backup']" />
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,26 +128,27 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, provide, onMounted } from 'vue'
|
import { ref, reactive, computed, provide, onMounted, onActivated, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { ArrowLeft, Refresh, Edit, Delete, Monitor } from '@element-plus/icons-vue'
|
import { ArrowLeft, Refresh, Edit, Delete, Monitor } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
getKvmServiceDetail, updateKvmService, deleteKvmService
|
getKvmServiceDetail, updateKvmService, deleteKvmService
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
// 子模块组件(懒加载)
|
// 子模块组件(懒加载)
|
||||||
import { defineAsyncComponent } from 'vue'
|
import { defineAsyncComponent } from 'vue'
|
||||||
const HostGroupMapping = defineAsyncComponent(() => import('./HostGroupMapping.vue'))
|
const HostTreeManage = defineAsyncComponent(() => import('./HostTreeManage.vue'))
|
||||||
const HostManage = defineAsyncComponent(() => import('./HostManage.vue'))
|
|
||||||
const ImageManage = defineAsyncComponent(() => import('./ImageManage.vue'))
|
const ImageManage = defineAsyncComponent(() => import('./ImageManage.vue'))
|
||||||
const NetworkManage = defineAsyncComponent(() => import('./NetworkManage.vue'))
|
const NetworkManage = defineAsyncComponent(() => import('./NetworkManage.vue'))
|
||||||
const VolumeManage = defineAsyncComponent(() => import('./VolumeManage.vue'))
|
const VolumeManage = defineAsyncComponent(() => import('./VolumeManage.vue'))
|
||||||
const VmManage = defineAsyncComponent(() => import('./VmManage.vue'))
|
const VmManage = defineAsyncComponent(() => import('./VmManage.vue'))
|
||||||
const SecurityGroupManage = defineAsyncComponent(() => import('./SecurityGroupManage.vue'))
|
const SecurityGroupManage = defineAsyncComponent(() => import('./SecurityGroupManage.vue'))
|
||||||
const VncNodeManage = defineAsyncComponent(() => import('./VncNodeManage.vue'))
|
const VncNodeManage = defineAsyncComponent(() => import('./VncNodeManage.vue'))
|
||||||
const RemoteHostGroupManage = defineAsyncComponent(() => import('./RemoteHostGroupManage.vue'))
|
const SnapshotManage = defineAsyncComponent(() => import('./SnapshotManage.vue'))
|
||||||
|
const BackupManage = defineAsyncComponent(() => import('./BackupManage.vue'))
|
||||||
|
|
||||||
// 引入tagsViewStore
|
// 引入tagsViewStore
|
||||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||||
@@ -169,11 +170,9 @@ const submitLoading = ref(false)
|
|||||||
const serviceInfo = ref({})
|
const serviceInfo = ref({})
|
||||||
|
|
||||||
// Tab 管理
|
// Tab 管理
|
||||||
const activeTab = ref('host-group')
|
const activeTab = ref('host')
|
||||||
const tabLoaded = reactive({
|
const tabLoaded = reactive({
|
||||||
'host-group': true, // 默认加载第一个
|
'host': true,
|
||||||
'remote-host-group': false,
|
|
||||||
'host': false,
|
|
||||||
'image': false,
|
'image': false,
|
||||||
'network': false,
|
'network': false,
|
||||||
'volume': false,
|
'volume': false,
|
||||||
@@ -182,6 +181,7 @@ const tabLoaded = reactive({
|
|||||||
'vnc': false
|
'vnc': false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const handleTabClick = (tab) => {
|
const handleTabClick = (tab) => {
|
||||||
const name = tab.props.name
|
const name = tab.props.name
|
||||||
if (!tabLoaded[name]) {
|
if (!tabLoaded[name]) {
|
||||||
@@ -190,6 +190,7 @@ const handleTabClick = (tab) => {
|
|||||||
localStorage.setItem('kvmDetailActiveTab', name)
|
localStorage.setItem('kvmDetailActiveTab', name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
const formatTime = (t) => {
|
const formatTime = (t) => {
|
||||||
if (!t) return '-'
|
if (!t) return '-'
|
||||||
@@ -210,7 +211,7 @@ const fetchServiceInfo = async () => {
|
|||||||
if (body?.code === 200 && body?.data) {
|
if (body?.code === 200 && body?.data) {
|
||||||
serviceInfo.value = body.data
|
serviceInfo.value = body.data
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(body?.message || '获取服务详情失败')
|
ElMessage.error(extractApiError(body, '获取服务详情失败'))
|
||||||
// 使用 query 参数中的基本信息做兜底
|
// 使用 query 参数中的基本信息做兜底
|
||||||
serviceInfo.value = { Id: serviceId.value, Name: serviceName.value }
|
serviceInfo.value = { Id: serviceId.value, Name: serviceName.value }
|
||||||
}
|
}
|
||||||
@@ -269,10 +270,10 @@ const handleSubmitEdit = () => {
|
|||||||
editDialogVisible.value = false
|
editDialogVisible.value = false
|
||||||
fetchServiceInfo()
|
fetchServiceInfo()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(body?.message || '更新失败')
|
ElMessage.error(extractApiError(body, '更新失败'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('更新失败: ' + (e?.response?.data?.message || e.message))
|
ElMessage.error(extractApiError(e?.response?.data, '更新失败'))
|
||||||
} finally { submitLoading.value = false }
|
} finally { submitLoading.value = false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -291,22 +292,46 @@ const handleDeleteService = () => {
|
|||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
goBack()
|
goBack()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(body?.message || '删除失败')
|
ElMessage.error(extractApiError(body, '删除失败'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('删除失败')
|
ElMessage.error(extractApiError(e?.response?.data, '删除失败'))
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
// 记录当前已加载的 serviceId,用于判断是否需要刷新
|
||||||
fetchServiceInfo()
|
let loadedServiceId = null
|
||||||
// 恢复上次选中的 Tab
|
|
||||||
|
const initPage = () => {
|
||||||
|
if (!serviceId.value || loadedServiceId === serviceId.value) return
|
||||||
|
loadedServiceId = serviceId.value
|
||||||
|
|
||||||
|
// 重置所有 tab 加载状态,使子组件在切换后重新拉取数据
|
||||||
|
Object.keys(tabLoaded).forEach(k => { tabLoaded[k] = false })
|
||||||
const savedTab = localStorage.getItem('kvmDetailActiveTab')
|
const savedTab = localStorage.getItem('kvmDetailActiveTab')
|
||||||
if (savedTab && tabLoaded.hasOwnProperty(savedTab)) {
|
if (savedTab && tabLoaded.hasOwnProperty(savedTab)) {
|
||||||
activeTab.value = savedTab
|
activeTab.value = savedTab
|
||||||
tabLoaded[savedTab] = true
|
} else {
|
||||||
|
activeTab.value = 'host'
|
||||||
}
|
}
|
||||||
|
tabLoaded[activeTab.value] = true
|
||||||
|
|
||||||
|
fetchServiceInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(serviceId, () => {
|
||||||
|
initPage()
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
if (loadedServiceId !== serviceId.value) {
|
||||||
|
initPage()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initPage()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -502,4 +527,5 @@ onMounted(() => {
|
|||||||
.custom-tabs :deep(.vnc-node-container) {
|
.custom-tabs :deep(.vnc-node-container) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue'
|
import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue'
|
||||||
import { getRemoteHostList, getNetworkList, getNetworkDetail, createNetwork, updateNetwork, deleteNetwork } from '@/api/admin/kvmService'
|
import { getRemoteHostList, getNetworkList, getNetworkDetail, createNetwork, updateNetwork, deleteNetwork } from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -269,7 +270,7 @@ const handleSubmit = () => {
|
|||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res?.data?.message || '操作失败')
|
ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
||||||
@@ -296,8 +297,8 @@ const handleDelete = (row) => {
|
|||||||
try {
|
try {
|
||||||
const res = await deleteNetwork({ service_id: serviceId.value, network_id: row.id, host_id: row.host_id })
|
const res = await deleteNetwork({ service_id: serviceId.value, network_id: row.id, host_id: row.host_id })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch (e) { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
<el-table-column prop="note" label="备注" min-width="140" show-overflow-tooltip>
|
<el-table-column prop="note" label="备注" min-width="140" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.note || '-' }}</template>
|
<template #default="{ row }">{{ row.note || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="父级ID" width="80">
|
<el-table-column label="父级组" min-width="140" show-overflow-tooltip>
|
||||||
<template #default="{ row }">{{ row.parent_id || '-' }}</template>
|
<template #default="{ row }">{{ getParentName(row.parent_id) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="创建时间" width="170">
|
<el-table-column label="创建时间" width="170">
|
||||||
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
|
||||||
@@ -50,8 +50,17 @@
|
|||||||
<el-form-item label="备注">
|
<el-form-item label="备注">
|
||||||
<el-input v-model="formData.note" type="textarea" :rows="3" placeholder="备注(可选)" />
|
<el-input v-model="formData.note" type="textarea" :rows="3" placeholder="备注(可选)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="父级ID">
|
<el-form-item label="父级组">
|
||||||
<el-input-number v-model="formData.parent_id" :min="0" style="width: 100%" />
|
<el-select v-model="formData.parent_id" placeholder="请选择父级宿主机组" style="width: 100%" clearable @clear="formData.parent_id = 0">
|
||||||
|
<el-option :value="0" label="无(顶级分组)" />
|
||||||
|
<el-option
|
||||||
|
v-for="g in parentOptions"
|
||||||
|
:key="g.id"
|
||||||
|
:value="g.id"
|
||||||
|
:label="`${g.name}(ID: ${g.id})`"
|
||||||
|
:disabled="g.id === formData.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -61,27 +70,66 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 详情弹窗 -->
|
<!-- 详情弹窗 -->
|
||||||
<el-dialog v-model="detailVisible" title="宿主机组详情" width="520px" destroy-on-close>
|
<el-dialog v-model="detailVisible" title="宿主机组详情" width="560px" destroy-on-close>
|
||||||
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading">
|
<div v-loading="detailLoading">
|
||||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
<el-descriptions title="基本信息" :column="2" border v-if="currentDetail">
|
||||||
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="父级ID">{{ currentDetail.parent_id || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="备注">{{ currentDetail.note || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="父级ID">{{ currentDetail.parent_id || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
|
<el-descriptions-item label="备注">{{ currentDetail.note || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
|
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-descriptions title="父级宿主机组" :column="2" border v-if="parentDetail" style="margin-top: 20px;">
|
||||||
|
<el-descriptions-item label="ID">{{ parentDetail.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="名称">{{ parentDetail.name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注">{{ parentDetail.note || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatTimestamp(parentDetail.created_at) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-empty v-if="!parentDetail && currentDetail && !currentDetail.parent_id" description="无父级宿主机组" :image-size="60" style="margin-top: 12px;" />
|
||||||
|
</div>
|
||||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 最优主机弹窗 -->
|
<!-- 最优主机弹窗 -->
|
||||||
<el-dialog v-model="optimalVisible" title="最优主机配置" width="520px" destroy-on-close>
|
<el-dialog v-model="optimalVisible" title="最优主机配置" width="700px" destroy-on-close>
|
||||||
<div v-loading="optimalLoading">
|
<div v-loading="optimalLoading">
|
||||||
<el-descriptions :column="2" border v-if="optimalData">
|
<template v-if="optimalData">
|
||||||
<el-descriptions-item v-for="(val, key) in optimalData" :key="key" :label="key">
|
<el-descriptions :column="2" border>
|
||||||
{{ typeof val === 'object' ? JSON.stringify(val) : val }}
|
<el-descriptions-item label="主机ID">{{ optimalData.host_id }}</el-descriptions-item>
|
||||||
</el-descriptions-item>
|
<el-descriptions-item label="主机名称">{{ optimalData.host_name }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
<el-empty v-else description="暂无数据" />
|
<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="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.type === 'number'">
|
||||||
|
<span v-if="row.min != null">最小: {{ formatFieldValue(row.key, row.min) }}</span>
|
||||||
|
<span v-if="row.min != null && row.max != null"> / </span>
|
||||||
|
<span v-if="row.max != null">最大: {{ formatFieldValue(row.key, row.max) }}</span>
|
||||||
|
<span v-if="row.step" style="color: #909399; margin-left: 6px;">步长: {{ row.step }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-else style="color: #c0c4cc;">-</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 style="color: #c0c4cc;">否</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<el-empty v-else :description="optimalError || '暂无数据'" />
|
||||||
</div>
|
</div>
|
||||||
<template #footer><el-button @click="optimalVisible = false">关闭</el-button></template>
|
<template #footer><el-button @click="optimalVisible = false">关闭</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -97,6 +145,7 @@ import {
|
|||||||
getRemoteHostGroupList, getRemoteHostGroupDetail, createRemoteHostGroup,
|
getRemoteHostGroupList, getRemoteHostGroupDetail, createRemoteHostGroup,
|
||||||
updateRemoteHostGroup, deleteRemoteHostGroup, getOptimalHostInfo
|
updateRemoteHostGroup, deleteRemoteHostGroup, getOptimalHostInfo
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -117,12 +166,42 @@ const dialogType = ref('add')
|
|||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const currentDetail = ref(null)
|
const currentDetail = ref(null)
|
||||||
|
const parentDetail = ref(null)
|
||||||
const optimalVisible = ref(false)
|
const optimalVisible = ref(false)
|
||||||
const optimalData = ref(null)
|
const optimalData = ref(null)
|
||||||
|
const optimalError = ref('')
|
||||||
|
|
||||||
const formData = reactive({ id: undefined, name: '', note: '', parent_id: 0 })
|
const formData = reactive({ id: undefined, name: '', note: '', parent_id: 0 })
|
||||||
const formRules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }] }
|
const formRules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }] }
|
||||||
|
|
||||||
|
const parentOptions = computed(() => groupList.value.filter(g => g.id !== formData.id))
|
||||||
|
|
||||||
|
const getParentName = (parentId) => {
|
||||||
|
if (!parentId) return '-'
|
||||||
|
const found = groupList.value.find(g => g.id === parentId)
|
||||||
|
return found ? `${found.name}(ID: ${parentId})` : `ID: ${parentId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFieldValue = (key, val) => {
|
||||||
|
if (val == null) return '-'
|
||||||
|
if (key === 'memory') {
|
||||||
|
val = Number(val)
|
||||||
|
if (val >= 1099511627776) return `${(val / 1099511627776).toFixed(1)} TB`
|
||||||
|
if (val >= 1073741824) return `${(val / 1073741824).toFixed(1)} GB`
|
||||||
|
if (val >= 1048576) return `${(val / 1048576).toFixed(1)} MB`
|
||||||
|
if (val >= 1024) return `${(val / 1024).toFixed(1)} KB`
|
||||||
|
return `${val} B`
|
||||||
|
}
|
||||||
|
if (key === 'system_size') {
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
const formatTimestamp = (ts) => {
|
const formatTimestamp = (ts) => {
|
||||||
if (!ts) return '-'
|
if (!ts) return '-'
|
||||||
if (typeof ts === 'object' && ts.seconds) {
|
if (typeof ts === 'object' && ts.seconds) {
|
||||||
@@ -184,10 +263,10 @@ const handleSubmit = () => {
|
|||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res?.data?.message || '操作失败')
|
ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
ElMessage.error(extractApiError(e?.response?.data, '操作失败'))
|
||||||
} finally { submitLoading.value = false }
|
} finally { submitLoading.value = false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -196,28 +275,34 @@ const handleViewDetail = async (row) => {
|
|||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
currentDetail.value = row
|
currentDetail.value = row
|
||||||
|
parentDetail.value = null
|
||||||
try {
|
try {
|
||||||
const res = await getRemoteHostGroupDetail({ service_id: serviceId.value, id: row.id })
|
const res = await getRemoteHostGroupDetail({ service_id: serviceId.value, id: row.id })
|
||||||
if (res?.data?.code === 200 && res?.data?.data) {
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
currentDetail.value = res.data.data?.data ?? res.data.data
|
const inner = res.data.data
|
||||||
|
currentDetail.value = inner.host_group || inner
|
||||||
|
parentDetail.value = inner.parent_host_group || null
|
||||||
}
|
}
|
||||||
} catch { /* fallback */ }
|
} catch { /* fallback */ }
|
||||||
finally { detailLoading.value = false }
|
finally { detailLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const handleOptimalHost = async (row) => {
|
const handleOptimalHost = async (row) => {
|
||||||
optimalVisible.value = true
|
optimalVisible.value = true
|
||||||
optimalLoading.value = true
|
optimalLoading.value = true
|
||||||
optimalData.value = null
|
optimalData.value = null
|
||||||
|
optimalError.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await getOptimalHostInfo({ service_id: serviceId.value, host_group_id: row.id })
|
const res = await getOptimalHostInfo({ service_id: serviceId.value, host_group_id: row.id })
|
||||||
if (res?.data?.code === 200) {
|
if (res?.data?.code === 200) {
|
||||||
optimalData.value = res.data.data
|
optimalData.value = res.data.data
|
||||||
} else {
|
} else {
|
||||||
ElMessage.warning(res?.data?.message || '暂无最优主机数据')
|
optimalError.value = extractApiError(res?.data, '暂无最优主机数据')
|
||||||
}
|
}
|
||||||
} catch { ElMessage.error('获取失败') }
|
} catch {
|
||||||
finally { optimalLoading.value = false }
|
optimalError.value = '暂无最优主机数据'
|
||||||
|
} finally { optimalLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (row) => {
|
const handleDelete = (row) => {
|
||||||
@@ -227,8 +312,8 @@ const handleDelete = (row) => {
|
|||||||
try {
|
try {
|
||||||
const res = await deleteRemoteHostGroup({ service_id: serviceId.value, id: row.id })
|
const res = await deleteRemoteHostGroup({ service_id: serviceId.value, id: row.id })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch (e) { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,16 @@
|
|||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
|
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="锁定">
|
<el-descriptions-item label="白名单模式">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<el-tag :type="detail.drop_all ? 'warning' : 'info'" size="small">{{ detail.drop_all ? '开启(拦截所有未放行流量)' : '关闭' }}</el-tag>
|
||||||
|
<el-button size="small" :type="detail.drop_all ? 'info' : 'warning'" @click="handleEnableWhitelist" :disabled="detail.drop_all">开启白名单</el-button>
|
||||||
|
<el-button size="small" :type="!detail.drop_all ? 'info' : 'success'" @click="handleDisableWhitelist" :disabled="!detail.drop_all">关闭白名单</el-button>
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="锁定" v-if="detail.lock !== undefined">
|
||||||
<el-tag :type="detail.lock ? 'danger' : 'success'" size="small">{{ detail.lock ? '是' : '否' }}</el-tag>
|
<el-tag :type="detail.lock ? 'danger' : 'success'" size="small">{{ detail.lock ? '是' : '否' }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="白名单模式">
|
|
||||||
<el-tag :type="detail.drop_all ? 'warning' : 'info'" size="small">{{ detail.drop_all ? '开启' : '关闭' }}</el-tag>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="宿主机">{{ getHostLabel(detail.host_id) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="介绍">{{ detail.direction || '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</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-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
@@ -34,14 +36,16 @@
|
|||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<el-card shadow="never" class="info-card" v-if="detail">
|
<el-card shadow="never" class="info-card" v-if="detail">
|
||||||
<template #header><span class="card-title">操作</span></template>
|
<template #header><span class="card-title">操作</span></template>
|
||||||
<div class="action-buttons">
|
<div class="action-buttons" style="margin-bottom: 12px">
|
||||||
|
<el-button type="primary" plain @click="handleEdit">修改安全组</el-button>
|
||||||
<el-button type="primary" @click="handleSync">同步到宿主机</el-button>
|
<el-button type="primary" @click="handleSync">同步到宿主机</el-button>
|
||||||
<el-button type="success" @click="handleBind">绑定VM</el-button>
|
<el-button type="success" @click="handleBind">绑定VM</el-button>
|
||||||
<el-button type="warning" @click="handleUnbind">解绑VM</el-button>
|
<el-button type="warning" @click="handleUnbind">解绑VM</el-button>
|
||||||
<el-button :type="detail.drop_all ? 'info' : 'warning'" @click="handleToggleWhitelist">
|
</div>
|
||||||
{{ detail.drop_all ? '关闭白名单' : '开启白名单' }}
|
<el-divider style="margin: 8px 0" />
|
||||||
</el-button>
|
<div class="action-buttons" style="margin-bottom: 12px">
|
||||||
<el-button type="primary" @click="handleApply">应用规则</el-button>
|
<el-button type="primary" plain @click="handleSetShared(true)" v-if="!detail.shared">设为共享</el-button>
|
||||||
|
<el-button type="info" plain @click="handleSetShared(false)" v-if="detail.shared">取消共享</el-button>
|
||||||
<el-button type="danger" @click="handleDelete">删除安全组</el-button>
|
<el-button type="danger" @click="handleDelete">删除安全组</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -51,7 +55,10 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header-row">
|
<div class="card-header-row">
|
||||||
<span class="card-title">安全组规则</span>
|
<span class="card-title">安全组规则</span>
|
||||||
<el-button type="primary" size="small" @click="handleAddRule">新增规则</el-button>
|
<div style="display: flex; gap: 8px">
|
||||||
|
<el-button type="primary" size="small" @click="handleApply">应用规则</el-button>
|
||||||
|
<el-button type="primary" size="small" @click="handleAddRule">新增规则</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-table :data="detail?.rules || []" stripe size="small" style="width: 100%">
|
<el-table :data="detail?.rules || []" stripe size="small" style="width: 100%">
|
||||||
@@ -115,6 +122,31 @@
|
|||||||
|
|
||||||
<VmSelectorPopup v-model="showVmSelector" :service-id="serviceId" :current-id="bindVmId" @confirm="vm => { bindVmId = vm.id; bindVmName = vm.name || '' }" />
|
<VmSelectorPopup v-model="showVmSelector" :service-id="serviceId" :current-id="bindVmId" @confirm="vm => { bindVmId = vm.id; bindVmName = vm.name || '' }" />
|
||||||
|
|
||||||
|
<!-- 修改安全组弹窗 -->
|
||||||
|
<el-dialog v-model="editDialogVisible" title="修改安全组" width="520px" destroy-on-close>
|
||||||
|
<el-form ref="editFormRef" :model="editForm" :rules="editRules" label-width="100px">
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="editForm.name" placeholder="安全组名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="方向" prop="direction">
|
||||||
|
<el-select v-model="editForm.direction" placeholder="选择规则方向" style="width: 100%">
|
||||||
|
<el-option label="入站 (in)" value="in" />
|
||||||
|
<el-option label="出站 (out)" value="out" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="锁定">
|
||||||
|
<el-switch v-model="editForm.lock" active-text="用户不可修改" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="白名单模式">
|
||||||
|
<el-switch v-model="editForm.drop_all" active-text="开启白名单" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="actionLoading" @click="submitEdit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 规则弹窗 -->
|
<!-- 规则弹窗 -->
|
||||||
<el-dialog v-model="ruleDialogVisible" :title="ruleDialogType === 'add' ? '新增规则' : '编辑规则'" width="520px" destroy-on-close>
|
<el-dialog v-model="ruleDialogVisible" :title="ruleDialogType === 'add' ? '新增规则' : '编辑规则'" width="520px" destroy-on-close>
|
||||||
<el-form ref="ruleFormRef" :model="ruleForm" :rules="ruleRules" label-width="100px">
|
<el-form ref="ruleFormRef" :model="ruleForm" :rules="ruleRules" label-width="100px">
|
||||||
@@ -141,16 +173,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onActivated, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
getRemoteHostList, getSecurityGroupDetail,
|
getRemoteHostList, getSecurityGroupDetail, updateSecurityGroup,
|
||||||
syncSecurityGroup, bindSecurityGroup, unbindSecurityGroup,
|
syncSecurityGroup, bindSecurityGroup, unbindSecurityGroup,
|
||||||
deleteSecurityGroup, enableSecurityGroupWhitelist, disableSecurityGroupWhitelist,
|
deleteSecurityGroup, enableSecurityGroupWhitelist, disableSecurityGroupWhitelist,
|
||||||
applySecurityGroup, createSecurityGroupRule, updateSecurityGroupRule, deleteSecurityGroupRule
|
applySecurityGroup, createSecurityGroupRule, updateSecurityGroupRule, deleteSecurityGroupRule,
|
||||||
|
setSecurityGroupShared
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||||
|
|
||||||
@@ -167,9 +201,18 @@ const actionLoading = ref(false)
|
|||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
const hostOptions = ref([])
|
const hostOptions = ref([])
|
||||||
|
|
||||||
|
// 修改安全组
|
||||||
|
const editDialogVisible = ref(false)
|
||||||
|
const editFormRef = ref(null)
|
||||||
|
const editForm = reactive({ name: '', direction: '', lock: false, drop_all: false })
|
||||||
|
const editRules = {
|
||||||
|
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||||
|
direction: [{ required: true, message: '请选择方向', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
// 同步
|
// 同步
|
||||||
const syncDialogVisible = ref(false)
|
const syncDialogVisible = ref(false)
|
||||||
const syncHostId = ref(0)
|
const syncHostId = ref(null)
|
||||||
|
|
||||||
// 绑定
|
// 绑定
|
||||||
const bindDialogVisible = ref(false)
|
const bindDialogVisible = ref(false)
|
||||||
@@ -214,19 +257,45 @@ const loadDetail = async () => {
|
|||||||
if (res?.data?.code === 200 && res?.data?.data) {
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
const inner = res.data.data
|
const inner = res.data.data
|
||||||
detail.value = inner.group || inner.data || inner
|
detail.value = inner.group || inner.data || inner
|
||||||
} else ElMessage.error(res?.data?.message || '加载失败')
|
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
|
||||||
} catch { ElMessage.error('加载失败') } finally { loading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSync = () => { syncHostId.value = detail.value?.host_id || 0; syncDialogVisible.value = true }
|
const handleEdit = () => {
|
||||||
|
if (!detail.value) return
|
||||||
|
const d = detail.value
|
||||||
|
Object.assign(editForm, { name: d.name || '', direction: d.direction || '', lock: !!d.lock, drop_all: !!d.drop_all })
|
||||||
|
editDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitEdit = () => {
|
||||||
|
editFormRef.value?.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('id', sgId.value)
|
||||||
|
fd.append('name', editForm.name)
|
||||||
|
fd.append('direction', editForm.direction)
|
||||||
|
fd.append('lock', editForm.lock)
|
||||||
|
fd.append('drop_all', editForm.drop_all)
|
||||||
|
const res = await updateSecurityGroup(fd)
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSync = () => { syncHostId.value = detail.value?.host_id || null; syncDialogVisible.value = true }
|
||||||
const submitSync = async () => {
|
const submitSync = async () => {
|
||||||
if (!syncHostId.value) { ElMessage.warning('请选择宿主机'); return }
|
if (!syncHostId.value) { ElMessage.warning('请选择宿主机'); return }
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await syncSecurityGroup({ service_id: serviceId.value, id: sgId.value, host_id: syncHostId.value })
|
const res = await syncSecurityGroup({ service_id: serviceId.value, id: sgId.value, host_id: syncHostId.value })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('同步成功'); syncDialogVisible.value = false; loadDetail() }
|
if (res?.data?.code === 200) { ElMessage.success('同步成功'); syncDialogVisible.value = false; loadDetail() }
|
||||||
else ElMessage.error(res?.data?.message || '同步失败')
|
else ElMessage.error(extractApiError(res?.data, '同步失败'))
|
||||||
} catch { ElMessage.error('同步失败') } finally { actionLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '同步失败')) } finally { actionLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBind = () => { bindType.value = 'bind'; bindVmId.value = 0; bindVmName.value = ''; bindDialogVisible.value = true }
|
const handleBind = () => { bindType.value = 'bind'; bindVmId.value = 0; bindVmName.value = ''; bindDialogVisible.value = true }
|
||||||
@@ -238,19 +307,27 @@ const submitBind = async () => {
|
|||||||
const api = bindType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup
|
const api = bindType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup
|
||||||
const res = await api({ service_id: serviceId.value, id: sgId.value, vm_id: bindVmId.value })
|
const res = await api({ service_id: serviceId.value, id: sgId.value, vm_id: bindVmId.value })
|
||||||
if (res?.data?.code === 200) { ElMessage.success(bindType.value === 'bind' ? '绑定成功' : '解绑成功'); bindDialogVisible.value = false }
|
if (res?.data?.code === 200) { ElMessage.success(bindType.value === 'bind' ? '绑定成功' : '解绑成功'); bindDialogVisible.value = false }
|
||||||
else ElMessage.error(res?.data?.message || '操作失败')
|
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
} catch { ElMessage.error('操作失败') } finally { actionLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleWhitelist = () => {
|
const handleEnableWhitelist = () => {
|
||||||
const action = detail.value.drop_all ? '关闭' : '开启'
|
ElMessageBox.confirm('确定开启白名单模式?开启后将拦截所有未放行的流量。', '开启白名单', { type: 'warning' }).then(async () => {
|
||||||
ElMessageBox.confirm(`确定${action}白名单模式?`, `${action}白名单`, { type: 'warning' }).then(async () => {
|
|
||||||
try {
|
try {
|
||||||
const api = detail.value.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist
|
const res = await enableSecurityGroupWhitelist({ service_id: serviceId.value, id: sgId.value })
|
||||||
const res = await api({ service_id: serviceId.value, id: sgId.value })
|
if (res?.data?.code === 200) { ElMessage.success('开启成功'); loadDetail() }
|
||||||
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadDetail() }
|
else ElMessage.error(extractApiError(res?.data, '开启失败'))
|
||||||
else ElMessage.error(res?.data?.message || `${action}失败`)
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '开启失败')) }
|
||||||
} catch { ElMessage.error(`${action}失败`) }
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisableWhitelist = () => {
|
||||||
|
ElMessageBox.confirm('确定关闭白名单模式?', '关闭白名单', { type: 'warning' }).then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await disableSecurityGroupWhitelist({ service_id: serviceId.value, id: sgId.value })
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('关闭成功'); loadDetail() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '关闭失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '关闭失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,8 +336,8 @@ const handleApply = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await applySecurityGroup({ service_id: serviceId.value, id: sgId.value })
|
const res = await applySecurityGroup({ service_id: serviceId.value, id: sgId.value })
|
||||||
if (res?.data?.code === 200) ElMessage.success('应用成功')
|
if (res?.data?.code === 200) ElMessage.success('应用成功')
|
||||||
else ElMessage.error(res?.data?.message || '应用失败')
|
else ElMessage.error(extractApiError(res?.data, '应用失败'))
|
||||||
} catch { ElMessage.error('应用失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '应用失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,8 +346,26 @@ const handleDelete = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await deleteSecurityGroup({ service_id: serviceId.value, id: sgId.value })
|
const res = await deleteSecurityGroup({ service_id: serviceId.value, id: sgId.value })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetShared = (shared) => {
|
||||||
|
const label = shared ? '设为共享' : '取消共享'
|
||||||
|
ElMessageBox.confirm(`确定要${label}安全组「${detail.value?.name}」吗?`, `${label}确认`, {
|
||||||
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
|
||||||
|
}).then(async () => {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('id', sgId.value)
|
||||||
|
fd.append('shared', shared)
|
||||||
|
const res = await setSecurityGroupShared(fd)
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success(`${label}成功`); loadDetail() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, `${label}失败`))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${label}失败`)) } finally { actionLoading.value = false }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,8 +387,8 @@ const submitRule = () => {
|
|||||||
const api = ruleDialogType.value === 'add' ? createSecurityGroupRule : updateSecurityGroupRule
|
const api = ruleDialogType.value === 'add' ? createSecurityGroupRule : updateSecurityGroupRule
|
||||||
const res = await api({ service_id: serviceId.value, ...ruleForm })
|
const res = await api({ service_id: serviceId.value, ...ruleForm })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('操作成功'); ruleDialogVisible.value = false; loadDetail() }
|
if (res?.data?.code === 200) { ElMessage.success('操作成功'); ruleDialogVisible.value = false; loadDetail() }
|
||||||
else ElMessage.error(res?.data?.message || '操作失败')
|
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
} catch { ElMessage.error('操作失败') } finally { actionLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const handleDeleteRule = (rule) => {
|
const handleDeleteRule = (rule) => {
|
||||||
@@ -301,8 +396,8 @@ const handleDeleteRule = (rule) => {
|
|||||||
try {
|
try {
|
||||||
const res = await deleteSecurityGroupRule({ service_id: serviceId.value, id: rule.id })
|
const res = await deleteSecurityGroupRule({ service_id: serviceId.value, id: rule.id })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +406,17 @@ const goBack = () => {
|
|||||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadHostOptions(); loadDetail() })
|
let loadedSgId = null
|
||||||
|
const initPage = () => {
|
||||||
|
if (!sgId.value || loadedSgId === sgId.value) return
|
||||||
|
loadedSgId = sgId.value
|
||||||
|
loadHostOptions()
|
||||||
|
loadDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(sgId, () => { initPage() })
|
||||||
|
onActivated(() => { if (loadedSgId !== sgId.value) initPage() })
|
||||||
|
onMounted(() => { initPage() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -36,21 +36,22 @@
|
|||||||
<!-- 安全组列表 -->
|
<!-- 安全组列表 -->
|
||||||
<el-table :data="sgList" v-loading="loading" stripe>
|
<el-table :data="sgList" v-loading="loading" stripe>
|
||||||
<el-table-column prop="id" label="ID" width="70" />
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column label="锁定" width="80">
|
<el-table-column label="锁定" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.lock ? 'danger' : 'success'" size="small">{{ row.lock ? '是' : '否' }}</el-tag>
|
<el-tag :type="row.lock ? 'danger' : 'success'" size="small">{{ row.lock ? '是' : '否' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="白名单" width="90">
|
<el-table-column label="白名单" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
|
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="宿主机" width="140">
|
<el-table-column label="方向" width="80">
|
||||||
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.direction === 'in' ? 'success' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : row.direction === 'out' ? '出站' : (row.direction || '-') }}</el-tag>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="direction" label="介绍" min-width="200" show-overflow-tooltip />
|
|
||||||
<el-table-column label="操作" width="120" fixed="right">
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="handleGoDetail(row)">编辑</el-button>
|
<el-button link type="primary" @click="handleGoDetail(row)">编辑</el-button>
|
||||||
@@ -72,13 +73,16 @@
|
|||||||
<el-form-item label="名称" prop="name">
|
<el-form-item label="名称" prop="name">
|
||||||
<el-input v-model="createForm.name" placeholder="安全组名称" />
|
<el-input v-model="createForm.name" placeholder="安全组名称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="宿主机" prop="host_id">
|
<el-form-item label="方向" prop="direction">
|
||||||
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%">
|
<el-select v-model="createForm.direction" placeholder="选择规则方向" style="width: 100%">
|
||||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
<el-option label="入站 (in)" value="in" />
|
||||||
|
<el-option label="出站 (out)" value="out" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="介绍">
|
<el-form-item label="宿主机" prop="host_id">
|
||||||
<el-input v-model="createForm.direction" type="textarea" :rows="3" placeholder="安全组介绍(可选)" />
|
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable clearable style="width: 100%">
|
||||||
|
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="锁定">
|
<el-form-item label="锁定">
|
||||||
<el-switch v-model="createForm.lock" active-text="用户不可修改" />
|
<el-switch v-model="createForm.lock" active-text="用户不可修改" />
|
||||||
@@ -134,16 +138,17 @@
|
|||||||
<!-- 详情+规则弹窗 -->
|
<!-- 详情+规则弹窗 -->
|
||||||
<el-dialog v-model="detailVisible" title="安全组详情 & 规则" width="800px" destroy-on-close>
|
<el-dialog v-model="detailVisible" title="安全组详情 & 规则" width="800px" destroy-on-close>
|
||||||
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading" style="margin-bottom: 20px">
|
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading" style="margin-bottom: 20px">
|
||||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
<el-descriptions-item label="ID" width="100px">{{ currentDetail.id }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
<el-descriptions-item label="名称" width="100px">{{ currentDetail.name }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="锁定">
|
<el-descriptions-item label="锁定" width="100px">
|
||||||
<el-tag :type="currentDetail.lock ? 'danger' : 'success'" size="small">{{ currentDetail.lock ? '是' : '否' }}</el-tag>
|
<el-tag :type="currentDetail.lock ? 'danger' : 'success'" size="small">{{ currentDetail.lock ? '是' : '否' }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="白名单">
|
<el-descriptions-item label="白名单" width="100px">
|
||||||
<el-tag :type="currentDetail.drop_all ? 'warning' : 'info'" size="small">{{ currentDetail.drop_all ? '开启' : '关闭' }}</el-tag>
|
<el-tag :type="currentDetail.drop_all ? 'warning' : 'info'" size="small">{{ currentDetail.drop_all ? '开启' : '关闭' }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
|
<el-descriptions-item label="方向">
|
||||||
<el-descriptions-item label="介绍">{{ currentDetail.direction || '-' }}</el-descriptions-item>
|
<el-tag :type="currentDetail.direction === 'in' ? 'success' : 'warning'" size="small">{{ currentDetail.direction === 'in' ? '入站' : currentDetail.direction === 'out' ? '出站' : (currentDetail.direction || '-') }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<!-- 规则列表 -->
|
<!-- 规则列表 -->
|
||||||
@@ -223,6 +228,7 @@ import {
|
|||||||
deleteSecurityGroup, enableSecurityGroupWhitelist, disableSecurityGroupWhitelist,
|
deleteSecurityGroup, enableSecurityGroupWhitelist, disableSecurityGroupWhitelist,
|
||||||
applySecurityGroup, createSecurityGroupRule, updateSecurityGroupRule, deleteSecurityGroupRule
|
applySecurityGroup, createSecurityGroupRule, updateSecurityGroupRule, deleteSecurityGroupRule
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -263,16 +269,17 @@ const loadHostOptions = async () => {
|
|||||||
// 创建弹窗
|
// 创建弹窗
|
||||||
const createDialogVisible = ref(false)
|
const createDialogVisible = ref(false)
|
||||||
const createFormRef = ref(null)
|
const createFormRef = ref(null)
|
||||||
const createForm = reactive({ name: '', host_id: 0, direction: '', lock: false, drop_all: false })
|
const createForm = reactive({ name: '', host_id: null, direction: '', lock: false, drop_all: false })
|
||||||
const createRules = {
|
const createRules = {
|
||||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||||
|
direction: [{ required: true, message: '请选择方向', trigger: 'change' }],
|
||||||
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }]
|
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步弹窗
|
// 同步弹窗
|
||||||
const syncDialogVisible = ref(false)
|
const syncDialogVisible = ref(false)
|
||||||
const syncTarget = ref(null)
|
const syncTarget = ref(null)
|
||||||
const syncHostId = ref(0)
|
const syncHostId = ref(null)
|
||||||
|
|
||||||
// 绑定弹窗
|
// 绑定弹窗
|
||||||
const bindDialogVisible = ref(false)
|
const bindDialogVisible = ref(false)
|
||||||
@@ -321,7 +328,7 @@ const loadList = async () => {
|
|||||||
const handleSearch = () => { queryParams.page = 1; loadList() }
|
const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
Object.assign(createForm, { name: '', host_id: 0, direction: '', lock: false, drop_all: false })
|
Object.assign(createForm, { name: '', host_id: null, direction: '', lock: false, drop_all: false })
|
||||||
createDialogVisible.value = true
|
createDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,14 +342,14 @@ const submitCreate = () => {
|
|||||||
ElMessage.success('创建成功')
|
ElMessage.success('创建成功')
|
||||||
createDialogVisible.value = false
|
createDialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else ElMessage.error(res?.data?.message || '创建失败')
|
} else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||||||
} catch (e) { ElMessage.error('创建失败') } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { submitLoading.value = false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSync = (row) => {
|
const handleSync = (row) => {
|
||||||
syncTarget.value = row
|
syncTarget.value = row
|
||||||
syncHostId.value = row.host_id || 0
|
syncHostId.value = row.host_id || null
|
||||||
syncDialogVisible.value = true
|
syncDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,8 +362,8 @@ const submitSync = async () => {
|
|||||||
ElMessage.success('同步成功')
|
ElMessage.success('同步成功')
|
||||||
syncDialogVisible.value = false
|
syncDialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else ElMessage.error(res?.data?.message || '同步失败')
|
} else ElMessage.error(extractApiError(res?.data, '同步失败'))
|
||||||
} catch (e) { ElMessage.error('同步失败') } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '同步失败')) } finally { submitLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBind = (row) => {
|
const handleBind = (row) => {
|
||||||
@@ -382,8 +389,8 @@ const submitBind = async () => {
|
|||||||
if (res?.data?.code === 200) {
|
if (res?.data?.code === 200) {
|
||||||
ElMessage.success(bindType.value === 'bind' ? '绑定成功' : '解绑成功')
|
ElMessage.success(bindType.value === 'bind' ? '绑定成功' : '解绑成功')
|
||||||
bindDialogVisible.value = false
|
bindDialogVisible.value = false
|
||||||
} else ElMessage.error(res?.data?.message || '操作失败')
|
} else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
} catch (e) { ElMessage.error('操作失败') } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleWhitelist = (row) => {
|
const handleToggleWhitelist = (row) => {
|
||||||
@@ -395,8 +402,8 @@ const handleToggleWhitelist = (row) => {
|
|||||||
const api = row.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist
|
const api = row.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist
|
||||||
const res = await api({ service_id: serviceId.value, id: row.id })
|
const res = await api({ service_id: serviceId.value, id: row.id })
|
||||||
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || `${action}失败`)
|
else ElMessage.error(extractApiError(res?.data, `${action}失败`))
|
||||||
} catch (e) { ElMessage.error(`${action}失败`) }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${action}失败`)) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,8 +414,8 @@ const handleApply = (row) => {
|
|||||||
try {
|
try {
|
||||||
const res = await applySecurityGroup({ service_id: serviceId.value, id: row.id })
|
const res = await applySecurityGroup({ service_id: serviceId.value, id: row.id })
|
||||||
if (res?.data?.code === 200) ElMessage.success('应用成功')
|
if (res?.data?.code === 200) ElMessage.success('应用成功')
|
||||||
else ElMessage.error(res?.data?.message || '应用失败')
|
else ElMessage.error(extractApiError(res?.data, '应用失败'))
|
||||||
} catch (e) { ElMessage.error('应用失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '应用失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,8 +430,8 @@ const handleDelete = (row) => {
|
|||||||
try {
|
try {
|
||||||
const res = await deleteSecurityGroup({ service_id: serviceId.value, id: row.id })
|
const res = await deleteSecurityGroup({ service_id: serviceId.value, id: row.id })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch (e) { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,8 +481,8 @@ const submitRule = () => {
|
|||||||
ruleDialogVisible.value = false
|
ruleDialogVisible.value = false
|
||||||
// 刷新详情
|
// 刷新详情
|
||||||
handleViewDetail(currentDetail.value)
|
handleViewDetail(currentDetail.value)
|
||||||
} else ElMessage.error(res?.data?.message || '操作失败')
|
} else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
} catch (e) { ElMessage.error('操作失败') } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,8 +495,8 @@ const handleDeleteRule = (rule) => {
|
|||||||
if (res?.data?.code === 200) {
|
if (res?.data?.code === 200) {
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
handleViewDetail(currentDetail.value)
|
handleViewDetail(currentDetail.value)
|
||||||
} else ElMessage.error(res?.data?.message || '删除失败')
|
} else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch (e) { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div class="snapshot-manage">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="handleCreate"><el-icon><Plus /></el-icon>创建快照</el-button>
|
||||||
|
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe size="small" style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="vm_id" label="虚拟机ID" width="90" />
|
||||||
|
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'completed' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'" size="small">
|
||||||
|
{{ statusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatTs(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="handleRestore(row)">恢复</el-button>
|
||||||
|
<el-button link type="info" size="small" @click="handleProgress(row)">进度</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!list.length && !loading" description="暂无快照数据" />
|
||||||
|
|
||||||
|
<el-dialog v-model="createVisible" title="创建快照" width="480px" destroy-on-close>
|
||||||
|
<el-form :model="createForm" label-width="100px">
|
||||||
|
<el-form-item label="虚拟机" required>
|
||||||
|
<el-select v-model="createForm.vm_id" placeholder="请选择虚拟机" filterable style="width: 100%" :loading="vmOptionsLoading">
|
||||||
|
<el-option v-for="vm in vmOptions" :key="vm.id" :label="`${vm.name} (ID: ${vm.id})`" :value="vm.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="快照名称" required>
|
||||||
|
<el-input v-model="createForm.name" placeholder="请输入快照名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选描述" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="createVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="progressVisible" title="快照任务进度" width="420px" destroy-on-close>
|
||||||
|
<div v-loading="progressLoading">
|
||||||
|
<el-descriptions :column="1" border size="small" v-if="progressData">
|
||||||
|
<el-descriptions-item v-for="(val, key) in progressData" :key="key" :label="key">{{ val }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-empty v-else description="暂无进度信息" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="progressVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress, getVmList } from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
|
|
||||||
|
const serviceId = inject('serviceId')
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
|
||||||
|
const statusLabel = (s) => ({ completed: '完成', pending: '等待', running: '运行中', failed: '失败' }[s] || s || '-')
|
||||||
|
const formatTs = (ts) => {
|
||||||
|
if (!ts) return '-'
|
||||||
|
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||||
|
return typeof ts === 'string' ? ts : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSnapshotList({ service_id: serviceId.value })
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const d = res.data.data
|
||||||
|
list.value = d.snapshots || d.data || d.list || (Array.isArray(d) ? d : [])
|
||||||
|
} else list.value = []
|
||||||
|
} catch { list.value = [] } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmOptions = ref([])
|
||||||
|
const vmOptionsLoading = ref(false)
|
||||||
|
|
||||||
|
const loadVmOptions = async () => {
|
||||||
|
vmOptionsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getVmList({ service_id: serviceId.value, page: 1, page_size: 500 })
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const inner = res.data.data
|
||||||
|
vmOptions.value = inner.vms || inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||||
|
}
|
||||||
|
} catch { /* */ } finally { vmOptionsLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const createVisible = ref(false)
|
||||||
|
const createForm = reactive({ vm_id: null, name: '', description: '' })
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
Object.assign(createForm, { vm_id: null, name: '', description: '' })
|
||||||
|
if (!vmOptions.value.length) await loadVmOptions()
|
||||||
|
createVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (!createForm.vm_id || !createForm.name) { ElMessage.warning('请填写必填项'); return }
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('vm_id', createForm.vm_id)
|
||||||
|
fd.append('name', createForm.name)
|
||||||
|
if (createForm.description) fd.append('description', createForm.description)
|
||||||
|
const res = await createSnapshot(fd)
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('快照创建成功'); createVisible.value = false; loadList() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { submitLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = (row) => {
|
||||||
|
ElMessageBox.confirm(`确定要恢复快照「${row.name}」到虚拟机(ID:${row.vm_id})吗?`, '恢复确认', {
|
||||||
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('snapshot_id', row.id)
|
||||||
|
fd.append('vm_id', row.vm_id)
|
||||||
|
const res = await restoreSnapshot(fd)
|
||||||
|
if (res?.data?.code === 200) ElMessage.success('恢复操作已提交')
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '恢复失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复失败')) }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (row) => {
|
||||||
|
ElMessageBox.confirm(`确定要删除快照「${row.name}」吗?`, '删除确认', {
|
||||||
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('snapshot_id', row.id)
|
||||||
|
fd.append('vm_id', row.vm_id)
|
||||||
|
const res = await deleteSnapshot(fd)
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressVisible = ref(false)
|
||||||
|
const progressLoading = ref(false)
|
||||||
|
const progressData = ref(null)
|
||||||
|
|
||||||
|
const handleProgress = async (row) => {
|
||||||
|
progressData.value = null
|
||||||
|
progressVisible.value = true
|
||||||
|
progressLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSnapshotProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) })
|
||||||
|
if (res?.data?.code === 200) progressData.value = res.data.data
|
||||||
|
else ElMessage.warning('暂无进度信息')
|
||||||
|
} catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadList() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.snapshot-manage { padding: 0; }
|
||||||
|
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
|
</style>
|
||||||
+1223
-133
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
|||||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||||
<div class="header-info">
|
<div class="header-info">
|
||||||
<h3>虚拟机管理</h3>
|
<h3>虚拟机管理</h3>
|
||||||
<span class="sub-info" v-if="serviceName">主控服务:{{ serviceName }} | 宿主机:{{ selectedHostName || '请选择' }}</span>
|
<span class="sub-info" v-if="serviceName">主控服务:{{ serviceName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -23,9 +23,6 @@
|
|||||||
<el-input v-model="keyword" placeholder="搜索虚拟机" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
|
<el-input v-model="keyword" placeholder="搜索虚拟机" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-select v-model="hostIdInput" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="handleSearch">
|
|
||||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
|
||||||
</el-select>
|
|
||||||
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 130px" @change="handleSearch">
|
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 130px" @change="handleSearch">
|
||||||
<el-option v-for="s in vmStatuses" :key="s.value" :label="s.label" :value="s.value" />
|
<el-option v-for="s in vmStatuses" :key="s.value" :label="s.label" :value="s.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -77,14 +74,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 创建弹窗 -->
|
<!-- 创建弹窗 -->
|
||||||
<el-dialog v-model="createDialogVisible" title="创建虚拟机" width="640px" destroy-on-close>
|
<el-dialog v-model="createDialogVisible" title="创建虚拟机" width="800px" destroy-on-close>
|
||||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px">
|
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px">
|
||||||
<el-form-item label="名称"><el-input v-model="createForm.name" placeholder="不填随机生成" /></el-form-item>
|
<el-form-item label="名称"><el-input v-model="createForm.name" placeholder="不填随机生成" /></el-form-item>
|
||||||
<el-form-item label="宿主机" prop="host_id">
|
|
||||||
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%">
|
|
||||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="镜像" prop="image_id">
|
<el-form-item label="镜像" prop="image_id">
|
||||||
<div class="bind-selector-row">
|
<div class="bind-selector-row">
|
||||||
<el-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
|
<el-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||||
@@ -92,46 +84,82 @@
|
|||||||
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left: 4px">清除</el-button>
|
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left: 4px">清除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-divider content-position="left">资源配置</el-divider>
|
<el-form-item label="用户" prop="user_id">
|
||||||
<el-row :gutter="16">
|
|
||||||
<el-col :span="8">
|
|
||||||
<el-form-item label="CPU(核)" prop="vcpu">
|
|
||||||
<el-input-number v-model="createForm.vcpu" :min="1" controls-position="right" style="width: 100%" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="8">
|
|
||||||
<el-form-item label="内存(KB)" prop="memory">
|
|
||||||
<el-input-number v-model="createForm.memory" :min="65536" :step="65536" controls-position="right" style="width: 100%" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="8">
|
|
||||||
<el-form-item label="系统盘(MB)" prop="system_size">
|
|
||||||
<el-input-number v-model="createForm.system_size" :min="1024" :step="1024" 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="createForm.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="createForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
<el-divider content-position="left">可选配置</el-divider>
|
|
||||||
<el-form-item label="宿主机组">
|
|
||||||
<div class="bind-selector-row">
|
<div class="bind-selector-row">
|
||||||
<el-input :model-value="createForm.host_group_id ? `宿主机组 #${createForm.host_group_id}${createForm._groupName ? ' - ' + createForm._groupName : ''}` : '未选择'" disabled style="flex: 1" />
|
<el-input :model-value="createForm.user_id ? `${createForm._userName || ''} (ID: ${createForm.user_id})` : '未选择'" disabled style="flex: 1" />
|
||||||
|
<el-button type="primary" @click="showUserSelector = true" style="margin-left: 8px">选择</el-button>
|
||||||
|
<el-button v-if="createForm.user_id" @click="createForm.user_id = 0; createForm._userName = ''" style="margin-left: 4px">清除</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider content-position="left">宿主机配置(二选一)</el-divider>
|
||||||
|
<el-form-item label="分配方式">
|
||||||
|
<el-radio-group v-model="hostMode">
|
||||||
|
<el-radio value="host">指定宿主机</el-radio>
|
||||||
|
<el-radio value="group">指定宿主机组</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="宿主机" v-if="hostMode === 'host'">
|
||||||
|
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%" @change="(v) => loadNetworkOptions(v)">
|
||||||
|
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="宿主机组" v-if="hostMode === 'group'">
|
||||||
|
<div class="bind-selector-row">
|
||||||
|
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
|
||||||
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||||
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = 0; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
|
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = 0; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="用户ID"><el-input-number v-model="createForm.user_id" :min="0" controls-position="right" style="width: 100%" /></el-form-item>
|
|
||||||
<el-form-item label="IP 数量"><el-input-number v-model="createForm.ip_num" :min="0" controls-position="right" style="width: 100%" /></el-form-item>
|
<el-divider content-position="left">资源配置</el-divider>
|
||||||
|
<div class="resource-row">
|
||||||
|
<div class="resource-item">
|
||||||
|
<span class="resource-label">* 内存</span>
|
||||||
|
<el-select v-model="memoryUnit" class="resource-unit-select">
|
||||||
|
<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="1" controls-position="right" class="resource-input" />
|
||||||
|
</div>
|
||||||
|
<div class="resource-item">
|
||||||
|
<span class="resource-label">* 系统盘</span>
|
||||||
|
<el-select v-model="diskUnit" class="resource-unit-select">
|
||||||
|
<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="1" controls-position="right" class="resource-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="resource-row">
|
||||||
|
<div class="resource-item">
|
||||||
|
<span class="resource-label">* CPU(核)</span>
|
||||||
|
<el-input-number v-model="createForm.vcpu" :min="1" controls-position="right" class="resource-input" />
|
||||||
|
</div>
|
||||||
|
<div class="resource-item">
|
||||||
|
<span class="resource-label">下行带宽(Mbps)</span>
|
||||||
|
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" class="resource-input" />
|
||||||
|
</div>
|
||||||
|
<div class="resource-item">
|
||||||
|
<span class="resource-label">上行带宽(Mbps)</span>
|
||||||
|
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" class="resource-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider content-position="left">网络配置(二选一)</el-divider>
|
||||||
|
<el-form-item label="IP分配方式">
|
||||||
|
<el-radio-group v-model="ipMode">
|
||||||
|
<el-radio value="num">按IP数量分配</el-radio>
|
||||||
|
<el-radio value="ids">选择网络IP</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="IP数量" v-if="ipMode === 'num'">
|
||||||
|
<el-input-number v-model="createForm.ip_num" :min="1" controls-position="right" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="网络IP列表" v-if="ipMode === 'ids'">
|
||||||
|
<el-select v-model="createForm.network_ids" multiple filterable placeholder="选择网络IP" style="width: 100%">
|
||||||
|
<el-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''} (ID: ${n.id})`" :value="n.id" />
|
||||||
|
</el-select>
|
||||||
|
<div class="form-tip" v-if="!networkOptions.length">请先选择宿主机以加载可用网络</div>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||||
@@ -228,22 +256,17 @@
|
|||||||
<template v-if="vmMetricsData">
|
<template v-if="vmMetricsData">
|
||||||
<h4 style="margin: 16px 0 8px">实时指标</h4>
|
<h4 style="margin: 16px 0 8px">实时指标</h4>
|
||||||
<el-descriptions :column="2" border size="small">
|
<el-descriptions :column="2" border size="small">
|
||||||
<template v-if="vmMetricsData.cpu">
|
<el-descriptions-item label="虚拟机">{{ vmMetricsData.vm_name || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="CPU使用率">{{ (vmMetricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
|
<el-descriptions-item label="CPU使用率">
|
||||||
</template>
|
<span :style="{ color: vmMetricsData.cpu_usage_percent > 90 ? '#F56C6C' : vmMetricsData.cpu_usage_percent > 60 ? '#E6A23C' : '#67C23A' }">
|
||||||
<template v-if="vmMetricsData.memory">
|
{{ (vmMetricsData.cpu_usage_percent ?? 0).toFixed(1) }}%
|
||||||
<el-descriptions-item label="内存总量">{{ formatBytesRaw(vmMetricsData.memory.total) }}</el-descriptions-item>
|
</span>
|
||||||
<el-descriptions-item label="内存使用">{{ formatBytesRaw(vmMetricsData.memory.used) }} ({{ vmMetricsData.memory.percent || 0 }}%)</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</template>
|
<template v-if="vmMetricsData.internet_speed && Object.keys(vmMetricsData.internet_speed).length">
|
||||||
<template v-if="vmMetricsData.disk">
|
<el-descriptions-item label="网络速率" :span="2">
|
||||||
<el-descriptions-item v-for="(info, path) in vmMetricsData.disk" :key="path" :label="'磁盘 ' + path">
|
<div v-for="(val, key) in vmMetricsData.internet_speed" :key="key">{{ key }}: {{ val }}</div>
|
||||||
{{ formatBytesRaw(info.used) }} / {{ formatBytesRaw(info.total) }} ({{ info.percent || 0 }}%)
|
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="vmMetricsData.network">
|
|
||||||
<el-descriptions-item label="网络接收">{{ formatBytesRaw(vmMetricsData.network.rx_bytes) }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="网络发送">{{ formatBytesRaw(vmMetricsData.network.tx_bytes) }}</el-descriptions-item>
|
|
||||||
</template>
|
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,6 +279,8 @@
|
|||||||
<ImageSelectorPopup v-model="showRebuildImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="handleRebuildImageSelected" />
|
<ImageSelectorPopup v-model="showRebuildImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="handleRebuildImageSelected" />
|
||||||
<!-- 宿主机组选择器 -->
|
<!-- 宿主机组选择器 -->
|
||||||
<HostGroupSelectorPopup v-model="showHostGroupSelector" :service-id="serviceId" :current-id="createForm.host_group_id" @confirm="handleHostGroupSelected" />
|
<HostGroupSelectorPopup v-model="showHostGroupSelector" :service-id="serviceId" :current-id="createForm.host_group_id" @confirm="handleHostGroupSelected" />
|
||||||
|
<!-- 用户选择器 -->
|
||||||
|
<UserListSelector v-model="showUserSelector" :current-user-id="createForm.user_id" @confirm="handleUserSelected" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -267,10 +292,12 @@ import { Plus, Refresh, Search, ArrowLeft, ArrowDown } from '@element-plus/icons
|
|||||||
import {
|
import {
|
||||||
getRemoteHostList, getVmList, getVmDetail, getVmStatus, getVmMetrics,
|
getRemoteHostList, getVmList, getVmDetail, getVmStatus, getVmMetrics,
|
||||||
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
|
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
|
||||||
resumeVm, rescueVm, exitRescueVm, deleteVm
|
resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
||||||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||||||
|
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -288,7 +315,6 @@ const vmList = ref([])
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const filterStatus = ref('')
|
const filterStatus = ref('')
|
||||||
const hostIdInput = ref(0)
|
|
||||||
const hostOptions = ref([])
|
const hostOptions = ref([])
|
||||||
const queryParams = reactive({ page: 1, count: 10 })
|
const queryParams = reactive({ page: 1, count: 10 })
|
||||||
|
|
||||||
@@ -296,12 +322,50 @@ const queryParams = reactive({ page: 1, count: 10 })
|
|||||||
const showCreateImageSelector = ref(false)
|
const showCreateImageSelector = ref(false)
|
||||||
const showRebuildImageSelector = ref(false)
|
const showRebuildImageSelector = ref(false)
|
||||||
const showHostGroupSelector = ref(false)
|
const showHostGroupSelector = ref(false)
|
||||||
|
const showUserSelector = ref(false)
|
||||||
|
|
||||||
const selectedHostName = computed(() => {
|
// 创建表单模式切换
|
||||||
const h = hostOptions.value.find(x => x.id === hostIdInput.value)
|
const hostMode = ref('host')
|
||||||
return h ? `${h.name} (${h.ip || h.id})` : (hostIdInput.value || '')
|
const ipMode = ref('num')
|
||||||
|
const networkOptions = ref([])
|
||||||
|
|
||||||
|
// 内存单位: API传输单位为 KB
|
||||||
|
const memoryUnitOptions = [
|
||||||
|
{ label: 'KB', factor: 1 },
|
||||||
|
{ label: 'MB', factor: 1024 },
|
||||||
|
{ label: 'GB', factor: 1048576 }
|
||||||
|
]
|
||||||
|
const memoryUnit = ref('KB')
|
||||||
|
const getMemFactor = () => memoryUnitOptions.find(u => u.label === memoryUnit.value)?.factor || 1
|
||||||
|
const memoryDisplay = computed({
|
||||||
|
get: () => Math.round(createForm.memory / getMemFactor()),
|
||||||
|
set: (v) => { createForm.memory = Math.round(v * getMemFactor()) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 系统盘单位: API传输单位为 MB
|
||||||
|
const diskUnitOptions = [
|
||||||
|
{ label: 'MB', factor: 1 },
|
||||||
|
{ label: 'GB', factor: 1024 }
|
||||||
|
]
|
||||||
|
const diskUnit = ref('GB')
|
||||||
|
const getDiskFactor = () => diskUnitOptions.find(u => u.label === diskUnit.value)?.factor || 1
|
||||||
|
const diskDisplay = computed({
|
||||||
|
get: () => Math.round(createForm.system_size / getDiskFactor()),
|
||||||
|
set: (v) => { createForm.system_size = Math.round(v * getDiskFactor()) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadNetworkOptions = async (hostId) => {
|
||||||
|
if (!hostId) return
|
||||||
|
try {
|
||||||
|
const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, page: 1, page_size: 200 })
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const inner = body.data
|
||||||
|
networkOptions.value = inner.networks || inner.data || (Array.isArray(inner) ? inner : [])
|
||||||
|
}
|
||||||
|
} catch { networkOptions.value = [] }
|
||||||
|
}
|
||||||
|
|
||||||
const getHostLabel = (hid) => {
|
const getHostLabel = (hid) => {
|
||||||
const h = hostOptions.value.find(x => x.id === hid)
|
const h = hostOptions.value.find(x => x.id === hid)
|
||||||
return h ? `${h.name}` : (hid || '-')
|
return h ? `${h.name}` : (hid || '-')
|
||||||
@@ -340,16 +404,16 @@ const vmMetricsData = ref(null)
|
|||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
name: '', host_id: 0, image_id: 0, vcpu: 1, memory: 1048576,
|
name: '', host_id: 0, image_id: 0, vcpu: 1, memory: 1048576,
|
||||||
system_size: 10240, rx_bandwidth: 0, tx_bandwidth: 0,
|
system_size: 10240, rx_bandwidth: 0, tx_bandwidth: 0,
|
||||||
host_group_id: 0, user_id: 0, ip_num: 0,
|
host_group_id: 0, user_id: 0, ip_num: 0, network_ids: [],
|
||||||
_imageName: '', _groupName: ''
|
_imageName: '', _groupName: '', _userName: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const createRules = {
|
const createRules = {
|
||||||
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }],
|
|
||||||
image_id: [{ required: true, message: '请选择镜像', trigger: 'blur', type: 'number', min: 1 }],
|
image_id: [{ required: true, message: '请选择镜像', trigger: 'blur', type: 'number', min: 1 }],
|
||||||
vcpu: [{ required: true, message: '请输入CPU核数', trigger: 'blur' }],
|
vcpu: [{ required: true, message: '请输入CPU核数', trigger: 'blur' }],
|
||||||
memory: [{ required: true, message: '请输入内存(KB)', trigger: 'blur' }],
|
memory: [{ required: true, message: '请输入内存(KB)', trigger: 'blur' }],
|
||||||
system_size: [{ required: true, message: '请输入系统盘(MB)', trigger: 'blur' }]
|
system_size: [{ required: true, message: '请输入系统盘大小', trigger: 'blur' }],
|
||||||
|
user_id: [{ required: true, message: '请选择用户', trigger: 'change', type: 'number', min: 1 }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const vmStatusType = (s) => ({
|
const vmStatusType = (s) => ({
|
||||||
@@ -397,14 +461,13 @@ const formatBytesRaw = (val) => {
|
|||||||
const handleCreateImageSelected = (img) => { createForm.image_id = img.id; createForm._imageName = img.name }
|
const handleCreateImageSelected = (img) => { createForm.image_id = img.id; createForm._imageName = img.name }
|
||||||
const handleRebuildImageSelected = (img) => { rebuildImageId.value = img.id; rebuildImageName.value = img.name }
|
const handleRebuildImageSelected = (img) => { rebuildImageId.value = img.id; rebuildImageName.value = img.name }
|
||||||
const handleHostGroupSelected = (group) => { createForm.host_group_id = group.id; createForm._groupName = group.name || '' }
|
const handleHostGroupSelected = (group) => { createForm.host_group_id = group.id; createForm._groupName = group.name || '' }
|
||||||
|
const handleUserSelected = (user) => { createForm.user_id = user.user_id || user.id; createForm._userName = user.user_name || user.name || '' }
|
||||||
|
|
||||||
const loadList = async () => {
|
const loadList = async () => {
|
||||||
if (!serviceId.value) return
|
if (!serviceId.value) return
|
||||||
const hid = hostIdInput.value || hostId.value
|
|
||||||
if (!hid) { ElMessage.warning('请先选择宿主机'); return }
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, count: queryParams.count }
|
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.count }
|
||||||
if (keyword.value) params.key = keyword.value
|
if (keyword.value) params.key = keyword.value
|
||||||
if (filterStatus.value) params.status = filterStatus.value
|
if (filterStatus.value) params.status = filterStatus.value
|
||||||
const res = await getVmList(params)
|
const res = await getVmList(params)
|
||||||
@@ -414,41 +477,51 @@ const loadList = async () => {
|
|||||||
vmList.value = inner.data || inner.vms || (Array.isArray(inner) ? inner : [])
|
vmList.value = inner.data || inner.vms || (Array.isArray(inner) ? inner : [])
|
||||||
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? vmList.value.length
|
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? vmList.value.length
|
||||||
} else { vmList.value = []; total.value = 0 }
|
} else { vmList.value = []; total.value = 0 }
|
||||||
} catch (e) { ElMessage.error('获取虚拟机列表失败') } finally { loading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取虚拟机列表失败')) } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = () => { queryParams.page = 1; loadList() }
|
const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
Object.assign(createForm, {
|
Object.assign(createForm, {
|
||||||
name: '', host_id: hostIdInput.value || hostId.value || 0, image_id: 0,
|
name: '', host_id: hostId.value || 0, image_id: 0,
|
||||||
vcpu: 1, memory: 1048576, system_size: 10240,
|
vcpu: 1, memory: 1048576, system_size: 10240,
|
||||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, user_id: 0, ip_num: 0,
|
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, user_id: 0, ip_num: 0, network_ids: [],
|
||||||
_imageName: '', _groupName: ''
|
_imageName: '', _groupName: '', _userName: ''
|
||||||
})
|
})
|
||||||
|
hostMode.value = 'host'
|
||||||
|
ipMode.value = 'num'
|
||||||
|
if (createForm.host_id) loadNetworkOptions(createForm.host_id)
|
||||||
createDialogVisible.value = true
|
createDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitCreate = () => {
|
const submitCreate = () => {
|
||||||
|
if (hostMode.value === 'host' && !createForm.host_id) { ElMessage.warning('请选择宿主机'); return }
|
||||||
|
if (hostMode.value === 'group' && !createForm.host_group_id) { ElMessage.warning('请选择宿主机组'); return }
|
||||||
|
if (ipMode.value === 'ids' && !createForm.network_ids.length) { ElMessage.warning('请选择网络IP'); return }
|
||||||
|
if (ipMode.value === 'num' && !createForm.ip_num) { ElMessage.warning('请输入IP数量'); return }
|
||||||
|
|
||||||
createFormRef.value?.validate(async (valid) => {
|
createFormRef.value?.validate(async (valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
submitLoading.value = true
|
submitLoading.value = true
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
service_id: serviceId.value,
|
service_id: serviceId.value,
|
||||||
host_id: createForm.host_id, image_id: createForm.image_id,
|
image_id: createForm.image_id,
|
||||||
vcpu: createForm.vcpu, memory: createForm.memory, system_size: createForm.system_size
|
vcpu: createForm.vcpu, memory: createForm.memory, system_size: createForm.system_size,
|
||||||
|
user_id: createForm.user_id
|
||||||
}
|
}
|
||||||
if (createForm.name) payload.name = createForm.name
|
if (createForm.name) payload.name = createForm.name
|
||||||
if (createForm.rx_bandwidth) payload.rx_bandwidth = createForm.rx_bandwidth
|
if (createForm.rx_bandwidth) payload.rx_bandwidth = createForm.rx_bandwidth
|
||||||
if (createForm.tx_bandwidth) payload.tx_bandwidth = createForm.tx_bandwidth
|
if (createForm.tx_bandwidth) payload.tx_bandwidth = createForm.tx_bandwidth
|
||||||
if (createForm.host_group_id) payload.host_group_id = createForm.host_group_id
|
if (hostMode.value === 'host') payload.host_id = createForm.host_id
|
||||||
if (createForm.user_id) payload.user_id = createForm.user_id
|
else payload.host_group_id = createForm.host_group_id
|
||||||
if (createForm.ip_num) payload.ip_num = createForm.ip_num
|
if (ipMode.value === 'num') payload.ip_num = createForm.ip_num
|
||||||
|
else payload.network_ids = createForm.network_ids
|
||||||
const res = await createVm(payload)
|
const res = await createVm(payload)
|
||||||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '创建失败')
|
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||||||
} catch (e) { ElMessage.error('创建失败: ' + (e?.response?.data?.message || e.message)) }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) }
|
||||||
finally { submitLoading.value = false }
|
finally { submitLoading.value = false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -473,8 +546,8 @@ const handlePower = (row, action) => {
|
|||||||
res = await apis[action](payload)
|
res = await apis[action](payload)
|
||||||
}
|
}
|
||||||
if (res?.data?.code === 200) { ElMessage.success(`${labels[action]}成功`); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success(`${labels[action]}成功`); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || `${labels[action]}失败`)
|
else ElMessage.error(extractApiError(res?.data, `${labels[action]}失败`))
|
||||||
} catch (e) { ElMessage.error(`${labels[action]}失败`) }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${labels[action]}失败`)) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,8 +570,8 @@ const submitRebuild = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await rebuildVm({ service_id: serviceId.value, vm_id: rebuildTarget.value.id, image_id: rebuildImageId.value })
|
const res = await rebuildVm({ service_id: serviceId.value, vm_id: rebuildTarget.value.id, image_id: rebuildImageId.value })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('重建成功'); rebuildDialogVisible.value = false; loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('重建成功'); rebuildDialogVisible.value = false; loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '重建失败')
|
else ElMessage.error(extractApiError(res?.data, '重建失败'))
|
||||||
} catch (e) { ElMessage.error('重建失败') } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重建失败')) } finally { submitLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRescue = (row) => {
|
const handleRescue = (row) => {
|
||||||
@@ -511,8 +584,8 @@ const handleRescue = (row) => {
|
|||||||
fd.append('vm_id', row.id)
|
fd.append('vm_id', row.id)
|
||||||
const res = await rescueVm(fd)
|
const res = await rescueVm(fd)
|
||||||
if (res?.data?.code === 200) { ElMessage.success('已进入救援模式'); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('已进入救援模式'); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '操作失败')
|
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
} catch (e) { ElMessage.error('操作失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,8 +599,8 @@ const handleExitRescue = (row) => {
|
|||||||
fd.append('vm_id', row.id)
|
fd.append('vm_id', row.id)
|
||||||
const res = await exitRescueVm(fd)
|
const res = await exitRescueVm(fd)
|
||||||
if (res?.data?.code === 200) { ElMessage.success('已退出救援模式'); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('已退出救援模式'); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '操作失败')
|
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
} catch (e) { ElMessage.error('操作失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,7 +627,7 @@ const fetchVmStatus = async (vm) => {
|
|||||||
currentDetail.value = { ...currentDetail.value, status: statusData.status ?? statusData }
|
currentDetail.value = { ...currentDetail.value, status: statusData.status ?? statusData }
|
||||||
ElMessage.success('状态已刷新: ' + vmStatusLabel(currentDetail.value.status))
|
ElMessage.success('状态已刷新: ' + vmStatusLabel(currentDetail.value.status))
|
||||||
}
|
}
|
||||||
} catch { ElMessage.error('获取状态失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchVmMetrics = async (vm) => {
|
const fetchVmMetrics = async (vm) => {
|
||||||
@@ -562,7 +635,7 @@ const fetchVmMetrics = async (vm) => {
|
|||||||
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: vm.name })
|
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: vm.name })
|
||||||
if (res?.data?.code === 200) vmMetricsData.value = res.data.data?.data ?? res.data.data
|
if (res?.data?.code === 200) vmMetricsData.value = res.data.data?.data ?? res.data.data
|
||||||
else ElMessage.warning('暂无指标数据')
|
else ElMessage.warning('暂无指标数据')
|
||||||
} catch { ElMessage.error('获取指标失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取指标失败')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGoDetail = (row) => {
|
const handleGoDetail = (row) => {
|
||||||
@@ -574,13 +647,10 @@ const handleDelete = (row) => {
|
|||||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||||
}).then(async () => {
|
}).then(async () => {
|
||||||
try {
|
try {
|
||||||
const fd = new FormData()
|
const res = await deleteVm({ service_id: serviceId.value, vm_id: row.id })
|
||||||
fd.append('service_id', serviceId.value)
|
|
||||||
fd.append('vm_id', row.id)
|
|
||||||
const res = await deleteVm(fd)
|
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch (e) { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,12 +659,7 @@ const goBack = () => { router.push('/virtualization/kvm-service') }
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (serviceId.value) {
|
if (serviceId.value) {
|
||||||
await loadHostOptions()
|
await loadHostOptions()
|
||||||
if (hostId.value) {
|
loadList()
|
||||||
hostIdInput.value = hostId.value
|
|
||||||
} else if (hostOptions.value.length > 0) {
|
|
||||||
hostIdInput.value = hostOptions.value[0].id
|
|
||||||
}
|
|
||||||
if (hostIdInput.value) loadList()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -615,4 +680,9 @@ onMounted(async () => {
|
|||||||
.bind-selector-row { display: flex; align-items: center; width: 100%; }
|
.bind-selector-row { display: flex; align-items: center; width: 100%; }
|
||||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||||
|
.resource-row { display: flex; gap: 20px; margin-bottom: 18px; }
|
||||||
|
.resource-item { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; }
|
||||||
|
.resource-label { white-space: nowrap; font-size: 14px; color: #606266; flex-shrink: 0; }
|
||||||
|
.resource-unit-select { width: 72px; flex-shrink: 0; }
|
||||||
|
.resource-input { flex: 1; min-width: 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -39,12 +39,18 @@
|
|||||||
<span class="host-addr">{{ row.ip || '-' }}:{{ row.port || '-' }}</span>
|
<span class="host-addr">{{ row.ip || '-' }}:{{ row.port || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="Token" min-width="160" show-overflow-tooltip>
|
<el-table-column label="Token" min-width="140" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.token" class="token-mask">{{ maskToken(row.token) }}</span>
|
<span v-if="row.token" class="token-mask">{{ maskToken(row.token) }}</span>
|
||||||
<span v-else class="text-muted">未设置</span>
|
<span v-else class="text-muted">未设置</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="延迟" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row._delay != null" :style="{ color: row._delay > 500000000 ? '#F56C6C' : row._delay > 100000000 ? '#E6A23C' : '#67C23A' }">{{ formatDelay(row._delay) }}</span>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="260" fixed="right">
|
<el-table-column label="操作" width="260" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||||
@@ -118,12 +124,10 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<div v-if="vmVncResult" class="vnc-result">
|
<div v-if="vmVncResult" class="vnc-result">
|
||||||
<el-descriptions :column="1" border size="small">
|
<el-descriptions :column="1" border size="small">
|
||||||
<el-descriptions-item v-for="(val, key) in vmVncResult" :key="key" :label="key">
|
<el-descriptions-item label="VNC地址">
|
||||||
<template v-if="typeof val === 'string' && (val.startsWith('http') || val.startsWith('ws'))">
|
<el-link type="primary" :href="vmVncResult.url" target="_blank">{{ vmVncResult.url }}</el-link>
|
||||||
<el-link type="primary" :href="val" target="_blank">{{ val }}</el-link>
|
|
||||||
</template>
|
|
||||||
<template v-else>{{ val }}</template>
|
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="过期时间">{{ formatTimestamp(vmVncResult.expire_at) }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -145,6 +149,7 @@ import {
|
|||||||
getVncNodeList, getVmVnc, addVncNode, testVncNode, updateVncNode, deleteVncNode,
|
getVncNodeList, getVmVnc, addVncNode, testVncNode, updateVncNode, deleteVncNode,
|
||||||
getRemoteHostList
|
getRemoteHostList
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -169,6 +174,20 @@ const maskToken = (token) => {
|
|||||||
return token.substring(0, 4) + '****' + token.substring(token.length - 4)
|
return token.substring(0, 4) + '****' + token.substring(token.length - 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 formatDelay = (ns) => {
|
||||||
|
if (!ns && ns !== 0) return '-'
|
||||||
|
const ms = ns / 1000000
|
||||||
|
if (ms >= 1000) return (ms / 1000).toFixed(2) + ' s'
|
||||||
|
return ms.toFixed(2) + ' ms'
|
||||||
|
}
|
||||||
|
|
||||||
const loadHostOptions = async () => {
|
const loadHostOptions = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||||
@@ -241,8 +260,8 @@ const handleSubmit = () => {
|
|||||||
ElMessage.success(formType.value === 'add' ? '创建成功' : '修改成功')
|
ElMessage.success(formType.value === 'add' ? '创建成功' : '修改成功')
|
||||||
formDialogVisible.value = false
|
formDialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else ElMessage.error(res?.data?.message || '操作失败')
|
} else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||||
} catch (e) { ElMessage.error('操作失败') } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,8 +272,8 @@ const handleDelete = (row) => {
|
|||||||
try {
|
try {
|
||||||
const res = await deleteVncNode({ service_id: serviceId.value, id: row.id })
|
const res = await deleteVncNode({ service_id: serviceId.value, id: row.id })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch (e) { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,9 +298,15 @@ const submitTest = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await testVncNode({ service_id: serviceId.value, id: testTarget.value.id, host_id: testHostId.value })
|
const res = await testVncNode({ service_id: serviceId.value, id: testTarget.value.id, host_id: testHostId.value })
|
||||||
if (res?.data?.code === 200) {
|
if (res?.data?.code === 200) {
|
||||||
testResult.value = { success: true, message: '连接测试成功' }
|
const d = res.data.data || {}
|
||||||
|
const delay = d.delay
|
||||||
|
if (delay != null && testTarget.value) {
|
||||||
|
const idx = nodeList.value.findIndex(n => n.id === testTarget.value.id)
|
||||||
|
if (idx >= 0) nodeList.value[idx]._delay = delay
|
||||||
|
}
|
||||||
|
testResult.value = { success: true, message: '连接测试成功' + (delay != null ? `,延迟:${formatDelay(delay)}` : '') }
|
||||||
} else {
|
} else {
|
||||||
testResult.value = { success: false, message: res?.data?.message || '连接测试失败' }
|
testResult.value = { success: false, message: extractApiError(res?.data, '连接测试失败') }
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
testResult.value = { success: false, message: '连接测试异常: ' + (e?.message || '未知错误') }
|
testResult.value = { success: false, message: '连接测试异常: ' + (e?.message || '未知错误') }
|
||||||
@@ -315,9 +340,9 @@ const submitVmVnc = async () => {
|
|||||||
if (res?.data?.code === 200 && res?.data?.data) {
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
vmVncResult.value = res.data.data
|
vmVncResult.value = res.data.data
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res?.data?.message || '获取VNC连接信息失败')
|
ElMessage.error(extractApiError(res?.data, '获取VNC连接信息失败'))
|
||||||
}
|
}
|
||||||
} catch (e) { ElMessage.error('获取失败') } finally { vmVncLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取VNC连接信息失败')) } finally { vmVncLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ import {
|
|||||||
getRemoteHostList, getVolumeList, getVolumeDetail,
|
getRemoteHostList, getVolumeList, getVolumeDetail,
|
||||||
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume
|
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
||||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||||
|
|
||||||
@@ -310,7 +311,7 @@ const loadList = async () => {
|
|||||||
volumeList.value = inner.data || inner.volumes || (Array.isArray(inner) ? inner : [])
|
volumeList.value = inner.data || inner.volumes || (Array.isArray(inner) ? inner : [])
|
||||||
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? volumeList.value.length
|
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? volumeList.value.length
|
||||||
} else { volumeList.value = []; total.value = 0 }
|
} else { volumeList.value = []; total.value = 0 }
|
||||||
} catch (e) { ElMessage.error('获取数据卷列表失败') } finally { loading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取数据卷列表失败')) } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = () => { queryParams.page = 1; loadList() }
|
const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||||
@@ -339,8 +340,8 @@ const submitCreate = () => {
|
|||||||
if (createForm.target_device) payload.target_device = createForm.target_device
|
if (createForm.target_device) payload.target_device = createForm.target_device
|
||||||
const res = await createVolume(payload)
|
const res = await createVolume(payload)
|
||||||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '创建失败')
|
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||||||
} catch (e) { ElMessage.error('创建失败: ' + (e?.response?.data?.message || e.message)) } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { submitLoading.value = false }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,8 +352,8 @@ const submitResize = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await resizeVolume({ service_id: serviceId.value, volume_id: resizeTarget.value.id, size: newSize.value })
|
const res = await resizeVolume({ service_id: serviceId.value, volume_id: resizeTarget.value.id, size: newSize.value })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '调整失败')
|
else ElMessage.error(extractApiError(res?.data, '调整失败'))
|
||||||
} catch (e) { ElMessage.error('调整失败') } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { submitLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMount = (row) => {
|
const handleMount = (row) => {
|
||||||
@@ -366,8 +367,8 @@ const submitMount = async () => {
|
|||||||
try {
|
try {
|
||||||
const res = await mountVolume({ service_id: serviceId.value, volume_id: mountTarget.value.id, vm_id: mountVmId.value })
|
const res = await mountVolume({ service_id: serviceId.value, volume_id: mountTarget.value.id, vm_id: mountVmId.value })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('挂载成功'); mountDialogVisible.value = false; loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('挂载成功'); mountDialogVisible.value = false; loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '挂载失败')
|
else ElMessage.error(extractApiError(res?.data, '挂载失败'))
|
||||||
} catch (e) { ElMessage.error('挂载失败') } finally { submitLoading.value = false }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) } finally { submitLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUnmount = (row) => {
|
const handleUnmount = (row) => {
|
||||||
@@ -377,8 +378,8 @@ const handleUnmount = (row) => {
|
|||||||
try {
|
try {
|
||||||
const res = await unmountVolume({ service_id: serviceId.value, volume_id: row.id })
|
const res = await unmountVolume({ service_id: serviceId.value, volume_id: row.id })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('卸载成功'); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('卸载成功'); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '卸载失败')
|
else ElMessage.error(extractApiError(res?.data, '卸载失败'))
|
||||||
} catch (e) { ElMessage.error('卸载失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '卸载失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,9 +404,9 @@ const submitTransfer = async () => {
|
|||||||
transferDialogVisible.value = false
|
transferDialogVisible.value = false
|
||||||
loadList()
|
loadList()
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res?.data?.message || '迁移失败')
|
ElMessage.error(extractApiError(res?.data, '迁移失败'))
|
||||||
}
|
}
|
||||||
} catch (e) { ElMessage.error('迁移失败: ' + (e?.response?.data?.message || e.message)) }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) }
|
||||||
finally { transferLoading.value = false }
|
finally { transferLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,8 +431,8 @@ const handleDelete = (row) => {
|
|||||||
try {
|
try {
|
||||||
const res = await deleteVolume({ service_id: serviceId.value, volume_id: row.id })
|
const res = await deleteVolume({ service_id: serviceId.value, volume_id: row.id })
|
||||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||||
else ElMessage.error(res?.data?.message || '删除失败')
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
} catch (e) { ElMessage.error('删除失败') }
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
// 强制绑定 IPv4 回环地址,避免 TUN/VPN 代理模式拦截 IPv6 或通配地址
|
// 强制绑定 IPv4 回环地址,避免 TUN/VPN 代理模式拦截 IPv6 或通配地址
|
||||||
host: '127.0.0.1',
|
host: '0.0.0.0',
|
||||||
port: 5176,
|
port: 5176,
|
||||||
strictPort: false,
|
strictPort: false,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
+1186
-21
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user