feat: merge PVE VM management + local features

- Merge PVE (Proxmox VE) virtual machine management from remote
  - PVE host CRUD API
  - VM status query / start / stop operations
  - PVE database tables and models
  - Frontend VM status display and control buttons
  - SPA routing fix for nested asset paths

- Keep local features:
  - Change password functionality
  - Log cleanup service
  - Docker containerization (Dockerfile + docker-compose)
  - Empty machine ping log spam fix
  - settings table for admin password storage

- Update machines handlers to support PVE fields
- Update frontend API client with PVE endpoints
This commit is contained in:
shirainbown
2026-06-18 22:33:45 +08:00
parent af306c183c
commit 6f507b319e
18 changed files with 1619 additions and 22 deletions

68
.dockerignore Normal file
View File

@@ -0,0 +1,68 @@
# Git
.git
.gitignore
.gitattributes
# Node.js
web/node_modules/
web/dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Go
server/lan-manager
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
# 构建产物
lan-manager
lan-manager-darwin
lan-manager-linux
lan-manager.exe
server/static/
# 数据(构建时不需要,运行时通过 volume 挂载)
data/
*.db
*.db-journal
*.db-wal
*.db-shm
# 部署包
deploy/
*.tar.gz
# 日志
*.log
logs/
# Playwright MCP
.playwright-mcp/
# 测试
coverage/
*.cover
*.coverage
# 文档(构建时不需要)
docs/
# 临时文件
tmp/
temp/
*.tmp

16
.qwen/settings.json Normal file
View File

@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Read(//Users/songshiyu/fsdownload/**)",
"Read(//Users/songshiyu/Downloads/**)",
"WebFetch(docs.qwen.ai)",
"WebFetch(github.com)",
"Bash(qwen *)",
"Bash(find *)",
"Bash(npm *)",
"Bash(ls *)",
"Bash(npx *)"
]
},
"$version": 3
}

83
Dockerfile Normal file
View File

