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:
68
.dockerignore
Normal file
68
.dockerignore
Normal 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
16
.qwen/settings.json
Normal 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
83
Dockerfile
Normal 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"]
|
||||||
55
README.md
55
README.md
@@ -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 Ping,3 秒内无响应视为离线
|
- **在线检测机制**:每分钟对所有机器 IP 执行 ICMP Ping,3 秒内无响应视为离线
|
||||||
- **磁盘显示策略**:机器列表卡片仅展示 `/` 根分区占用;详情页展示所有真实磁盘分区
|
- **磁盘显示策略**:机器列表卡片仅展示 `/` 根分区占用;详情页展示所有真实磁盘分区
|
||||||
|
|
||||||
|
## Docker 部署注意事项
|
||||||
|
|
||||||
|
1. **数据持久化**:通过 volume 挂载 `./data` 目录到容器,确保数据在容器重建后不丢失
|
||||||
|
2. **网络访问**:容器需要访问宿主机网络或目标局域网,默认使用 bridge 网络模式
|
||||||
|
3. **Ping 权限**:容器内可能需要 `NET_RAW` 能力才能执行 ICMP ping,已在 Dockerfile 中通过 `setcap` 配置
|
||||||
|
4. **环境变量**:建议通过 `.env` 文件或 Docker Compose 管理敏感配置
|
||||||
|
|||||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
339
server/handlers/pve.go
Normal 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"})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
501
server/services/pve.go
Normal 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 始终使用 HTTPS,verify_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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user