feat: PVE host management and VM control

- Add PVE host management page (/pve-hosts)
- Add PVE host CRUD operations with connection test
- Add VM start/stop buttons on machine list cards
- Auto-detect PVE host status on page load
- Add VM ID validation (frontend + backend)
- Fix PVE status detection logic (online vs ok)
- Update Dream2.0 theme CSS variables
- Fix visitor mode grid layout
This commit is contained in:
shirainbown
2026-06-19 17:49:04 +08:00
parent 39ea3e154f
commit 21a2d2dff3
6 changed files with 464 additions and 26 deletions

View File

@@ -248,6 +248,12 @@ func (h *PVEHandler) VMStatus(c *gin.Context) {
return
}
// 验证 VM ID 格式(必须是正整数)
if _, err := strconv.Atoi(pveVMID.String); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid VM ID format"})
return
}
status, err := h.service.GetVMStatus(*pveHostID, pveVMID.String)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -283,6 +289,12 @@ func (h *PVEHandler) VMStart(c *gin.Context) {
return
}
// 验证 VM ID 格式(必须是正整数)
if _, err := strconv.Atoi(pveVMID.String); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid VM ID format"})
return
}
if err := h.service.StartVM(*pveHostID, pveVMID.String); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -323,6 +335,12 @@ func (h *PVEHandler) VMStop(c *gin.Context) {
return
}
// 验证 VM ID 格式(必须是正整数)
if _, err := strconv.Atoi(pveVMID.String); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid VM ID format"})
return
}
if err := h.service.StopVM(*pveHostID, pveVMID.String); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return

View File

