feat: PVE 虚拟机管理功能 (F001)

新增 PVE 虚拟机管理功能,允许通过管理面板直接启动/关闭 PVE 宿主机上的虚拟机。

后端:
- 新增 pve_hosts 表存储 PVE 主机配置(密码加密存储)
- 机器表扩展 pve_host_id 和 pve_vmid 字段
- 实现 PVE 主机 CRUD API
- 实现 PVE API 客户端(认证/启动/停止/状态查询)
- 虚拟机操作 API: /api/machines/:id/vm-status/start/stop

前端:
- 新增 PVE 主机管理页面 (/pve-hosts)
- 机器编辑弹窗增加 PVE 配置区块
- 机器详情页增加虚拟机启动/关闭按钮
- 机器列表页显示虚拟机运行状态

安全:
- 密码使用 AES-GCM 加密存储,不返回明文
- 仅管理员可访问 PVE 相关功能

[宪宪/glm-5 🐾]
This commit is contained in:
openclaw
2026-04-20 13:07:22 +08:00
parent c9e248d453
commit 8ef3e8c631
12 changed files with 1019 additions and 14 deletions

View File

@@ -107,6 +107,20 @@ func migrate() error {
)`,
`CREATE INDEX IF NOT EXISTS idx_offline_logs_machine ON offline_logs(machine_id)`,
`CREATE INDEX IF NOT EXISTS idx_offline_logs_started ON offline_logs(started_at)`,
// PVE 相关表
`CREATE TABLE IF NOT EXISTS pve_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
hostname TEXT NOT NULL,
port INTEGER DEFAULT 8006,
username TEXT NOT NULL,
password_enc TEXT NOT NULL,
verify_ssl INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`ALTER TABLE machines ADD COLUMN pve_host_id INTEGER REFERENCES pve_hosts(id) ON DELETE SET NULL`,
`ALTER TABLE machines ADD COLUMN pve_vmid TEXT`,
}
for _, s := range stmts {
if _, err := DB.Exec(s); err != nil {

View File

@@ -31,6 +31,8 @@ func sanitizeMachine(m *models.Machine) {
m.ListenPorts = models.NullString{}
m.CreatedAt = time.Time{}
m.UpdatedAt = time.Time{}
m.PVEHostID = nil
m.PVEVMID = models.NullString{}
}
func (h *MachineHandler) List(c *gin.Context) {
@@ -38,7 +40,7 @@ func (h *MachineHandler) List(c *gin.Context) {
osFilter := c.Query("os_type")
search := c.Query("search")
query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at FROM machines WHERE 1=1`
query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at, pve_host_id, pve_vmid FROM machines WHERE 1=1`
args := []interface{}{}
if osFilter != "" {
query += ` AND os_type = ?`
@@ -67,7 +69,7 @@ func (h *MachineHandler) List(c *gin.Context) {
var m models.Machine
var lp, ss *time.Time
var lo *time.Time
err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt)
err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt, &m.PVEHostID, &m.PVEVMID)
if err != nil {
continue
}
@@ -116,8 +118,8 @@ func (h *MachineHandler) Get(c *gin.Context) {
var m models.Machine
var lp, ss *time.Time
var lo *time.Time
err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at FROM machines WHERE id = ?`, id).
Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt)
err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at, pve_host_id, pve_vmid FROM machines WHERE id = ?`, id).
Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt, &m.PVEHostID, &m.PVEVMID)
m.LastOfflineAt = lo
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
@@ -205,8 +207,8 @@ func (h *MachineHandler) Create(c *gin.Context) {
passField := models.NullString{}
passField.String = encPass
passField.Valid = encPass != ""
res, err := db.DB.Exec(`INSERT INTO machines (hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passField)
res, err := db.DB.Exec(`INSERT INTO machines (hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, pve_host_id, pve_vmid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passField, m.PVEHostID, m.PVEVMID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -251,8 +253,8 @@ func (h *MachineHandler) Update(c *gin.Context) {
passToSave = encPass
}
_, err = db.DB.Exec(`UPDATE machines SET hostname=?, ip=?, mac=?, os_type=?, os_version=?, notes=?, ssh_port=?, ssh_username=?, ssh_password=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passToSave, id)
_, err = db.DB.Exec(`UPDATE machines SET hostname=?, ip=?, mac=?, os_type=?, os_version=?, notes=?, ssh_port=?, ssh_username=?, ssh_password=?, pve_host_id=?, pve_vmid=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passToSave, m.PVEHostID, m.PVEVMID, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return

266
server/handlers/pve.go Normal file
View File

@@ -0,0 +1,266 @@
package handlers
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"lan-manager/server/db"
"lan-manager/server/middleware"
"lan-manager/server/models"
"lan-manager/server/services"
)
type PVEHandler struct {
service *services.PVEHostService
}
func NewPVEHandler() *PVEHandler {
return &PVEHandler{
service: services.NewPVEHostService(),
}
}
// List 获取 PVE 主机列表
func (h *PVEHandler) List(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdmin(r) {
http.Error(w, "admin only", http.StatusForbidden)
return
}
hosts, err := h.service.GetAll()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 密码不返回给前端
for i := range hosts {
hosts[i].PasswordEnc = ""
}
middleware.JSON(w, hosts)
}
// Get 获取单个 PVE 主机
func (h *PVEHandler) Get(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdmin(r) {
http.Error(w, "admin only", http.StatusForbidden)
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
host, err := h.service.GetByID(id)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
host.PasswordEnc = ""
middleware.JSON(w, host)
}
// Create 创建 PVE 主机
func (h *PVEHandler) Create(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdmin(r) {
http.Error(w, "admin only", http.StatusForbidden)
return
}
var host models.PVEHost
if err := json.NewDecoder(r.Body).Decode(&host); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if host.Name == "" || host.Hostname == "" || host.Username == "" || host.Password == "" {
http.Error(w, "missing required fields", http.StatusBadRequest)
return
}
if host.Port == 0 {
host.Port = 8006
}
if err := h.service.Create(&host); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
host.PasswordEnc = ""
middleware.JSON(w, host)
}
// Update 更新 PVE 主机
func (h *PVEHandler) Update(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdmin(r) {
http.Error(w, "admin only", http.StatusForbidden)
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var host models.PVEHost
if err := json.NewDecoder(r.Body).Decode(&host); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
host.ID = id
// 如果密码为空,说明不更新密码
if host.Password == "" {
existing, err := h.service.GetByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
host.PasswordEnc = existing.PasswordEnc
}
if err := h.service.Update(&host); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
host.PasswordEnc = ""
middleware.JSON(w, host)
}
// Delete 删除 PVE 主机
func (h *PVEHandler) Delete(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdmin(r) {
http.Error(w, "admin only", http.StatusForbidden)
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := h.service.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
middleware.JSON(w, map[string]string{"message": "deleted"})
}
// VMStatus 获取虚拟机状态
func (h *PVEHandler) VMStatus(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdmin(r) {
http.Error(w, "admin only", http.StatusForbidden)
return
}
machineID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var pveHostID *int64
var pveVMID sql.NullString
err = db.DB.QueryRow(`SELECT pve_host_id, pve_vmid FROM machines WHERE id = ?`, machineID).Scan(&pveHostID, &pveVMID)
if err != nil {
http.Error(w, "machine not found", http.StatusNotFound)
return
}
if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" {
http.Error(w, "machine not linked to PVE VM", http.StatusBadRequest)
return
}
status, err := h.service.GetVMStatus(*pveHostID, pveVMID.String)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
middleware.JSON(w, status)
}
// VMStart 启动虚拟机
func (h *PVEHandler) VMStart(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdmin(r) {
http.Error(w, "admin only", http.StatusForbidden)
return
}
machineID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var pveHostID *int64
var pveVMID sql.NullString
err = db.DB.QueryRow(`SELECT pve_host_id, pve_vmid FROM machines WHERE id = ?`, machineID).Scan(&pveHostID, &pveVMID)
if err != nil {
http.Error(w, "machine not found", http.StatusNotFound)
return
}
if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" {
http.Error(w, "machine not linked to PVE VM", http.StatusBadRequest)
return
}
if err := h.service.StartVM(*pveHostID, pveVMID.String); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
middleware.JSON(w, map[string]string{"message": "started"})
}
// VMStop 停止虚拟机
func (h *PVEHandler) VMStop(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAdmin(r) {
http.Error(w, "admin only", http.StatusForbidden)
return
}
machineID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var pveHostID *int64
var pveVMID sql.NullString
err = db.DB.QueryRow(`SELECT pve_host_id, pve_vmid FROM machines WHERE id = ?`, machineID).Scan(&pveHostID, &pveVMID)
if err != nil {
http.Error(w, "machine not found", http.StatusNotFound)
return
}
if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" {
http.Error(w, "machine not linked to PVE VM", http.StatusBadRequest)
return
}
if err := h.service.StopVM(*pveHostID, pveVMID.String); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
middleware.JSON(w, map[string]string{"message": "stopped"})
}

View File

@@ -94,6 +94,19 @@ func main() {
admin.GET("/logs", handlers.NewLogHandler().List)
// PVE 主机管理
pveH := handlers.NewPVEHandler()
admin.GET("/pve/hosts", pveH.List)
admin.GET("/pve/hosts/:id", pveH.Get)
admin.POST("/pve/hosts", pveH.Create)
admin.PUT("/pve/hosts/:id", pveH.Update)
admin.DELETE("/pve/hosts/:id", pveH.Delete)
// 虚拟机操作
admin.GET("/machines/:id/vm-status", pveH.VMStatus)
admin.POST("/machines/:id/vm-start", pveH.VMStart)
admin.POST("/machines/:id/vm-stop", pveH.VMStop)
eh := handlers.NewExportHandler()
admin.GET("/export", eh.Export)
admin.POST("/import", eh.Import)

View File

@@ -58,6 +58,10 @@ type Machine struct {
TotalOfflineSeconds int `json:"total_offline_seconds"`
LastOfflineAt *time.Time `json:"last_offline_at"`
LastOfflineReason NullString `json:"last_offline_reason"`
// PVE 相关字段
PVEHostID *int64 `json:"pve_host_id"`
PVEVMID NullString `json:"pve_vmid"`
PVEVMStatus string `json:"pve_vm_status"` // "running", "stopped", ""
}
type OfflineLog struct {
@@ -125,3 +129,25 @@ type SSHInfoResult struct {
RawMemoryInfo string `json:"raw_memory_info"`
RawDiskInfo string `json:"raw_disk_info"`
}
type PVEHost struct {
ID int64 `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Port int `json:"port"`
Username string `json:"username"`
PasswordEnc string `json:"password_enc"`
VerifySSL bool `json:"verify_ssl"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 运行时使用,不存储
Password string `json:"-"`
}
type PVEVMStatus struct {
Status string `json:"status"` // "running", "stopped"
Uptime int64 `json:"uptime"`
CPU float64 `json:"cpu"`
MemoryUsed int64 `json:"memory_used"`
MemoryTotal int64 `json:"memory_total"`
}

425
server/services/pve.go Normal file
View File

@@ -0,0 +1,425 @@
package services
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"time"
"lan-manager/server/models"
"lan-manager/server/utils"
)
type PVEClient struct {
Host string
Port int
Username string
Password string
VerifySSL bool
CSRFToken string
Cookie string
httpClient *http.Client
}
func NewPVEClient(host *models.PVEHost, password string) *PVEClient {
jar, _ := cookiejar.New(nil)
client := &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
}
if !host.VerifySSL {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
return &PVEClient{
Host: host.Hostname,
Port: host.Port,
Username: host.Username,
Password: password,
VerifySSL: host.VerifySSL,
httpClient: client,
}
}
func (c *PVEClient) baseURL() string {
scheme := "https"
if !c.VerifySSL {
scheme = "http"
}
return fmt.Sprintf("%s://%s:%d", scheme, c.Host, c.Port)
}
// Login 获取 PVE 认证ticket
func (c *PVEClient) Login() error {
loginURL := c.baseURL() + "/api2/json/access/ticket"
data := fmt.Sprintf("username=%s&password=%s", c.Username, c.Password)
req, err := http.NewRequest("POST", loginURL, strings.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("login failed, status: %d", resp.StatusCode)
}
var result struct {
Data struct {
CSRFToken string `json:"CSRFPreventionToken"`
Cookie string `json:"PVEAuthCookie"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
c.CSRFToken = result.Data.CSRFToken
c.Cookie = result.Data.Cookie
return nil
}
// GetVMStatus 获取虚拟机状态
func (c *PVEClient) GetVMStatus(vmid string) (*models.PVEVMStatus, error) {
if c.CSRFToken == "" {
if err := c.Login(); err != nil {
return nil, err
}
}
// 获取 node 名称(通常是 pve
statusURL := c.baseURL() + "/api2/json/nodes/pve/qemu/" + vmid + "/status"
req, err := http.NewRequest("GET", statusURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Cookie", c.Cookie)
req.Header.Set("CSRFPreventionToken", c.CSRFToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
// token 过期,重新登录
if err := c.Login(); err != nil {
return nil, err
}
return c.GetVMStatus(vmid)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get vm status failed, status: %d", resp.StatusCode)
}
var result struct {
Data struct {
Status string `json:"status"`
Uptime int64 `json:"uptime"`
CPU float64 `json:"cpu"`
MemoryUsed int64 `json:"mem"`
MemoryTotal int64 `json:"maxmem"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("decode failed: %v, body: %s", err, string(body))
}
return &models.PVEVMStatus{
Status: result.Data.Status,
Uptime: result.Data.Uptime,
CPU: result.Data.CPU * 100,
MemoryUsed: result.Data.MemoryUsed,
MemoryTotal: result.Data.MemoryTotal,
}, nil
}
// StartVM 启动虚拟机
func (c *PVEClient) StartVM(vmid string) error {
if c.CSRFToken == "" {
if err := c.Login(); err != nil {
return err
}
}
startURL := c.baseURL() + "/api2/json/nodes/pve/qemu/" + vmid + "/status/start"
req, err := http.NewRequest("POST", startURL, nil)
if err != nil {
return err
}
req.Header.Set("Cookie", c.Cookie)
req.Header.Set("CSRFPreventionToken", c.CSRFToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
if err := c.Login(); err != nil {
return err
}
return c.StartVM(vmid)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("start vm failed, status: %d, body: %s", resp.StatusCode, string(body))
}
return nil
}
// StopVM 停止虚拟机
func (c *PVEClient) StopVM(vmid string) error {
if c.CSRFToken == "" {
if err := c.Login(); err != nil {
return err
}
}
stopURL := c.baseURL() + "/api2/json/nodes/pve/qemu/" + vmid + "/status/stop"
req, err := http.NewRequest("POST", stopURL, nil)
if err != nil {
return err
}
req.Header.Set("Cookie", c.Cookie)
req.Header.Set("CSRFPreventionToken", c.CSRFToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == 401 {
if err := c.Login(); err != nil {
return err
}
return c.StopVM(vmid)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("stop vm failed, status: %d, body: %s", resp.StatusCode, string(body))
}
return nil
}
// GetVMList 获取虚拟机列表
func (c *PVEClient) GetVMList() ([]struct {
VMID string `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
}, error) {
if c.CSRFToken == "" {
if err := c.Login(); err != nil {
return nil, err
}
}
listURL := c.baseURL() + "/api2/json/cluster/resources?type=vm"
req, err := http.NewRequest("GET", listURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Cookie", c.Cookie)
req.Header.Set("CSRFPreventionToken", c.CSRFToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get vm list failed, status: %d", resp.StatusCode)
}
var result struct {
Data []struct {
VMID string `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// PVEHostService PVE 主机服务
type PVEHostService struct{}
func NewPVEHostService() *PVEHostService {
return &PVEHostService{}
}
// GetAll 获取所有 PVE 主机
func (s *PVEHostService) GetAll() ([]models.PVEHost, error) {
rows, err := DB.Query(`SELECT id, name, hostname, port, username, password_enc, verify_ssl, created_at, updated_at FROM pve_hosts ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var hosts []models.PVEHost
for rows.Next() {
var h models.PVEHost
var verifySSL int
if err := rows.Scan(&h.ID, &h.Name, &h.Hostname, &h.Port, &h.Username, &h.PasswordEnc, &verifySSL, &h.CreatedAt, &h.UpdatedAt); err != nil {
return nil, err
}
h.VerifySSL = verifySSL == 1
hosts = append(hosts, h)
}
return hosts, nil
}
// GetByID 根据 ID 获取 PVE 主机
func (s *PVEHostService) GetByID(id int64) (*models.PVEHost, error) {
var h models.PVEHost
var verifySSL int
err := DB.QueryRow(`SELECT id, name, hostname, port, username, password_enc, verify_ssl, created_at, updated_at FROM pve_hosts WHERE id = ?`, id).
Scan(&h.ID, &h.Name, &h.Hostname, &h.Port, &h.Username, &h.PasswordEnc, &verifySSL, &h.CreatedAt, &h.UpdatedAt)
if err != nil {
return nil, err
}
h.VerifySSL = verifySSL == 1
return &h, nil
}
// Create 创建 PVE 主机
func (s *PVEHostService) Create(host *models.PVEHost) error {
// 加密密码
encPassword, err := utils.Encrypt(host.Password)
if err != nil {
return fmt.Errorf("encrypt password failed: %w", err)
}
result, err := DB.Exec(`INSERT INTO pve_hosts (name, hostname, port, username, password_enc, verify_ssl) VALUES (?, ?, ?, ?, ?, ?)`,
host.Name, host.Hostname, host.Port, host.Username, encPassword, boolToInt(host.VerifySSL))
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
host.ID = id
return nil
}
// Update 更新 PVE 主机
func (s *PVEHostService) Update(host *models.PVEHost) error {
if host.Password != "" {
// 密码有更新,重新加密
encPassword, err := utils.Encrypt(host.Password)
if err != nil {
return fmt.Errorf("encrypt password failed: %w", err)
}
_, err = DB.Exec(`UPDATE pve_hosts SET name=?, hostname=?, port=?, username=?, password_enc=?, verify_ssl=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
host.Name, host.Hostname, host.Port, host.Username, encPassword, boolToInt(host.VerifySSL), host.ID)
return err
}
_, err := DB.Exec(`UPDATE pve_hosts SET name=?, hostname=?, port=?, username=?, verify_ssl=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
host.Name, host.Hostname, host.Port, host.Username, boolToInt(host.VerifySSL), host.ID)
return err
}
// Delete 删除 PVE 主机
func (s *PVEHostService) Delete(id int64) error {
// 先清除关联机器的 PVE 字段
if _, err := DB.Exec(`UPDATE machines SET pve_host_id = NULL, pve_vmid = NULL WHERE pve_host_id = ?`, id); err != nil {
return err
}
_, err := DB.Exec(`DELETE FROM pve_hosts WHERE id = ?`, id)
return err
}
// GetDecryptedPassword 获取解密后的密码
func (s *PVEHostService) GetDecryptedPassword(host *models.PVEHost) (string, error) {
return utils.Decrypt(host.PasswordEnc)
}
// GetVMStatus 获取虚拟机状态
func (s *PVEHostService) GetVMStatus(hostID int64, vmid string) (*models.PVEVMStatus, error) {
host, err := s.GetByID(hostID)
if err != nil {
return nil, err
}
password, err := s.GetDecryptedPassword(host)
if err != nil {
return nil, err
}
client := NewPVEClient(host, password)
return client.GetVMStatus(vmid)
}
// StartVM 启动虚拟机
func (s *PVEHostService) StartVM(hostID int64, vmid string) error {
host, err := s.GetByID(hostID)
if err != nil {
return err
}
password, err := s.GetDecryptedPassword(host)
if err != nil {
return err
}
client := NewPVEClient(host, password)
return client.StartVM(vmid)
}
// StopVM 停止虚拟机
func (s *PVEHostService) StopVM(hostID int64, vmid string) error {
host, err := s.GetByID(hostID)
if err != nil {
return err
}
password, err := s.GetDecryptedPassword(host)
if err != nil {
return err
}
client := NewPVEClient(host, password)
return client.StopVM(vmid)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View File

@@ -62,3 +62,15 @@ export const exportData = () => api.get('/export', { responseType: 'blob' })
export const importData = (formData) => api.post('/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
// PVE 主机管理
export const fetchPVEHosts = () => api.get('/pve/hosts')
export const fetchPVEHost = (id) => api.get(`/pve/hosts/${id}`)
export const createPVEHost = (data) => api.post('/pve/hosts', data)
export const updatePVEHost = (id, data) => api.put(`/pve/hosts/${id}`, data)
export const deletePVEHost = (id) => api.delete(`/pve/hosts/${id}`)
// 虚拟机操作
export const fetchVMStatus = (machineId) => api.get(`/machines/${machineId}/vm-status`)
export const startVM = (machineId) => api.post(`/machines/${machineId}/vm-start`)
export const stopVM = (machineId) => api.post(`/machines/${machineId}/vm-stop`)

View File

@@ -18,6 +18,10 @@
<el-icon><Document /></el-icon>
<span>操作日志</span>
</router-link>
<router-link v-if="isAdmin" to="/pve-hosts" class="nav-item" active-class="active">
<el-icon><Cpu /></el-icon>
<span>PVE 主机</span>
</router-link>
</nav>
<div class="theme-toggle" @click="toggleTheme">
<el-icon class="theme-icon"><component :is="isDark ? Sunny : Moon" /></el-icon>
@@ -46,7 +50,7 @@
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Monitor, Share, Document, User, SwitchButton, Sunny, Moon } from '@element-plus/icons-vue'
import { Monitor, Share, Document, User, SwitchButton, Sunny, Moon, Cpu } from '@element-plus/icons-vue'
import { getAuth, refreshAuth } from '@/router'
import { logout as apiLogout } from '@/api'
import { ElMessage } from 'element-plus'

View File

@@ -37,6 +37,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

@@ -21,6 +21,8 @@
</div>
</div>
<div class="header-actions" v-if="isAdmin">
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="success" :icon="VideoPlay" :loading="vmLoading" @click="startVM">启动</el-button>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="warning" :icon="VideoPause" :loading="vmLoading" @click="stopVM">关闭</el-button>
<el-button text :icon="Edit" @click="openEdit(machine)">编辑</el-button>
<el-button text type="danger" :icon="Delete" @click="delMachine">删除</el-button>
</div>
@@ -301,13 +303,14 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Edit, Delete, Plus, Refresh, Right, Timer } from '@element-plus/icons-vue'
import { ArrowLeft, Edit, Delete, Plus, Refresh, Right, Timer, VideoPlay, VideoPause } from '@element-plus/icons-vue'
import {
fetchMachine, updateMachine, deleteMachine,
fetchServices, createService, updateService, deleteService,
fetchRelationships, createRelationship, updateRelationship, deleteRelationship,
sshInfo, syncSSH, fetchMachines, fetchOfflineLogs,
checkAuth, uiRefreshInterval,
startVM as apiStartVM, stopVM as apiStopVM, fetchVMStatus,
} from '@/api'
import { getAuth } from '@/router'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -320,6 +323,7 @@ const machineId = route.params.id
const machine = ref(null)
const services = ref([])
const relationships = ref([])
const vmLoading = ref(false)
const allMachines = ref([])
const offlineLogs = ref([])
@@ -449,6 +453,27 @@ async function saveMachine() {
loadMachine()
}
async function startVM() {
vmLoading.value = true
try {
await apiStartVM(machineId)
ElMessage.success('虚拟机已启动')
loadMachine()
} catch (e) {}
vmLoading.value = false
}
async function stopVM() {
try {
await ElMessageBox.confirm('确定要关闭虚拟机吗?', '确认关闭', { type: 'warning' })
vmLoading.value = true
await apiStopVM(machineId)
ElMessage.success('虚拟机已关闭')
loadMachine()
} catch (e) {}
vmLoading.value = false
}
async function delMachine() {
try {
await ElMessageBox.confirm('确定删除该机器?', '提示', { type: 'warning' })

View File

@@ -33,6 +33,9 @@
<span class="meta-item">{{ m.os_type }}</span>
<span v-if="m.os_version" class="meta-item">{{ m.os_version }}</span>
<span v-if="m.uptime" class="meta-uptime">{{ m.uptime }}</span>
<span v-if="isAdmin && m.pve_host_id && m.pve_vmid" class="meta-pve">
<span class="vm-status" :class="m.pve_vm_status">{{ m.pve_vm_status === 'running' ? '🟢 VM运行中' : '🔴 VM已停止' }}</span>
</span>
</div>
</div>
@@ -114,6 +117,15 @@
<el-form-item label="备注">
<el-input v-model="editing.notes" type="textarea" :rows="3" placeholder="记录用途、负责人等信息" />
</el-form-item>
<el-divider content-position="left">PVE 配置可选</el-divider>
<el-form-item label="PVE 主机">
<el-select v-model="editing.pve_host_id" placeholder="选择 PVE 主机" clearable style="width:100%">
<el-option v-for="h in pveHosts" :key="h.id" :label="h.name" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="虚拟机 ID">
<el-input v-model="editing.pve_vmid" placeholder="如 101" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -127,23 +139,31 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, Search } from '@element-plus/icons-vue'
import { fetchMachines, createMachine, updateMachine, checkAuth, uiRefreshInterval, exportData, importData } from '@/api'
import { fetchMachines, createMachine, updateMachine, checkAuth, uiRefreshInterval, exportData, importData, fetchPVEHosts } from '@/api'
import { getAuth } from '@/router'
import { ElMessage } from 'element-plus'
const router = useRouter()
const isAdmin = getAuth().is_admin
const machines = ref([])
const pveHosts = ref([])
const search = ref('')
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: '' })
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)
let timer = null
onMounted(async () => {
await load()
await checkAuth()
// 加载 PVE 主机列表
if (isAdmin) {
try {
const res = await fetchPVEHosts()
pveHosts.value = res.data
} catch (e) {}
}
const interval = uiRefreshInterval || 10000
if (interval > 0) {
timer = setInterval(load, interval)
@@ -165,8 +185,8 @@ function goDetail(id) {
function openEdit(item) {
editing.value = item
? { ...item, ssh_port: item.ssh_port || 22, ssh_username: item.ssh_username || '', ssh_password: '' }
: { hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '' }
? { ...item, ssh_port: item.ssh_port || 22, ssh_username: item.ssh_username || '', ssh_password: '', pve_host_id: item.pve_host_id || null, pve_vmid: item.pve_vmid || '' }
: { hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '', pve_host_id: null, pve_vmid: '' }
dialogVisible.value = true
}

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

@@ -0,0 +1,192 @@
<template>
<div class="page">
<div class="toolbar">
<h2 class="page-title">PVE 主机管理</h2>
<el-button type="primary" :icon="Plus" @click="openEdit()">添加主机</el-button>
</div>
<el-empty v-if="!hosts.length" description="暂无 PVE 主机" />
<div class="hosts-grid" v-if="hosts.length">
<div v-for="h in hosts" :key="h.id" class="host-card">
<div class="host-header">
<el-icon class="host-icon"><Monitor /></el-icon>
<span class="host-name">{{ h.name }}</span>
</div>
<div class="host-info">
<div class="info-row">
<span class="label">地址</span>
<span class="value">{{ h.hostname }}:{{ h.port }}</span>
</div>
<div class="info-row">
<span class="label">用户</span>
<span class="value">{{ h.username }}</span>
</div>
</div>
<div class="host-actions">
<el-button size="small" @click="openEdit(h)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(h)">删除</el-button>
</div>
</div>
</div>
<!-- Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="editing.id ? '编辑主机' : '添加主机'" width="480px" class="modern-dialog" destroy-on-close>
<el-form :model="editing" label-width="90px">
<el-form-item label="名称" required>
<el-input v-model="editing.name" placeholder="如 PVE-01" />
</el-form-item>
<el-form-item label="地址" required>
<el-input v-model="editing.hostname" placeholder="192.168.1.100" />
</el-form-item>
<el-form-item label="端口">
<el-input-number v-model="editing.port" :min="1" :max="65535" style="width:100%" />
</el-form-item>
<el-form-item label="用户名" required>
<el-input v-model="editing.username" placeholder="root@pam 或 root@vmbr0" />
</el-form-item>
<el-form-item label="密码" :required="!editing.id">
<el-input v-model="editing.password" type="password" show-password :placeholder="editing.id ? '留空保持不变' : '必填'" />
</el-form-item>
<el-form-item label="SSL 验证">
<el-switch v-model="editing.verify_ssl" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveHost">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Plus, Monitor } from '@element-plus/icons-vue'
import { fetchPVEHosts, createPVEHost, updatePVEHost, deletePVEHost } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const hosts = ref([])
const dialogVisible = ref(false)
const editing = ref({ name: '', hostname: '', port: 8006, username: '', password: '', verify_ssl: false })
onMounted(() => {
load()
})
async function load() {
const res = await fetchPVEHosts()
hosts.value = res.data
}
function openEdit(item) {
editing.value = item
? { ...item, password: '' }
: { name: '', hostname: '', port: 8006, username: '', password: '', verify_ssl: false }
dialogVisible.value = true
}
async function saveHost() {
if (!editing.value.name || !editing.value.hostname || !editing.value.username) {
ElMessage.warning('请填写必填项')
return
}
if (!editing.value.id && !editing.value.password) {
ElMessage.warning('请填写密码')
return
}
try {
if (editing.value.id) {
await updatePVEHost(editing.value.id, editing.value)
} else {
await createPVEHost(editing.value)
}
ElMessage.success('保存成功')
dialogVisible.value = false
load()
} catch (e) {
// error handled by interceptor
}
}
async function handleDelete(host) {
try {
await ElMessageBox.confirm(`确定要删除主机 "${host.name}" 吗?`, '确认删除', { type: 'warning' })
await deletePVEHost(host.id)
ElMessage.success('删除成功')
load()
} catch (e) {
if (e !== 'cancel') {
// error handled by interceptor
}
}
}
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.hosts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.host-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.host-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.host-icon {
font-size: 20px;
color: var(--el-color-primary);
}
.host-name {
font-size: 16px;
font-weight: 600;
}
.host-info {
margin-bottom: 12px;
}
.info-row {
display: flex;
font-size: 13px;
margin-bottom: 4px;
}
.info-row .label {
color: var(--text-secondary);
width: 50px;
}
.info-row .value {
color: var(--text);
}
.host-actions {
display: flex;
gap: 8px;
}
</style>