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:
@@ -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 {
|
||||
|
||||
@@ -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
266
server/handlers/pve.go
Normal 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"})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
425
server/services/pve.go
Normal 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
|
||||
}
|
||||
@@ -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`)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
192
web/src/views/PVEHosts.vue
Normal 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>
|
||||
Reference in New Issue
Block a user