@@ -19,23 +19,23 @@ onMounted(() => {
/* ─── Light Mode (Dream2.0 Plus style) ─── */
:root {
--page-bg: #f5f7fa;
--card-bg: #ffffff;
--card-hover: #f2f6fc;
--sidebar-bg: #ffffff;
--card-bg: rgba(255, 255, 255, 0.94);
--card-hover: rgba(255, 255, 255, 0.98);
--sidebar-bg: rgba(255, 255, 255, 0.94);
--text-primary: #424242;
--text-secondary: #57606a;
--text-muted: #8c959f;
--border: #ebeef5;
--border-strong: #dcdcdc;
--accent: #50bfff;
--accent-weak: #e8f3ff;
--accent-weak: rgba(80, 191, 255, 0.12);
--accent-hover: #409eff;
--success: #1a7f37;
--success-weak: #dafbe1;
--success-weak: rgba(26, 127, 55, 0.12);
--warning: #9a6700;
--warning-weak: #fff8c5;
--warning-weak: rgba(154, 103, 0, 0.12);
--danger: #cf222e;
--danger-weak: #ffebe9;
--danger-weak: rgba(207, 34, 46, 0.12);
--radius: 8px;
--radius-sm: 4px;
--shadow: 0 0px 10px -5px #949494;
@@ -49,17 +49,17 @@ onMounted(() => {
--header-gradient: linear-gradient(135deg, #ffffff 0%, #f6f8fa 100%);
}
/* ─── Dark Mode (Dream2.0 Plus night style) ─── */
/* ─── Dark Mode (Dream2.0 Plus night style - 分层配色) ─── */
html.dark {
--page-bg: #282c34;
--card-bg: #3a3f4b;
--card-hover: #4a5060;
--sidebar-bg: #010409;
--text-primary: #ccc;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--border: #30363d;
--border-strong: #484f58;
--page-bg: #080c28;
--card-bg: rgba(40, 44, 52, 0.8);
--card-hover: #373d48;
--sidebar-bg: #080c28;
--text-primary: #d0d0d0;
--text-secondary: #aaa;
--text-muted: #888;
--border: #414243;
--border-strong: #666;
--accent: #5d93db;
--accent-weak: rgba(93, 147, 219, 0.15);
--accent-hover: #79b8ff;
@@ -69,14 +69,14 @@ html.dark {
--warning-weak: rgba(210, 153, 34, 0.15);
--danger: #f85149;
--danger-weak: rgba(248, 81, 73, 0.15);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.5), 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow: 1px 1px 3px 1px #1b1b1b;
--shadow-card: 1px 1px 3px 1px #1b1b1b;
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
--pill-bg: #21262d;
--pill-text: #c9d1d9;
--pill-border: #30363d;
--topo-bg: #0d1117;
--header-gradient: linear-gradient(135deg, #161b22 0%, #0d1117 100%);
--pill-bg: #303030;
--pill-text: #ccc;
--pill-border: #414243;
--topo-bg: #080c28;
--header-gradient: linear-gradient(135deg, #161b22 0%, #080c28 100%);
}
* { box-sizing: border-box; }

View File

@@ -21,6 +21,10 @@
<el-icon><Monitor /></el-icon>
<span>机器列表</span>
</router-link>
<router-link to="/pve-hosts" class="nav-item" active-class="active">
<el-icon><Cpu /></el-icon>
<span>PVE 主机</span>
</router-link>
<router-link to="/topology" class="nav-item" active-class="active">
<el-icon><Share /></el-icon>
<span>拓扑图</span>

View File

@@ -43,6 +43,12 @@ const routes = [
component: () => import('@/views/Logs.vue'),
meta: { admin: true },
},
{
path: 'pve-hosts',
name: 'PVEHosts',
component: () => import('@/views/PVEHosts.vue'),
meta: { admin: true },
},
]
},
]

View File

@@ -73,6 +73,29 @@
<el-icon :size="10"><component :is="m.pve_vm_status === 'running' ? VideoPlay : VideoPause" /></el-icon>
{{ m.pve_vm_status === 'running' ? 'VM运行中' : m.pve_vm_status === 'stopped' ? 'VM已停止' : 'VM检测中' }}
</el-tag>
<!-- VM 快捷控制按钮 -->
<template v-if="m.pve_host_id && m.pve_vmid && m.pve_vm_status !== 'unknown'">
<el-button
v-if="m.pve_vm_status !== 'running'"
type="success"
:icon="VideoPlay"
:loading="vmLoadingIds.has(m.id)"
size="small"
text
class="vm-btn"
@click.stop="handleStartVM(m, $event)"
>启动</el-button>
<el-button
v-if="m.pve_vm_status === 'running'"
type="warning"
:icon="VideoPause"
:loading="vmLoadingIds.has(m.id)"
size="small"
text
class="vm-btn"
@click.stop="handleStopVM(m, $event)"
>关闭</el-button>
</template>
<span v-if="m.uptime" class="meta-uptime">
<el-icon :size="10"><Timer /></el-icon> {{ m.uptime }}
</span>
@@ -311,9 +334,9 @@ import {
Collection, Histogram, Connection, Clock, VideoPlay, VideoPause, Lock, User, Key,
EditPen, Grid, InfoFilled, Warning
} from '@element-plus/icons-vue'
import { fetchMachines, createMachine, updateMachine, uiRefreshInterval, exportData, importData, fetchPVEHosts, fetchVMStatus } from '@/api'
import { fetchMachines, createMachine, updateMachine, uiRefreshInterval, exportData, importData, fetchPVEHosts, fetchVMStatus, startVM as apiStartVM, stopVM as apiStopVM } from '@/api'
import { getAuth } from '@/router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const isAdmin = getAuth().is_admin
@@ -324,6 +347,7 @@ const osFilter = ref('')
const dialogVisible = ref(false)
const editing = ref({ hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '', pve_host_id: null, pve_vmid: '' })
const importFileRef = ref(null)
const vmLoadingIds = ref(new Set())
let timer = null
onMounted(async () => {
@@ -377,6 +401,13 @@ async function saveMachine() {
ElMessage.warning('请填写必填项')
return
}
// 验证 VM ID 格式
if (data.pve_host_id && data.pve_vmid) {
if (!/^\d+$/.test(data.pve_vmid)) {
ElMessage.warning('虚拟机 ID 必须是正整数(如 101')
return
}
}
if (data.id) {
await updateMachine(data.id, data)
} else {
@@ -523,6 +554,36 @@ function formatTime(t) {
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
async function handleStartVM(m, event) {
event.stopPropagation()
try {
await ElMessageBox.confirm(`确定要启动虚拟机 ${m.hostname} 吗?`, '确认启动', { type: 'info' })
vmLoadingIds.value.add(m.id)
await apiStartVM(m.id)
ElMessage.success('虚拟机已启动')
m.pve_vm_status = 'running'
} catch (e) {
if (e !== 'cancel') ElMessage.error('启动失败')
} finally {
vmLoadingIds.value.delete(m.id)
}
}
async function handleStopVM(m, event) {
event.stopPropagation()
try {
await ElMessageBox.confirm(`确定要关闭虚拟机 ${m.hostname} 吗?`, '确认关闭', { type: 'warning' })
vmLoadingIds.value.add(m.id)
await apiStopVM(m.id)
ElMessage.success('虚拟机已关闭')
m.pve_vm_status = 'stopped'
} catch (e) {
if (e !== 'cancel') ElMessage.error('关闭失败')
} finally {
vmLoadingIds.value.delete(m.id)
}
}
</script>
<style scoped>
@@ -886,6 +947,8 @@ function formatTime(t) {
}
.meta-uptime { color: var(--success); display: inline-flex; align-items: center; gap: 4px; }
.vm-tag { font-size: 11px; height: 20px; padding: 0 8px; display: inline-flex; align-items: center; gap: 4px; }
.vm-btn { font-size: 11px; height: 20px; padding: 0 6px; margin-left: 4px; }
.vm-btn :deep(.el-icon) { font-size: 12px; }
.stats-row {
margin-top: 16px;

347
web/src/views/PVEHosts.vue Normal file
View File

@@ -0,0 +1,347 @@
<template>
<div class="page">
<div class="page-header">
<h2 class="page-title">
<el-icon><Cpu /></el-icon>
PVE 主机管理
</h2>
<el-button type="primary" :icon="Plus" @click="openEdit()">添加 PVE 主机</el-button>
</div>
<!-- PVE 主机列表 -->
<div class="pve-list" v-if="hosts.length">
<div v-for="h in hosts" :key="h.id" class="pve-card">
<div class="pve-card-header">
<div class="pve-name">
<el-icon><Cpu /></el-icon>
<span>{{ h.name }}</span>
</div>
<div class="pve-status" :class="h.status">
<span class="status-dot" :class="h.status"></span>
<span>{{ statusText(h.status) }}</span>
</div>
</div>
<div class="pve-info">
<div class="info-row">
<span class="info-label">地址</span>
<span class="info-value">{{ h.hostname }}:{{ h.port }}</span>
</div>
<div class="info-row">
<span class="info-label">节点</span>
<span class="info-value">{{ h.node_name }}</span>
</div>
<div class="info-row">
<span class="info-label">用户</span>
<span class="info-value">{{ h.username }}</span>
</div>
<div class="info-row">
<span class="info-label">SSL 验证</span>
<span class="info-value">{{ h.verify_ssl ? '启用' : '跳过' }}</span>
</div>
</div>
<div class="pve-actions">
<el-button text :icon="View" @click="checkStatus(h)">检测</el-button>
<el-button text :icon="Edit" @click="openEdit(h)">编辑</el-button>
<el-button text type="danger" :icon="Delete" @click="remove(h)">删除</el-button>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty v-else description="暂无 PVE 主机,点击上方按钮添加">
<el-button type="primary" :icon="Plus" @click="openEdit()">添加 PVE 主机</el-button>
</el-empty>
<!-- 编辑/添加对话框 -->
<el-dialog
v-model="dialogVisible"
:title="editing.id ? '编辑 PVE 主机' : '添加 PVE 主机'"
width="500px"
:close-on-click-modal="false"
class="modern-dialog"
>
<el-form ref="formRef" :model="editing" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="editing.name" placeholder="如:家用 PVE">
<template #prefix><el-icon><Cpu /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="主机地址" prop="hostname">
<el-input v-model="editing.hostname" placeholder="IP 或域名,如 192.168.1.10">
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input-number v-model="editing.port" :min="1" :max="65535" style="width:100%" />
</el-form-item>
<el-form-item label="节点名称" prop="node_name">
<el-input v-model="editing.node_name" placeholder="默认 pve">
<template #prefix><el-icon><SetUp /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="editing.username" placeholder="如root@pam">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="editing.password" type="password" show-password placeholder="Proxmox 登录密码">
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="SSL 验证">
<el-switch
v-model="editing.verify_ssl"
active-text="启用"
inactive-text="跳过"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :icon="Check" @click="save" :loading="saving">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Cpu, Link, SetUp, User, Lock, Check,
View, Edit, Delete,
} from '@element-plus/icons-vue'
import {
fetchPVEHosts, createPVEHost, updatePVEHost, deletePVEHost, fetchPVEHostStatus,
} from '@/api'
const hosts = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref()
const editing = ref({
id: null,
name: '',
hostname: '',
port: 8006,
node_name: 'pve',
username: '',
password: '',
verify_ssl: false,
})
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
hostname: [{ required: true, message: '请输入主机地址', trigger: 'blur' }],
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
node_name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{
validator: (rule, value, callback) => {
if (!editing.value.id && !value) {
callback(new Error('请输入密码'))
} else {
callback()
}
},
trigger: 'blur'
}],
}
function resetEditing() {
editing.value = {
id: null,
name: '',
hostname: '',
port: 8006,
node_name: 'pve',
username: '',
password: '',
verify_ssl: false,
}
}
function openEdit(host = null) {
if (host) {
editing.value = { ...host, password: '' }
} else {
resetEditing()
}
dialogVisible.value = true
}
async function save() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const data = { ...editing.value }
if (editing.value.id) {
await updatePVEHost(editing.value.id, data)
ElMessage.success('更新成功')
} else {
await createPVEHost(data)
ElMessage.success('添加成功')
}
dialogVisible.value = false
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || '操作失败')
} finally {
saving.value = false
}
}
async function remove(host) {
try {
await ElMessageBox.confirm(
`确定删除 PVE 主机 "${host.name}" 吗?关联的虚拟机配置将被清空。`,
'确认删除',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deletePVEHost(host.id)
ElMessage.success('删除成功')
load()
} catch (e) {
if (e !== 'cancel') {
ElMessage.error(e.response?.data?.error || '删除失败')
}
}
}
async function checkStatus(host) {
try {
ElMessage.info('正在检测连接...')
const { data } = await fetchPVEHostStatus(host.id)
host.status = data.status === 'online' ? 'online' : 'offline'
if (data.status === 'online') {
ElMessage.success('连接正常')
} else {
ElMessage.error('连接失败:' + (data.error || '未知错误'))
}
} catch (e) {
host.status = 'offline'
ElMessage.error('检测失败')
}
}
function statusText(status) {
const map = { online: '在线', offline: '离线', checking: '检测中', unknown: '未知' }
return map[status] || '未知'
}
async function load() {
try {
const { data } = await fetchPVEHosts()
hosts.value = data.map(h => ({ ...h, status: 'checking' }))
// 自动检测所有 PVE 主机状态
await Promise.all(hosts.value.map(async (h) => {
try {
const { data } = await fetchPVEHostStatus(h.id)
h.status = data.status === 'online' ? 'online' : 'offline'
} catch (e) {
h.status = 'offline'
}
}))
} catch (e) {
ElMessage.error('加载失败')
}
}
onMounted(load)
</script>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.pve-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 16px;
}
.pve-card {
background: var(--card-bg);
border-radius: var(--radius);
border: 1px solid var(--border);
padding: 16px;
transition: all .2s ease;
}
.pve-card:hover {
box-shadow: var(--shadow-card);
}
.pve-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.pve-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 16px;
color: var(--text-primary);
}
.pve-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
.pve-status.online { color: var(--success); }
.pve-status.offline { color: var(--danger); }
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.status-dot.online { background: var(--success); }
.status-dot.offline { background: var(--danger); }
.pve-info {
margin-bottom: 12px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 13px;
}
.info-label {
color: var(--text-muted);
}
.info-value {
color: var(--text-secondary);
font-family: monospace;
}
.pve-actions {
display: flex;
gap: 4px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
</style>