@@ -0,0 +1,83 @@
# 构建阶段 1构建前端
FROM node:20-alpine AS web-builder
WORKDIR /app/web
# 复制前端依赖文件
COPY web/package*.json ./
RUN npm ci
# 复制前端源码并构建
COPY web/ ./
RUN npm run build
# 构建阶段 2构建 Go 后端
FROM golang:1.26-alpine AS server-builder
WORKDIR /app/server
# 安装构建依赖
RUN apk add --no-cache git gcc musl-dev
# 复制 Go 模块文件
COPY go.mod go.sum ./
RUN go mod download
# 复制后端源码
COPY server/ ./
# 复制前端构建产物到 static 目录
COPY --from=web-builder /app/web/dist ./static/
# 构建 Go 二进制(静态链接,无 CGO
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/lan-manager .
# 运行阶段:最小化镜像
FROM alpine:3.21
LABEL maintainer="LAN Manager"
LABEL description="局域网机器管理后台"
WORKDIR /app
# 安装必要的运行时依赖
# iputils 提供 ping 命令(用于网络检测)
# ca-certificates 用于 HTTPS 请求
RUN apk add --no-cache \
ca-certificates \
iputils \
libcap \
&& rm -rf /var/cache/apk/*
# 从构建阶段复制二进制文件
COPY --from=server-builder /app/lan-manager /app/lan-manager
# 赋予 ping 权限(不需要 root 也能执行 ICMP ping
RUN setcap cap_net_raw+ep /bin/ping || true
# 创建非 root 用户运行服务
RUN adduser -D -s /bin/sh lanmgr && \
mkdir -p /app/data && \
chown -R lanmgr:lanmgr /app
# 切换到非 root 用户
USER lanmgr
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
# 设置数据目录环境变量
ENV DATA_DIR=/app/data
ENV DB_PATH=/app/data/lan-manager.db
ENV HOST=0.0.0.0
ENV PORT=8080
# 数据卷(持久化 SQLite 数据库)
VOLUME ["/app/data"]
# 启动命令
ENTRYPOINT ["/app/lan-manager"]

View File

@@ -46,6 +46,52 @@ make build
./lan-manager ./lan-manager
``` ```
## Docker 部署(推荐)
### 方式一:使用 Docker Compose推荐
```bash
# 1. 克隆项目后,直接启动
docker-compose up -d
# 2. 查看日志
docker-compose logs -f
# 3. 停止服务
docker-compose down
```
服务将运行在 `http://localhost:8080`
数据持久化在 `./data/` 目录(通过 volume 挂载)。
### 方式二:手动构建和运行
```bash
# 构建镜像
docker build -t lan-manager .
# 运行容器
docker run -d \
--name lan-manager \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-e ADMIN_PASS=your-secure-password \
-e ENCRYPT_KEY=your-encrypt-key \
lan-manager
```
### 方式三:使用预构建镜像(如果已发布到镜像仓库)
```bash
docker run -d \
--name lan-manager \
-p 8080:8080 \
-v $(pwd)/data:/app/data \
-e ADMIN_PASS=your-secure-password \
your-registry/lan-manager:latest
```
## Debian 12 一键部署 ## Debian 12 一键部署
```bash ```bash
@@ -122,6 +168,8 @@ lan-manager/
├── web/ # Vue 前端 ├── web/ # Vue 前端
│ ├── src/ │ ├── src/
│ └── dist/ │ └── dist/
├── Dockerfile # Docker 构建文件
├── docker-compose.yml # Docker Compose 配置
├── Makefile ├── Makefile
├── go.mod ├── go.mod
└── README.md └── README.md
@@ -134,3 +182,10 @@ lan-manager/
- **自动同步 SSH**:若机器已保存 SSH 用户名和密码,后台每次 Ping 检测成功后会自动通过 SSH 抓取系统信息并同步到数据库 - **自动同步 SSH**:若机器已保存 SSH 用户名和密码,后台每次 Ping 检测成功后会自动通过 SSH 抓取系统信息并同步到数据库
- **在线检测机制**:每分钟对所有机器 IP 执行 ICMP Ping3 秒内无响应视为离线 - **在线检测机制**:每分钟对所有机器 IP 执行 ICMP Ping3 秒内无响应视为离线
- **磁盘显示策略**:机器列表卡片仅展示 `/` 根分区占用;详情页展示所有真实磁盘分区 - **磁盘显示策略**:机器列表卡片仅展示 `/` 根分区占用;详情页展示所有真实磁盘分区
## Docker 部署注意事项
1. **数据持久化**:通过 volume 挂载 `./data` 目录到容器,确保数据在容器重建后不丢失
2. **网络访问**:容器需要访问宿主机网络或目标局域网,默认使用 bridge 网络模式
3. **Ping 权限**:容器内可能需要 `NET_RAW` 能力才能执行 ICMP ping已在 Dockerfile 中通过 `setcap` 配置
4. **环境变量**:建议通过 `.env` 文件或 Docker Compose 管理敏感配置

53
docker-compose.yml Normal file
View File

@@ -0,0 +1,53 @@
version: '3.8'
services:
lan-manager:
build:
context: .
dockerfile: Dockerfile
image: lan-manager:latest
container_name: lan-manager
restart: unless-stopped
ports:
- "8080:8080"
volumes:
# 数据持久化SQLite 数据库和上传文件
- ./data:/app/data
environment:
# 基础配置
- HOST=0.0.0.0
- PORT=8080
- DATA_DIR=/app/data
- DB_PATH=/app/data/lan-manager.db
# 管理员配置(⚠️ 生产环境务必修改)
- ADMIN_USER=admin
- ADMIN_PASS=admin
# 安全密钥(⚠️ 生产环境务必修改)
- SESSION_SECRET=lan-manager-secret-change-in-production
- ENCRYPT_KEY=lan-manager-default-key-change-in-production
# 功能配置
- PING_INTERVAL=60
- SSH_TIMEOUT=10
- LOG_RETENTION_DAYS=0
- UI_REFRESH_INTERVAL=10000
# 日志级别
- LOG_LEVEL=info
# 如果需要容器能 ping 通局域网其他机器,可以使用 host 网络模式
# 或者使用 macvlan 网络让容器获得独立 IP
# network_mode: host
# 如果需要 ICMP ping 权限,可以添加 capabilities
# cap_add:
# - NET_RAW
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"database/sql"
"log" "log"
"os" "os"
"strconv" "strconv"
@@ -47,6 +48,14 @@ func Load() *Config {
return cfg return cfg
} }
func LoadPasswordFromDB(cfg *Config, db *sql.DB) {
var stored string
err := db.QueryRow(`SELECT value FROM settings WHERE key = 'admin_password'`).Scan(&stored)
if err == nil && stored != "" {
cfg.AdminPass = stored
}
}
func getEnv(key, defaultValue string) string { func getEnv(key, defaultValue string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v

View File

@@ -107,6 +107,26 @@ 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_machine ON offline_logs(machine_id)`,
`CREATE INDEX IF NOT EXISTS idx_offline_logs_started ON offline_logs(started_at)`, `CREATE INDEX IF NOT EXISTS idx_offline_logs_started ON offline_logs(started_at)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)`,
// 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,
node_name TEXT DEFAULT 'pve',
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`,
`ALTER TABLE machines ADD COLUMN pve_vm_status TEXT`,
} }
for _, s := range stmts { for _, s := range stmts {
if _, err := DB.Exec(s); err != nil { if _, err := DB.Exec(s); err != nil {

View File

@@ -5,7 +5,9 @@ import (
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"lan-manager/server/config" "lan-manager/server/config"
"lan-manager/server/db"
"lan-manager/server/middleware" "lan-manager/server/middleware"
"lan-manager/server/models" "lan-manager/server/models"
) )
@@ -24,7 +26,11 @@ func (h *AuthHandler) Login(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if req.Username != h.Cfg.AdminUser || req.Password != h.Cfg.AdminPass { if req.Username != h.Cfg.AdminUser {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if !checkPassword(req.Password, h.Cfg.AdminPass) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return return
} }
@@ -53,3 +59,41 @@ func (h *AuthHandler) Me(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"is_admin": true, "username": user.(string), "ui_refresh_interval": h.Cfg.UIRefreshInterval}) c.JSON(http.StatusOK, gin.H{"is_admin": true, "username": user.(string), "ui_refresh_interval": h.Cfg.UIRefreshInterval})
} }
func (h *AuthHandler) ChangePassword(c *gin.Context) {
var req models.ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !checkPassword(req.OldPassword, h.Cfg.AdminPass) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "旧密码错误"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
_, err = db.DB.Exec(`INSERT INTO settings (key, value) VALUES ('admin_password', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, string(hashed))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存密码失败"})
return
}
h.Cfg.AdminPass = string(hashed)
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
}
func checkPassword(plain, stored string) bool {
if stored == "" {
return plain == ""
}
// bcrypt hashes start with "$2a$", "$2b$", or "$2x$" and have a fixed format
if len(stored) > 4 && (stored[:4] == "$2a$" || stored[:4] == "$2b$" || stored[:4] == "$2x$") {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(plain)) == nil
}
return plain == stored
}

View File

@@ -31,6 +31,8 @@ func sanitizeMachine(m *models.Machine) {
m.ListenPorts = models.NullString{} m.ListenPorts = models.NullString{}
m.CreatedAt = time.Time{} m.CreatedAt = time.Time{}
m.UpdatedAt = time.Time{} m.UpdatedAt = time.Time{}
m.PVEHostID = nil
m.PVEVMID = models.NullString{}
} }
func (h *MachineHandler) List(c *gin.Context) { func (h *MachineHandler) List(c *gin.Context) {
@@ -38,7 +40,7 @@ func (h *MachineHandler) List(c *gin.Context) {
osFilter := c.Query("os_type") osFilter := c.Query("os_type")
search := c.Query("search") 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{}{} args := []interface{}{}
if osFilter != "" { if osFilter != "" {
query += ` AND os_type = ?` query += ` AND os_type = ?`
@@ -67,7 +69,7 @@ func (h *MachineHandler) List(c *gin.Context) {
var m models.Machine var m models.Machine
var lp, ss *time.Time var lp, ss *time.Time
var lo *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 { if err != nil {
continue continue
} }
@@ -116,8 +118,8 @@ func (h *MachineHandler) Get(c *gin.Context) {
var m models.Machine var m models.Machine
var lp, ss *time.Time var lp, ss *time.Time
var lo *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). 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) 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 m.LastOfflineAt = lo
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
@@ -205,8 +207,8 @@ func (h *MachineHandler) Create(c *gin.Context) {
passField := models.NullString{} passField := models.NullString{}
passField.String = encPass passField.String = encPass
passField.Valid = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passField, m.PVEHostID, m.PVEVMID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -235,10 +237,17 @@ func (h *MachineHandler) Update(c *gin.Context) {
var old models.Machine var old models.Machine
var oldPass string var oldPass string
var lp, ss *time.Time var lp, ss *time.Time
_ = 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, created_at, updated_at FROM machines WHERE id = ?`, id). var oldPVEHostID sql.NullInt64
Scan(&old.ID, &old.Hostname, &old.IP, &old.MAC, &old.OsType, &old.OsVersion, &old.Notes, &old.SSHPort, &old.SSHUsername, &oldPass, &old.IsOnline, &lp, &old.CPUInfo, &old.MemoryInfo, &old.DiskInfo, &old.Uptime, &old.ListenPorts, &ss, &old.CreatedAt, &old.UpdatedAt) var oldPVEVMID models.NullString
_ = 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, created_at, updated_at, pve_host_id, pve_vmid FROM machines WHERE id = ?`, id).
Scan(&old.ID, &old.Hostname, &old.IP, &old.MAC, &old.OsType, &old.OsVersion, &old.Notes, &old.SSHPort, &old.SSHUsername, &oldPass, &old.IsOnline, &lp, &old.CPUInfo, &old.MemoryInfo, &old.DiskInfo, &old.Uptime, &old.ListenPorts, &ss, &old.CreatedAt, &old.UpdatedAt, &oldPVEHostID, &oldPVEVMID)
old.LastPingAt = lp old.LastPingAt = lp
old.SSHSyncedAt = ss old.SSHSyncedAt = ss
if oldPVEHostID.Valid {
v := oldPVEHostID.Int64
old.PVEHostID = &v
}
old.PVEVMID = oldPVEVMID
// Keep old password if new one is empty // Keep old password if new one is empty
passToSave := oldPass passToSave := oldPass
@@ -251,8 +260,18 @@ func (h *MachineHandler) Update(c *gin.Context) {
passToSave = encPass 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=?`, // Preserve PVE fields if not provided in request
m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passToSave, id) pveHostID := m.PVEHostID
if pveHostID == nil && old.PVEHostID != nil {
pveHostID = old.PVEHostID
}
pveVMID := m.PVEVMID
if !pveVMID.Valid && old.PVEVMID.Valid {
pveVMID = old.PVEVMID
}
_, 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, pveHostID, pveVMID, id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return

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

@@ -0,0 +1,339 @@
package handlers
import (
"database/sql"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"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(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
hosts, err := h.service.GetAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 密码不返回给前端
for i := range hosts {
hosts[i].PasswordEnc = ""
}
c.JSON(http.StatusOK, hosts)
}
// Get 获取单个 PVE 主机
func (h *PVEHandler) Get(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
host, err := h.service.GetByID(id)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
host.PasswordEnc = ""
host.Password = ""
c.JSON(http.StatusOK, host)
}
// Create 创建 PVE 主机
func (h *PVEHandler) Create(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
var host models.PVEHost
if err := c.ShouldBindJSON(&host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if host.Name == "" || host.Hostname == "" || host.Username == "" || host.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields"})
return
}
if host.Port == 0 {
host.Port = 8006
}
if err := h.service.Create(&host); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
idPtr := host.ID
middleware.LogOperation("create", "pve_host", &idPtr, host.Name, "", host.Hostname+":"+strconv.Itoa(host.Port), c.ClientIP(), middleware.CurrentUser(c))
host.PasswordEnc = ""
host.Password = ""
c.JSON(http.StatusCreated, host)
}
// Update 更新 PVE 主机
func (h *PVEHandler) Update(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var host models.PVEHost
if err := c.ShouldBindJSON(&host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
host.ID = id
// 如果密码为空,说明不更新密码
if host.Password == "" {
existing, err := h.service.GetByID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
host.PasswordEnc = existing.PasswordEnc
}
if err := h.service.Update(&host); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
middleware.LogOperation("update", "pve_host", &id, host.Name, "", host.Hostname+":"+strconv.Itoa(host.Port), c.ClientIP(), middleware.CurrentUser(c))
host.PasswordEnc = ""
c.JSON(http.StatusOK, host)
}
// Delete 删除 PVE 主机
func (h *PVEHandler) Delete(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
// 获取主机名用于日志
host, _ := h.service.GetByID(id)
hostName := ""
if host != nil {
hostName = host.Name
}
if err := h.service.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
middleware.LogOperation("delete", "pve_host", &id, hostName, "", "", c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// HostStatus 检测 PVE 节点连接状态
func (h *PVEHandler) HostStatus(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
status, err := h.service.GetHostStatus(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{"status": "offline", "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": status})
}
// HostVMList 获取 PVE 节点上的虚拟机列表
func (h *PVEHandler) HostVMList(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
vms, err := h.service.GetHostVMList(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, vms)
}
// VMStatus 获取虚拟机状态
func (h *PVEHandler) VMStatus(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
machineID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "machine not found"})
return
}
if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "machine not linked to PVE VM"})
return
}
status, err := h.service.GetVMStatus(*pveHostID, pveVMID.String)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, status)
}
// VMStart 启动虚拟机
func (h *PVEHandler) VMStart(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
machineID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "machine not found"})
return
}
if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "machine not linked to PVE VM"})
return
}
if err := h.service.StartVM(*pveHostID, pveVMID.String); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 记录操作日志
machineName := ""
db.DB.QueryRow("SELECT hostname FROM machines WHERE id = ?", machineID).Scan(&machineName)
idPtr := machineID
middleware.LogOperation("start", "vm", &idPtr, machineName, "", "vmid:"+pveVMID.String, c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, gin.H{"message": "started"})
}
// VMStop 停止虚拟机
func (h *PVEHandler) VMStop(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
machineID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "machine not found"})
return
}
if pveHostID == nil || !pveVMID.Valid || pveVMID.String == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "machine not linked to PVE VM"})
return
}
if err := h.service.StopVM(*pveHostID, pveVMID.String); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 记录操作日志
machineName := ""
db.DB.QueryRow("SELECT hostname FROM machines WHERE id = ?", machineID).Scan(&machineName)
idPtr := machineID
middleware.LogOperation("stop", "vm", &idPtr, machineName, "", "vmid:"+pveVMID.String, c.ClientIP(), middleware.CurrentUser(c))
c.JSON(http.StatusOK, gin.H{"message": "stopped"})
}

View File

@@ -33,6 +33,8 @@ func main() {
os.Exit(1) os.Exit(1)
} }
config.LoadPasswordFromDB(cfg, db.DB)
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
r.RedirectTrailingSlash = false r.RedirectTrailingSlash = false
@@ -55,6 +57,7 @@ func main() {
api.POST("/auth/login", authH.Login) api.POST("/auth/login", authH.Login)
api.POST("/auth/logout", authH.Logout) api.POST("/auth/logout", authH.Logout)
api.GET("/auth/me", authH.Me) api.GET("/auth/me", authH.Me)
api.POST("/auth/change-password", middleware.AuthRequired(), authH.ChangePassword)
api.GET("/health", handlers.NewHealthHandler().Check) api.GET("/health", handlers.NewHealthHandler().Check)
@@ -94,6 +97,21 @@ func main() {
admin.GET("/logs", handlers.NewLogHandler().List) 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("/pve/hosts/:id/status", pveH.HostStatus)
admin.GET("/pve/hosts/:id/vms", pveH.HostVMList)
// 虚拟机操作
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() eh := handlers.NewExportHandler()
admin.GET("/export", eh.Export) admin.GET("/export", eh.Export)
admin.POST("/import", eh.Import) admin.POST("/import", eh.Import)
@@ -115,9 +133,18 @@ func main() {
fileServer := http.FileServer(http.FS(staticFS)) fileServer := http.FileServer(http.FS(staticFS))
r.NoRoute(func(c *gin.Context) { r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path path := c.Request.URL.Path
if path != "/" && path != "" && !strings.HasPrefix(path, "/assets/") && path != "/favicon.ico" { // Handle assets at any path depth (e.g. /logs/assets/... from relative dynamic imports)
c.Request.URL.Path = "/" if idx := strings.Index(path, "/assets/"); idx >= 0 {
c.Request.URL.Path = path[idx:]
fileServer.ServeHTTP(c.Writer, c.Request)
return
} }
if path == "/favicon.ico" {
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
// SPA fallback: all other paths serve index.html
c.Request.URL.Path = "/"
fileServer.ServeHTTP(c.Writer, c.Request) fileServer.ServeHTTP(c.Writer, c.Request)
}) })
} }

View File

@@ -58,6 +58,10 @@ type Machine struct {
TotalOfflineSeconds int `json:"total_offline_seconds"` TotalOfflineSeconds int `json:"total_offline_seconds"`
LastOfflineAt *time.Time `json:"last_offline_at"` LastOfflineAt *time.Time `json:"last_offline_at"`
LastOfflineReason NullString `json:"last_offline_reason"` 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 { type OfflineLog struct {
@@ -108,6 +112,11 @@ type LoginRequest struct {
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
} }
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
type SSHInfoRequest struct { type SSHInfoRequest struct {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Password string `json:"password"` Password string `json:"password"`
@@ -125,3 +134,26 @@ type SSHInfoResult struct {
RawMemoryInfo string `json:"raw_memory_info"` RawMemoryInfo string `json:"raw_memory_info"`
RawDiskInfo string `json:"raw_disk_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"`
NodeName string `json:"node_name"`
VerifySSL bool `json:"verify_ssl"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 运行时使用,不存储
Password string `json:"password"`
}
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"`
}

View File

@@ -280,6 +280,11 @@ func StartPingService(interval int) {
} }
rows.Close() rows.Close()
if len(list) == 0 {
time.Sleep(step)
continue
}
fmt.Printf("[Ping] start round, %d machines, interval %v each\n", len(list), step) fmt.Printf("[Ping] start round, %d machines, interval %v each\n", len(list), step)
for i := range list { for i := range list {

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

@@ -0,0 +1,501 @@
package services
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"time"
"lan-manager/server/db"
"lan-manager/server/models"
"lan-manager/server/utils"
)
type PVEClient struct {
NodeName string
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,
NodeName: host.NodeName,
VerifySSL: host.VerifySSL,
httpClient: client,
}
}
func (c *PVEClient) baseURL() string {
// PVE 始终使用 HTTPSverify_ssl 只控制是否验证证书
return fmt.Sprintf("https://%s:%d", c.Host, c.Port)
}
// Login 获取 PVE 认证ticket
func (c *PVEClient) Login() error {
loginURL := c.baseURL() + "/api2/json/access/ticket"
formData := url.Values{}
formData.Set("username", c.Username)
formData.Set("password", c.Password)
req, err := http.NewRequest("POST", loginURL, strings.NewReader(formData.Encode()))
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"`
Ticket string `json:"ticket"`
} `json:"data"`
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read login response failed: %v", err)
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("decode login response failed: %v, body: %s", err, string(body))
}
c.CSRFToken = result.Data.CSRFToken
c.Cookie = "PVEAuthCookie=" + result.Data.Ticket
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/" + c.NodeName + "/qemu/" + vmid + "/status/current"
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)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body failed: %v", err)
}
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.Unmarshal(body, &result); err != nil {
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/" + c.NodeName + "/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/" + c.NodeName + "/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/nodes/" + c.NodeName + "/qemu"
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 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get vm list failed, status: %d, body: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read vm list body failed: %v", err)
}
var result struct {
Data []struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("decode vm list failed: %v, body: %s", err, string(body))
}
// vmid 是 int转换为 string
vms := make([]struct {
VMID string `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
}, len(result.Data))
for i, vm := range result.Data {
vms[i] = struct {
VMID string `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
}{
VMID: strconv.Itoa(vm.VMID),
Name: vm.Name,
Status: vm.Status,
}
}
return vms, nil
}
// PVEHostService PVE 主机服务
type PVEHostService struct{}
func NewPVEHostService() *PVEHostService {
return &PVEHostService{}
}
// GetAll 获取所有 PVE 主机
func (s *PVEHostService) GetAll() ([]models.PVEHost, error) {
rows, err := db.DB.Query(`SELECT id, name, hostname, port, username, node_name, password_enc, verify_ssl, created_at, updated_at FROM pve_hosts ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
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.NodeName, &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.DB.QueryRow(`SELECT id, name, hostname, port, username, node_name, 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.NodeName, &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.DB.Exec(`INSERT INTO pve_hosts (name, hostname, port, username, node_name, password_enc, verify_ssl) VALUES (?, ?, ?, ?, ?, ?, ?)`,
host.Name, host.Hostname, host.Port, host.Username, host.NodeName, 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.DB.Exec(`UPDATE pve_hosts SET name=?, hostname=?, port=?, username=?, node_name=?, password_enc=?, verify_ssl=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
host.Name, host.Hostname, host.Port, host.Username, host.NodeName, encPassword, boolToInt(host.VerifySSL), host.ID)
return err
}
_, err := db.DB.Exec(`UPDATE pve_hosts SET name=?, hostname=?, port=?, username=?, node_name=?, verify_ssl=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
host.Name, host.Hostname, host.Port, host.Username, host.NodeName, boolToInt(host.VerifySSL), host.ID)
return err
}
// Delete 删除 PVE 主机
func (s *PVEHostService) Delete(id int64) error {
// 先清除关联机器的 PVE 字段
if _, err := db.DB.Exec(`UPDATE machines SET pve_host_id = NULL, pve_vmid = NULL WHERE pve_host_id = ?`, id); err != nil {
return err
}
_, err := db.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)
}
// GetHostStatus 检测 PVE 节点连接状态
func (s *PVEHostService) GetHostStatus(hostID int64) (string, error) {
host, err := s.GetByID(hostID)
if err != nil {
return "offline", err
}
password, err := s.GetDecryptedPassword(host)
if err != nil {
return "offline", err
}
client := NewPVEClient(host, password)
if err := client.Login(); err != nil {
return "offline", nil
}
return "online", nil
}
// GetHostVMList 获取 PVE 节点上的虚拟机列表
func (s *PVEHostService) GetHostVMList(hostID int64) ([]struct {
VMID string `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
}, 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.GetVMList()
}
// 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

@@ -2,6 +2,9 @@ import axios from 'axios'
import { refreshAuth } from '@/router' import { refreshAuth } from '@/router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
export const changePassword = (data) => api.post('/auth/change-password', data)
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
timeout: 30000, timeout: 30000,
@@ -58,6 +61,18 @@ export const deleteRelationship = (id) => api.delete(`/relationships/${id}`)
export const fetchLogs = (params) => api.get('/logs', { params }) export const fetchLogs = (params) => api.get('/logs', { params })
// PVE API
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 fetchPVEHostStatus = (id) => api.get(`/pve/hosts/${id}/status`)
export const fetchPVEHostVMs = (id) => api.get(`/pve/hosts/${id}/vms`)
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`)
export const exportData = () => api.get('/export', { responseType: 'blob' }) export const exportData = () => api.get('/export', { responseType: 'blob' })
export const importData = (formData) => api.post('/import', formData, { export const importData = (formData) => api.post('/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },

View File

@@ -28,6 +28,10 @@
<el-icon class="user-icon"><User /></el-icon> <el-icon class="user-icon"><User /></el-icon>
<span class="user-name">{{ isAdmin ? '管理员' : '访客' }}</span> <span class="user-name">{{ isAdmin ? '管理员' : '访客' }}</span>
</div> </div>
<el-button text class="logout-btn" @click="openChangePassword">
<el-icon><Lock /></el-icon>
<span>修改密码</span>
</el-button>
<el-button text class="logout-btn" @click="logout"> <el-button text class="logout-btn" @click="logout">
<el-icon><SwitchButton /></el-icon> <el-icon><SwitchButton /></el-icon>
<span>退出</span> <span>退出</span>
@@ -40,15 +44,58 @@
<router-view /> <router-view />
</div> </div>
</main> </main>
<el-dialog
v-model="pwdDialogVisible"
title="修改密码"
width="360px"
:close-on-click-modal="false"
>
<el-form
ref="pwdFormRef"
:model="pwdForm"
:rules="pwdRules"
label-width="80px"
>
<el-form-item label="旧密码" prop="old_password">
<el-input
v-model="pwdForm.old_password"
type="password"
show-password
placeholder="请输入旧密码"
/>
</el-form-item>
<el-form-item label="新密码" prop="new_password">
<el-input
v-model="pwdForm.new_password"
type="password"
show-password
placeholder="至少6位字符"
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirm_password">
<el-input
v-model="pwdForm.confirm_password"
type="password"
show-password
placeholder="再次输入新密码"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitChangePassword">确定</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' 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, Lock } from '@element-plus/icons-vue'
import { getAuth, refreshAuth } from '@/router' import { getAuth, refreshAuth } from '@/router'
import { logout as apiLogout } from '@/api' import { logout as apiLogout, changePassword } from '@/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const router = useRouter() const router = useRouter()
@@ -73,6 +120,55 @@ async function logout() {
ElMessage.success('已退出') ElMessage.success('已退出')
router.push('/login') router.push('/login')
} }
const pwdDialogVisible = ref(false)
const pwdFormRef = ref(null)
const pwdForm = ref({
old_password: '',
new_password: '',
confirm_password: '',
})
const validateConfirm = (rule, value, callback) => {
if (value !== pwdForm.value.new_password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
const pwdRules = {
old_password: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirm_password: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{ validator: validateConfirm, trigger: 'blur' },
],
}
function openChangePassword() {
pwdForm.value = { old_password: '', new_password: '', confirm_password: '' }
pwdDialogVisible.value = true
}
async function submitChangePassword() {
if (!pwdFormRef.value) return
try {
await pwdFormRef.value.validate()
await changePassword({
old_password: pwdForm.value.old_password,
new_password: pwdForm.value.new_password,
})
ElMessage.success('密码修改成功')
pwdDialogVisible.value = false
} catch (err) {
const msg = err.response?.data?.error || err.message || '修改失败'
ElMessage.error(msg)
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -21,6 +21,11 @@
</div> </div>
</div> </div>
<div class="header-actions" v-if="isAdmin"> <div class="header-actions" v-if="isAdmin">
<el-tag v-if="machine.pve_host_id && machine.pve_vmid" size="small" :type="vmStatusInfo._error ? 'info' : vmStatusInfo.status === 'running' ? 'success' : vmStatusInfo.status === 'stopped' ? 'danger' : 'info'" effect="light" class="vm-status-tag">
{{ vmStatusInfo._error ? 'VM检测失败' : vmStatusInfo.status === 'running' ? 'VM运行中' : vmStatusInfo.status === 'stopped' ? 'VM已停止' : 'VM检测中' }}
</el-tag>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="success" :icon="VideoPlay" :loading="vmLoading" @click="startVM" :disabled="vmStatusInfo.status === 'running' || vmStatusInfo._error">启动</el-button>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="warning" :icon="VideoPause" :loading="vmLoading" @click="stopVM" :disabled="vmStatusInfo.status === 'stopped' || vmStatusInfo._error">关闭</el-button>
<el-button text :icon="Edit" @click="openEdit(machine)">编辑</el-button> <el-button text :icon="Edit" @click="openEdit(machine)">编辑</el-button>
<el-button text type="danger" :icon="Delete" @click="delMachine">删除</el-button> <el-button text type="danger" :icon="Delete" @click="delMachine">删除</el-button>
</div> </div>
@@ -39,6 +44,29 @@
</div> </div>
</div> </div>
<!-- PVE VM Status -->
<div class="card" v-if="machine.pve_host_id && machine.pve_vmid && vmStatusInfo.status">
<div class="card-title">PVE 虚拟机状态</div>
<div class="vm-status-grid">
<div class="vm-stat-item">
<span class="vm-stat-label">运行状态</span>
<span class="vm-stat-value" :class="vmStatusInfo.status">{{ vmStatusInfo.status === 'running' ? '运行中' : '已停止' }}</span>
</div>
<div class="vm-stat-item" v-if="vmStatusInfo.cpu !== undefined">
<span class="vm-stat-label">CPU 使用率</span>
<span class="vm-stat-value">{{ vmStatusInfo.cpu?.toFixed ? vmStatusInfo.cpu.toFixed(1) : vmStatusInfo.cpu }}%</span>
</div>
<div class="vm-stat-item" v-if="vmStatusInfo.memory_used !== undefined && vmStatusInfo.memory_total">
<span class="vm-stat-label">内存使用</span>
<span class="vm-stat-value">{{ formatBytes(vmStatusInfo.memory_used) }} / {{ formatBytes(vmStatusInfo.memory_total) }}</span>
</div>
<div class="vm-stat-item" v-if="vmStatusInfo.uptime">
<span class="vm-stat-label">运行时长</span>
<span class="vm-stat-value">{{ formatUptime(vmStatusInfo.uptime) }}</span>
</div>
</div>
</div>
<!-- System Status --> <!-- System Status -->
<div class="card" v-if="machine.cpu_info || machine.memory_info || machine.disk_info"> <div class="card" v-if="machine.cpu_info || machine.memory_info || machine.disk_info">
<div class="card-title">系统状态</div> <div class="card-title">系统状态</div>
@@ -229,6 +257,15 @@
</el-form-item> </el-form-item>
<el-form-item label="系统版本"><el-input v-model="editing.os_version" /></el-form-item> <el-form-item label="系统版本"><el-input v-model="editing.os_version" /></el-form-item>
<el-form-item label="备注"><el-input v-model="editing.notes" type="textarea" :rows="2" /></el-form-item> <el-form-item label="备注"><el-input v-model="editing.notes" type="textarea" :rows="2" /></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> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
@@ -301,13 +338,14 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' 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 { import {
fetchMachine, updateMachine, deleteMachine, fetchMachine, updateMachine, deleteMachine,
fetchServices, createService, updateService, deleteService, fetchServices, createService, updateService, deleteService,
fetchRelationships, createRelationship, updateRelationship, deleteRelationship, fetchRelationships, createRelationship, updateRelationship, deleteRelationship,
sshInfo, syncSSH, fetchMachines, fetchOfflineLogs, sshInfo, syncSSH, fetchMachines, fetchOfflineLogs, fetchPVEHosts,
checkAuth, uiRefreshInterval, checkAuth, uiRefreshInterval,
startVM as apiStartVM, stopVM as apiStopVM, fetchVMStatus,
} from '@/api' } from '@/api'
import { getAuth } from '@/router' import { getAuth } from '@/router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
@@ -320,8 +358,11 @@ const machineId = route.params.id
const machine = ref(null) const machine = ref(null)
const services = ref([]) const services = ref([])
const relationships = ref([]) const relationships = ref([])
const vmLoading = ref(false)
const vmStatusInfo = ref({})
const allMachines = ref([]) const allMachines = ref([])
const offlineLogs = ref([]) const offlineLogs = ref([])
const pveHosts = ref([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editing = ref({}) const editing = ref({})
@@ -351,6 +392,11 @@ onMounted(async () => {
await loadRelationships() await loadRelationships()
const res = await fetchMachines() const res = await fetchMachines()
allMachines.value = res.data allMachines.value = res.data
try {
const pveRes = await fetchPVEHosts()
pveHosts.value = pveRes.data
} catch (e) {}
await loadVMStatus()
} }
await checkAuth() await checkAuth()
const interval = uiRefreshInterval || 10000 const interval = uiRefreshInterval || 10000
@@ -360,6 +406,7 @@ onMounted(async () => {
await loadServices() await loadServices()
if (isAdmin) { if (isAdmin) {
await loadRelationships() await loadRelationships()
await loadVMStatus()
} }
}, interval) }, interval)
} }
@@ -411,7 +458,7 @@ async function loadRelationships() {
} }
function openEdit(item) { function openEdit(item) {
editing.value = { ...item, ssh_port: item.ssh_port || 22, ssh_username: item.ssh_username || '', ssh_password: '' } editing.value = { ...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 || '' }
dialogVisible.value = true dialogVisible.value = true
} }
@@ -449,6 +496,48 @@ async function saveMachine() {
loadMachine() loadMachine()
} }
async function loadVMStatus() {
if (!machine.value || !machine.value.pve_host_id || !machine.value.pve_vmid) return
try {
const res = await fetchVMStatus(machineId)
vmStatusInfo.value = res.data
} catch (e) {
vmStatusInfo.value = { _error: true }
}
}
async function startVM() {
try {
await ElMessageBox.confirm('确定要启动虚拟机吗?', '确认启动', { type: 'info' })
vmLoading.value = true
await apiStartVM(machineId)
ElMessage.success('虚拟机已启动')
await loadVMStatus()
loadMachine()
} catch (e) {
if (e !== 'cancel') {
// error handled by interceptor
}
}
vmLoading.value = false
}
async function stopVM() {
try {
await ElMessageBox.confirm('确定要关闭虚拟机吗?', '确认关闭', { type: 'warning' })
vmLoading.value = true
await apiStopVM(machineId)
ElMessage.success('虚拟机已关闭')
await loadVMStatus()
loadMachine()
} catch (e) {
if (e !== 'cancel') {
// error handled by interceptor
}
}
vmLoading.value = false
}
async function delMachine() { async function delMachine() {
try { try {
await ElMessageBox.confirm('确定删除该机器?', '提示', { type: 'warning' }) await ElMessageBox.confirm('确定删除该机器?', '提示', { type: 'warning' })
@@ -597,6 +686,26 @@ function formatDuration(seconds) {
const hr = h % 24 const hr = h % 24
return `${d}${hr}${m}` return `${d}${hr}${m}`
} }
function formatBytes(bytes) {
if (!bytes && bytes !== 0) return '-'
const b = Number(bytes)
if (b < 1024) return `${b}B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)}MB`
return `${(b / 1024 / 1024 / 1024).toFixed(1)}GB`
}
function formatUptime(seconds) {
if (!seconds && seconds !== 0) return '-'
const s = Number(seconds)
const d = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60)
if (d > 0) return `${d}${h}${m}`
if (h > 0) return `${h}${m}`
return `${m}`
}
</script> </script>
<style scoped> <style scoped>
@@ -629,6 +738,55 @@ function formatDuration(seconds) {
.status-badge.offline { background: rgba(239,68,68,0.10); color: #b91c1c; } .status-badge.offline { background: rgba(239,68,68,0.10); color: #b91c1c; }
html.dark .status-badge.online { background: rgba(52,211,153,0.15); color: #34d399; } html.dark .status-badge.online { background: rgba(52,211,153,0.15); color: #34d399; }
html.dark .status-badge.offline { background: rgba(248,113,113,0.15); color: #f87171; } html.dark .status-badge.offline { background: rgba(248,113,113,0.15); color: #f87171; }
.vm-status-tag {
font-size: 12px;
height: 24px;
margin-right: 4px;
}
.vm-status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.vm-stat-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
background: var(--surface-hover);
border-radius: 8px;
}
.vm-stat-label {
font-size: 11px;
color: var(--text-muted);
}
.vm-stat-value {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.vm-stat-value.running {
color: #15803d;
}
.vm-stat-value.stopped {
color: #b91c1c;
}
html.dark .vm-stat-value.running {
color: #34d399;
}
html.dark .vm-stat-value.stopped {
color: #f87171;
}
.host-subtitle { .host-subtitle {
margin-top: 4px; margin-top: 4px;
font-size: 13px; font-size: 13px;
@@ -939,3 +1097,4 @@ html.dark .status-badge.offline { background: rgba(248,113,113,0.15); color: #f8
color: var(--warning); color: var(--warning);
} }
</style> </style>

View File

@@ -33,6 +33,9 @@
<span class="meta-item">{{ m.os_type }}</span> <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.os_version" class="meta-item">{{ m.os_version }}</span>
<span v-if="m.uptime" class="meta-uptime">{{ m.uptime }}</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运行中' : m.pve_vm_status === 'stopped' ? 'VM已停止' : 'VM检测中' }}</span>
</span>
</div> </div>
</div> </div>
@@ -114,6 +117,15 @@
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="editing.notes" type="textarea" :rows="3" placeholder="记录用途、负责人等信息" /> <el-input v-model="editing.notes" type="textarea" :rows="3" placeholder="记录用途、负责人等信息" />
</el-form-item> </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> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
@@ -127,23 +139,31 @@
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Plus, Search } from '@element-plus/icons-vue' 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, fetchVMStatus } from '@/api'
import { getAuth } from '@/router' import { getAuth } from '@/router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const router = useRouter() const router = useRouter()
const isAdmin = getAuth().is_admin const isAdmin = getAuth().is_admin
const machines = ref([]) const machines = ref([])
const pveHosts = ref([])
const search = ref('') const search = ref('')
const osFilter = ref('') const osFilter = ref('')
const dialogVisible = ref(false) 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) const importFileRef = ref(null)
let timer = null let timer = null
onMounted(async () => { onMounted(async () => {
await load() await load()
await checkAuth() await checkAuth()
// 加载 PVE 主机列表
if (isAdmin) {
try {
const res = await fetchPVEHosts()
pveHosts.value = res.data
} catch (e) {}
}
const interval = uiRefreshInterval || 10000 const interval = uiRefreshInterval || 10000
if (interval > 0) { if (interval > 0) {
timer = setInterval(load, interval) timer = setInterval(load, interval)
@@ -157,6 +177,18 @@ onUnmounted(() => {
async function load() { async function load() {
const res = await fetchMachines({ search: search.value, os_type: osFilter.value }) const res = await fetchMachines({ search: search.value, os_type: osFilter.value })
machines.value = res.data machines.value = res.data
// 并行查询有关联 PVE 的机器的 VM 实时状态
if (isAdmin) {
const pveMachines = machines.value.filter(m => m.pve_host_id && m.pve_vmid)
await Promise.all(pveMachines.map(async (m) => {
try {
const statusRes = await fetchVMStatus(m.id)
m.pve_vm_status = statusRes.data.status
} catch (e) {
m.pve_vm_status = 'unknown'
}
}))
}
} }
function goDetail(id) { function goDetail(id) {
@@ -165,8 +197,8 @@ function goDetail(id) {
function openEdit(item) { function openEdit(item) {
editing.value = item editing.value = item
? { ...item, ssh_port: item.ssh_port || 22, ssh_username: item.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: '' } : { 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 dialogVisible.value = true
} }
@@ -509,4 +541,28 @@ html.dark .status-badge.offline {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
} }
.vm-status {
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
border-radius: 999px;
}
.vm-status.running {
background: rgba(34,197,94,0.12);
color: #15803d;
}
.vm-status.stopped {
background: rgba(239,68,68,0.10);
color: #b91c1c;
}
html.dark .vm-status.running {
background: rgba(52,211,153,0.15);
color: #34d399;
}
html.dark .vm-status.stopped {
background: rgba(248,113,113,0.15);
color: #f87171;
}
</style> </style>