feat: 多主机纳管、用户认证、noVNC控制台、深色主题

主要功能:
- 多主机管理: 支持TCP/SSH方式纳管远程KVM主机
- 用户认证: JWT token认证, 默认admin/admin123
- noVNC控制台: 前端集成noVNC, WebSocket代理VNC连接
- 深色主题: 全局Element Plus深色主题覆盖
- 虚拟机操作: 克隆、迁移、XML编辑、快照管理
- 资源监控: CPU/内存/磁盘IO/网络流量实时监控

Bug修复:
- libvirt getInfo()内存单位修正(MiB非KiB)
- 远程主机VNC 0.0.0.0监听地址连接策略修复
- Dashboard定时器内存泄漏修复
- bcrypt版本兼容性修复
This commit is contained in:
admin
2026-05-07 12:41:10 +08:00
parent fac8ab7470
commit 8ccccf8f52
30 changed files with 1972 additions and 170 deletions
+2
View File
@@ -6,3 +6,5 @@ node_modules/
dist/
.DS_Store
*.log
hosts.json
*.lingma/
+102
View File
@@ -0,0 +1,102 @@
"""用户认证模块 - JWT Token 认证"""
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
from app.config import settings
# 密码加密上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# OAuth2 Token 提取
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_PREFIX}/auth/login")
# 简易用户存储(生产环境应使用数据库)
# 默认管理员账号: admin / admin123
_users_db = {
"admin": {
"username": "admin",
"hashed_password": pwd_context.hash("admin123"),
"role": "admin",
}
}
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
username: str = ""
role: str = ""
class User(BaseModel):
username: str
role: str = "user"
class UserCreate(BaseModel):
username: str
password: str
role: str = "user"
class PasswordChange(BaseModel):
old_password: str
new_password: str
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""生成密码哈希"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""创建 JWT Token"""
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def authenticate_user(username: str, password: str) -> Optional[dict]:
"""验证用户"""
user = _users_db.get(username)
if not user:
return None
if not verify_password(password, user["hashed_password"]):
return None
return user
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
"""从 Token 获取当前用户(依赖注入)"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = _users_db.get(username)
if user is None:
raise credentials_exception
return User(username=user["username"], role=user["role"])
def get_users_db():
"""获取用户数据库(用于路由中管理用户)"""
return _users_db
+175
View File
@@ -0,0 +1,175 @@
"""主机注册表 - 管理多台 libvirt 宿主机"""
import json
import os
import time
import uuid
import logging
from typing import Optional
from pydantic import BaseModel, Field
from app.config import settings
logger = logging.getLogger(__name__)
# 数据存储目录
DATA_DIR = os.environ.get("KVM_DATA_DIR", "/var/lib/kvm-manager")
HOSTS_FILE = os.path.join(DATA_DIR, "hosts.json")
class HostInfo(BaseModel):
"""主机信息模型"""
id: str = Field(..., description="主机唯一ID")
name: str = Field(..., description="主机名称")
uri: str = Field(..., description="libvirt 连接 URI")
type: str = Field("local", description="连接类型: local/tcp/ssh")
ssh_key_path: Optional[str] = Field(None, description="SSH 私钥路径(ssh 模式)")
status: str = "unknown"
created_at: float = Field(default_factory=time.time)
last_seen: Optional[float] = None
class HostCreate(BaseModel):
"""创建主机请求"""
name: str = Field(..., description="主机名称")
uri: str = Field(..., description="libvirt 连接 URI,如 qemu+tcp://192.168.1.2/system")
ssh_key_path: Optional[str] = Field(None, description="SSH 私钥路径(ssh 模式)")
def _detect_type(uri: str) -> str:
"""根据 URI 判断连接类型"""
if uri.startswith("qemu+ssh://"):
return "ssh"
elif uri.startswith("qemu+tcp://"):
return "tcp"
return "local"
def _ensure_data_dir():
"""确保数据目录存在"""
os.makedirs(DATA_DIR, exist_ok=True)
def _load_hosts() -> dict:
"""从文件加载主机列表"""
if not os.path.exists(HOSTS_FILE):
return {}
try:
with open(HOSTS_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
def _save_hosts(data: dict):
"""保存主机列表到文件"""
_ensure_data_dir()
with open(HOSTS_FILE, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def _init_local_host() -> dict:
"""初始化本机默认主机"""
return HostInfo(
id="local",
name="本机",
uri=settings.LIBVIRT_URI,
type="local",
status="unknown",
).model_dump()
def list_hosts() -> list[HostInfo]:
"""列出所有已注册主机"""
data = _load_hosts()
if not data:
# 首次运行,初始化本机
local = _init_local_host()
data["local"] = local
_save_hosts(data)
return [HostInfo(**h) for h in data.values()]
def get_host(host_id: str) -> Optional[HostInfo]:
"""获取单个主机信息"""
data = _load_hosts()
if host_id not in data:
return None
return HostInfo(**data[host_id])
def add_host(req: HostCreate) -> HostInfo:
"""添加新主机"""
data = _load_hosts()
if not data:
data["local"] = _init_local_host()
host_id = req.name.lower().replace(" ", "-").replace(".", "-")
# 确保ID唯一
if host_id in data:
host_id = f"{host_id}-{uuid.uuid4().hex[:6]}"
host_type = _detect_type(req.uri)
# 构建 SSH URI
uri = req.uri
if host_type == "ssh" and req.ssh_key_path:
# 在 URI 中嵌入 key 提示,实际连接时由 libvirt ssh driver 使用
pass
host = HostInfo(
id=host_id,
name=req.name,
uri=uri,
type=host_type,
ssh_key_path=req.ssh_key_path,
status="unknown",
created_at=time.time(),
)
data[host_id] = host.model_dump()
_save_hosts(data)
return host
def remove_host(host_id: str) -> bool:
"""删除主机(local 不可删)"""
if host_id == "local":
return False
data = _load_hosts()
if host_id not in data:
return False
del data[host_id]
_save_hosts(data)
return True
def update_host_status(host_id: str, status: str):
"""更新主机在线状态"""
data = _load_hosts()
if host_id in data:
data[host_id]["status"] = status
data[host_id]["last_seen"] = time.time()
_save_hosts(data)
def test_connection(uri: str) -> dict:
"""测试 libvirt 连接是否可用"""
import libvirt
try:
conn = libvirt.openReadOnly(uri)
if conn:
info = conn.getInfo()
result = {
"success": True,
"hostname": conn.getHostname(),
"hypervisor": conn.getType(),
"cpu_cores": info[2],
"memory_mb": info[1], # getInfo()[1] 已经是 MiB 单位
"libvirt_version": conn.getLibVersion(),
}
conn.close()
return result
else:
return {"success": False, "error": "无法建立连接"}
except libvirt.libvirtError as e:
return {"success": False, "error": str(e)}
except Exception as e:
return {"success": False, "error": str(e)}
+88 -13
View File
@@ -1,38 +1,40 @@
"""libvirt 连接管理 - 使用连接池模式"""
"""libvirt 多主机连接管理"""
import libvirt
from contextlib import contextmanager
from app.config import settings
import logging
from app.hosts import get_host, update_host_status, list_hosts
logger = logging.getLogger(__name__)
class LibvirtConnection:
"""libvirt 连接管理器(只读连接保持,写操作用短连接)"""
"""单个 libvirt 连接管理器(只读保持,写短连接)"""
def __init__(self):
def __init__(self, uri: str, host_id: str = "local"):
self._uri = uri
self._host_id = host_id
self._conn = None
def _connect(self):
"""建立新的 libvirt 连接"""
"""建立只读连接"""
try:
conn = libvirt.openReadOnly(settings.LIBVIRT_URI)
conn = libvirt.openReadOnly(self._uri)
if conn is None:
raise ConnectionError(f"无法连接到 libvirt: {settings.LIBVIRT_URI}")
raise ConnectionError(f"无法连接到 libvirt: {self._uri}")
return conn
except libvirt.libvirtError as e:
logger.error(f"libvirt 连接错误: {e}")
logger.error(f"libvirt 连接错误 ({self._host_id}): {e}")
raise
def _connect_rw(self):
"""建立可读写连接"""
try:
conn = libvirt.open(settings.LIBVIRT_URI)
conn = libvirt.open(self._uri)
if conn is None:
raise ConnectionError(f"无法连接到 libvirt (RW): {settings.LIBVIRT_URI}")
raise ConnectionError(f"无法连接到 libvirt (RW): {self._uri}")
return conn
except libvirt.libvirtError as e:
logger.error(f"libvirt RW 连接错误: {e}")
logger.error(f"libvirt RW 连接错误 ({self._host_id}): {e}")
raise
@property
@@ -73,6 +75,79 @@ class LibvirtConnection:
"cpu_speed": c.getInfo()[3], # MHz
}
def close(self):
"""关闭连接"""
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
self._conn = None
# 全局单例
libvirt_conn = LibvirtConnection()
class ConnectionPool:
"""多主机连接池"""
def __init__(self):
self._pool: dict[str, LibvirtConnection] = {}
def get(self, host_id: str = "local") -> LibvirtConnection:
"""获取指定主机的连接管理器"""
if host_id not in self._pool:
host = get_host(host_id)
if not host:
raise ValueError(f"主机 '{host_id}' 不存在")
self._pool[host_id] = LibvirtConnection(host.uri, host_id)
return self._pool[host_id]
def remove(self, host_id: str):
"""移除并关闭指定主机的连接"""
if host_id in self._pool:
self._pool[host_id].close()
del self._pool[host_id]
def get_conn(self, host_id: str = "local"):
"""快捷获取 libvirt 只读连接"""
return self.get(host_id).conn
@contextmanager
def get_rw(self, host_id: str = "local"):
"""快捷获取 libvirt 读写连接"""
mgr = self.get(host_id)
with mgr.get_rw() as conn:
yield conn
def refresh_all(self):
"""刷新所有连接状态"""
for host_id, mgr in list(self._pool.items()):
try:
alive = mgr.conn.isAlive()
update_host_status(host_id, "online" if alive else "offline")
except Exception:
update_host_status(host_id, "offline")
mgr.close()
# 全局连接池单例
conn_pool = ConnectionPool()
# 保持向后兼容:本机连接的直接引用
libvirt_conn = property(lambda self: conn_pool.get("local"))
class _CompatConn:
"""向后兼容包装,让现有 `libvirt_conn.conn` 等调用继续工作"""
@property
def conn(self):
return conn_pool.get_conn("local")
def get_rw(self):
return conn_pool.get_rw("local")
def get_host_info(self):
return conn_pool.get("local").get_host_info()
# 全局兼容单例
libvirt_conn = _CompatConn()
+135 -4
View File
@@ -1,8 +1,9 @@
"""FastAPI 主应用"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.routers import vm, storage, network, snapshot, monitor
from app.routers import vm, storage, network, snapshot, monitor, auth, host as host_router
app = FastAPI(
title=settings.APP_NAME,
@@ -20,6 +21,8 @@ app.add_middleware(
)
# 注册路由
app.include_router(auth.router, prefix=f"{settings.API_PREFIX}/auth", tags=["认证"])
app.include_router(host_router.router, prefix=f"{settings.API_PREFIX}/hosts", tags=["主机管理"])
app.include_router(vm.router, prefix=f"{settings.API_PREFIX}/vm", tags=["虚拟机管理"])
app.include_router(storage.router, prefix=f"{settings.API_PREFIX}/storage", tags=["存储管理"])
app.include_router(network.router, prefix=f"{settings.API_PREFIX}/network", tags=["网络管理"])
@@ -34,7 +37,7 @@ async def root():
@app.get(f"{settings.API_PREFIX}/host")
async def host_info():
"""获取宿主机信息"""
"""获取本机宿主机信息(兼容旧接口)"""
from app.libvirt_conn import libvirt_conn
return libvirt_conn.get_host_info()
@@ -42,9 +45,137 @@ async def host_info():
@app.get("/health")
async def health():
"""健康检查"""
from app.libvirt_conn import libvirt_conn
from app.libvirt_conn import conn_pool
try:
conn = libvirt_conn.conn
conn = conn_pool.get_conn("local")
return {"status": "ok", "libvirt": conn.isAlive()}
except Exception as e:
return {"status": "error", "message": str(e)}
# VNC WebSocket 代理
from starlette.websockets import WebSocket
from starlette.responses import HTMLResponse
import asyncio
import socket
import struct
@app.websocket("/ws/vnc/{vm_name}")
async def vnc_websocket(websocket: WebSocket, vm_name: str, host_id: str = "local"):
"""WebSocket 代理到虚拟机 VNC"""
await websocket.accept()
from app.libvirt_conn import conn_pool
from app.hosts import get_host as get_host_info
from lxml import etree
try:
conn = conn_pool.get_conn(host_id)
dom = conn.lookupByName(vm_name)
xml_desc = dom.XMLDesc(0)
tree = etree.fromstring(xml_desc.encode())
# 获取 VNC 端口
graphics = tree.find(".//graphics[@type='vnc']")
if graphics is None:
await websocket.close(code=1000, reason="虚拟机没有 VNC 配置")
return
vnc_port = int(graphics.get("port", -1))
vnc_listen = graphics.get("listen", "127.0.0.1")
if vnc_port <= 0:
await websocket.close(code=1000, reason="VNC 端口未分配,虚拟机可能未运行")
return
# 根据主机类型决定 VNC 连接目标
host_info = get_host_info(host_id)
target_host = vnc_listen
is_remote = host_info and host_info.type != "local"
if is_remote:
from urllib.parse import urlparse
parsed = urlparse(host_info.uri)
remote_host = parsed.hostname or "127.0.0.1"
if vnc_listen in ("127.0.0.1", "localhost", ""):
# VNC 只监听本地回环,需要 SSH 隧道
if host_info.type == "ssh":
import subprocess, time
local_port = 20000 + (hash(vm_name + str(vnc_port)) % 10000)
# 先杀掉可能存在的旧隧道
subprocess.run(
["fuser", "-k", f"{local_port}/tcp"],
capture_output=True, timeout=3,
)
ssh_args = ["ssh", "-f", "-N"]
if host_info.ssh_key_path:
ssh_args.extend(["-i", host_info.ssh_key_path])
ssh_args.extend([
"-o", "StrictHostKeyChecking=no",
"-o", "ServerAliveInterval=30",
"-L", f"127.0.0.1:{local_port}:127.0.0.1:{vnc_port}",
remote_host,
])
result = subprocess.run(ssh_args, capture_output=True, timeout=10)
if result.returncode != 0:
err = result.stderr.decode(errors='replace').strip()
await websocket.close(code=1011, reason=f"SSH隧道建立失败: {err}")
return
target_host = "127.0.0.1"
vnc_port = local_port
else:
# TCP 模式但 VNC 只听 localhost,无法直接连
await websocket.close(code=1011, reason="远程主机 VNC 监听在 localhost,需要使用 SSH 模式")
return
elif vnc_listen == "0.0.0.0":
# VNC 监听所有接口,直接连远程主机 IP
target_host = remote_host
else:
# VNC 监听特定地址
target_host = vnc_listen
else:
# 本地主机
if vnc_listen in ("0.0.0.0", ""):
target_host = "127.0.0.1"
# else 保持 vnc_listen (127.0.0.1 或其他)
# 连接到 VNC 服务器
reader, writer = await asyncio.open_connection(target_host, vnc_port)
async def ws_to_vnc():
"""WebSocket -> VNC"""
try:
while True:
data = await websocket.receive_bytes()
writer.write(data)
await writer.drain()
except Exception:
pass
finally:
writer.close()
async def vnc_to_ws():
"""VNC -> WebSocket"""
try:
while True:
data = await reader.read(4096)
if not data:
break
await websocket.send_bytes(data)
except Exception:
pass
finally:
try:
await websocket.close()
except Exception:
pass
await asyncio.gather(ws_to_vnc(), vnc_to_ws())
except Exception as e:
try:
await websocket.close(code=1011, reason=str(e))
except Exception:
pass
+96
View File
@@ -0,0 +1,96 @@
"""认证路由"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from app.auth import (
authenticate_user, create_access_token, get_password_hash,
get_current_user, User, UserCreate, PasswordChange, Token,
get_users_db, verify_password,
)
router = APIRouter()
@router.post("/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
"""用户登录,获取 JWT Token"""
user = authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": user["username"], "role": user["role"]})
return Token(
access_token=access_token,
username=user["username"],
role=user["role"],
)
@router.get("/me", response_model=User)
async def get_me(current_user: User = Depends(get_current_user)):
"""获取当前用户信息"""
return current_user
@router.post("/change-password")
async def change_password(
pwd: PasswordChange,
current_user: User = Depends(get_current_user),
):
"""修改密码"""
db = get_users_db()
user = db.get(current_user.username)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if not verify_password(pwd.old_password, user["hashed_password"]):
raise HTTPException(status_code=400, detail="旧密码错误")
user["hashed_password"] = get_password_hash(pwd.new_password)
return {"message": "密码修改成功"}
@router.post("/register")
async def register(user_create: UserCreate, current_user: User = Depends(get_current_user)):
"""注册新用户(仅管理员)"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="仅管理员可注册新用户")
db = get_users_db()
if user_create.username in db:
raise HTTPException(status_code=400, detail="用户名已存在")
db[user_create.username] = {
"username": user_create.username,
"hashed_password": get_password_hash(user_create.password),
"role": user_create.role,
}
return {"message": f"用户 '{user_create.username}' 创建成功"}
@router.get("/users")
async def list_users(current_user: User = Depends(get_current_user)):
"""列出所有用户(仅管理员)"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="仅管理员可查看用户列表")
db = get_users_db()
return {
"users": [
{"username": u["username"], "role": u["role"]}
for u in db.values()
]
}
@router.delete("/users/{username}")
async def delete_user(username: str, current_user: User = Depends(get_current_user)):
"""删除用户(仅管理员,不能删除自己)"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="仅管理员可删除用户")
if username == current_user.username:
raise HTTPException(status_code=400, detail="不能删除自己")
db = get_users_db()
if username not in db:
raise HTTPException(status_code=404, detail="用户不存在")
del db[username]
return {"message": f"用户 '{username}' 已删除"}
+116
View File
@@ -0,0 +1,116 @@
"""主机管理路由"""
from fastapi import APIRouter, HTTPException
from app.hosts import (
list_hosts, get_host, add_host, remove_host,
test_connection, update_host_status, HostCreate,
)
from app.libvirt_conn import conn_pool
import time
router = APIRouter()
@router.get("/list")
async def api_list_hosts():
"""列出所有已注册主机"""
hosts = list_hosts()
result = []
for h in hosts:
info = h.model_dump()
# 尝试获取实时状态
try:
mgr = conn_pool.get(h.id)
conn = mgr.conn
alive = conn.isAlive()
info["status"] = "online" if alive else "offline"
if alive:
host_data = conn.getInfo()
info["cpu_cores"] = host_data[2]
info["memory_mb"] = host_data[1] # getInfo()[1] 已经是 MiB 单位
info["hostname"] = conn.getHostname()
# 虚拟机数
domains = conn.listAllDomains(0)
info["vm_total"] = len(domains)
info["vm_running"] = sum(1 for d in domains if d.isActive())
update_host_status(h.id, info["status"])
except Exception:
info["status"] = "offline"
update_host_status(h.id, "offline")
result.append(info)
return {"hosts": result, "total": len(result)}
@router.get("/detail/{host_id}")
async def api_get_host(host_id: str):
"""获取单台主机详情"""
host = get_host(host_id)
if not host:
raise HTTPException(status_code=404, detail=f"主机 '{host_id}' 不存在")
info = host.model_dump()
try:
mgr = conn_pool.get(host_id)
info["host_info"] = mgr.get_host_info()
info["status"] = "online"
# 虚拟机数
domains = mgr.conn.listAllDomains(0)
info["vm_total"] = len(domains)
info["vm_running"] = sum(1 for d in domains if d.isActive())
update_host_status(host_id, "online")
except Exception as e:
info["status"] = "offline"
info["error"] = str(e)
update_host_status(host_id, "offline")
return info
@router.post("/add")
async def api_add_host(req: HostCreate):
"""添加新主机"""
# 先测试连接
result = test_connection(req.uri)
if not result["success"]:
raise HTTPException(status_code=400, detail=f"连接测试失败: {result['error']}")
host = add_host(req)
# 更新状态为在线
update_host_status(host.id, "online")
return {"message": f"主机 '{host.name}' 添加成功", "host": host.model_dump()}
@router.delete("/delete/{host_id}")
async def api_delete_host(host_id: str):
"""删除主机"""
if host_id == "local":
raise HTTPException(status_code=400, detail="不能删除本机")
if not remove_host(host_id):
raise HTTPException(status_code=404, detail=f"主机 '{host_id}' 不存在")
conn_pool.remove(host_id)
return {"message": f"主机 '{host_id}' 已删除"}
@router.post("/test")
async def api_test_connection(req: HostCreate):
"""测试连接(不添加主机)"""
result = test_connection(req.uri)
return result
@router.post("/refresh/{host_id}")
async def api_refresh_host(host_id: str):
"""刷新主机状态"""
host = get_host(host_id)
if not host:
raise HTTPException(status_code=404, detail=f"主机 '{host_id}' 不存在")
try:
# 重新建立连接
conn_pool.remove(host_id)
mgr = conn_pool.get(host_id)
conn = mgr.conn
alive = conn.isAlive()
status = "online" if alive else "offline"
update_host_status(host_id, status)
return {"status": status, "hostname": conn.getHostname()}
except Exception as e:
update_host_status(host_id, "offline")
conn_pool.remove(host_id)
return {"status": "offline", "error": str(e)}
+10 -25
View File
@@ -1,6 +1,6 @@
"""资源监控路由"""
from fastapi import APIRouter, HTTPException
from app.libvirt_conn import libvirt_conn
from fastapi import APIRouter, HTTPException, Query
from app.libvirt_conn import conn_pool
import libvirt
import time
import threading
@@ -13,23 +13,19 @@ _cache_lock = threading.Lock()
@router.get("/overview")
async def monitor_overview():
async def monitor_overview(host_id: str = Query("local")):
"""宿主机总览监控"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
# 宿主机信息
host_info = conn.getInfo()
hostname = conn.getHostname()
# CPU 使用率(通过 node info
cpu_stats = conn.getCPUStats(-1, 0) # 全局 CPU 统计
cpu_stats = conn.getCPUStats(-1, 0)
cpu_total = cpu_stats.get("user", 0) + cpu_stats.get("system", 0) + cpu_stats.get("idle", 0)
cpu_used = cpu_stats.get("user", 0) + cpu_stats.get("system", 0)
cpu_percent = round(cpu_used / cpu_total * 100, 1) if cpu_total > 0 else 0
# 内存
memory_total_kb = host_info[1]
# 获取可用内存
try:
with open("/proc/meminfo", "r") as f:
meminfo = {}
@@ -48,7 +44,6 @@ async def monitor_overview():
mem_available_mb = mem_total_mb
mem_percent = 0
# 虚拟机统计
domains = conn.listAllDomains(0)
running = sum(1 for d in domains if d.isActive())
stopped = len(domains) - running
@@ -75,9 +70,9 @@ async def monitor_overview():
@router.get("/vm/{name}")
async def monitor_vm(name: str):
async def monitor_vm(name: str, host_id: str = Query("local")):
"""获取虚拟机实时监控数据"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
@@ -86,10 +81,9 @@ async def monitor_vm(name: str):
if not dom.isActive():
return {"name": name, "state": "stopped", "cpu_percent": 0, "memory": {}}
# CPU 百分比
cpu_percent = _get_vm_cpu_percent(dom)
cache_key = f"{host_id}_{name}"
cpu_percent = _get_vm_cpu_percent(dom, cache_key)
# 内存
mem_stats = {}
try:
raw = dom.memoryStats()
@@ -104,10 +98,7 @@ async def monitor_vm(name: str):
except Exception:
pass
# 磁盘IO
disk_stats = _get_vm_disk_stats(dom)
# 网络IO
net_stats = _get_vm_net_stats(dom)
return {
@@ -120,17 +111,13 @@ async def monitor_vm(name: str):
}
def _get_vm_cpu_percent(dom) -> float:
def _get_vm_cpu_percent(dom, cache_key: str) -> float:
"""计算虚拟机 CPU 使用率"""
cache_key = f"cpu_{dom.name()}"
try:
# 第一次采样
info1 = dom.info()
cpu_time1 = info1[2]
t1 = time.time()
# 从缓存获取上一次数据
with _cache_lock:
prev = _stats_cache.get(cache_key)
@@ -138,13 +125,11 @@ def _get_vm_cpu_percent(dom) -> float:
cpu_time0, t0 = prev
elapsed = t1 - t0
cpu_diff = cpu_time1 - cpu_time0
# CPU时间单位是纳秒
cpu_percent = round((cpu_diff / 1e9) / elapsed * 100, 1)
cpu_percent = min(cpu_percent, 100.0)
else:
cpu_percent = 0.0
# 更新缓存
with _cache_lock:
_stats_cache[cache_key] = (cpu_time1, t1)
+13 -20
View File
@@ -1,10 +1,10 @@
"""网络管理路由"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional, List
from lxml import etree
from app.libvirt_conn import libvirt_conn
from app.libvirt_conn import conn_pool
import libvirt
router = APIRouter()
@@ -20,15 +20,14 @@ class NetworkCreate(BaseModel):
@router.get("/list")
async def list_networks():
async def list_networks(host_id: str = Query("local")):
"""列出所有网络"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
networks = conn.listAllNetworks(0)
result = []
for net in networks:
xml = etree.fromstring(net.XMLDesc(0).encode())
# 解析网络信息
forward = xml.find("forward")
mode = forward.get("mode", "isolated") if forward is not None else "isolated"
@@ -39,18 +38,13 @@ async def list_networks():
bridge = xml.find("bridge")
bridge_name = bridge.get("name", "") if bridge is not None else ""
# DHCP范围
dhcp_range = None
dhcp = xml.find(".//dhcp")
if dhcp is not None:
r = dhcp.find("range")
if r is not None:
dhcp_range = {
"start": r.get("start", ""),
"end": r.get("end", ""),
}
dhcp_range = {"start": r.get("start", ""), "end": r.get("end", "")}
# 活跃租约
leases = []
try:
for lease in net.DHCPLeases():
@@ -79,9 +73,9 @@ async def list_networks():
@router.get("/detail/{name}")
async def get_network(name: str):
async def get_network(name: str, host_id: str = Query("local")):
"""获取网络详情"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
try:
net = conn.networkLookupByName(name)
except libvirt.libvirtError:
@@ -92,7 +86,7 @@ async def get_network(name: str):
@router.post("/create")
async def create_network(net: NetworkCreate):
async def create_network(net: NetworkCreate, host_id: str = Query("local")):
"""创建网络"""
if net.mode == "bridge" and not net.bridge:
raise HTTPException(status_code=400, detail="桥接模式必须指定桥接网卡")
@@ -104,7 +98,6 @@ async def create_network(net: NetworkCreate):
<bridge name='{net.bridge}'/>
</network>"""
else:
# NAT或隔离模式
import ipaddress
network = ipaddress.ip_network(net.subnet, strict=False)
gateway = str(network.network_address + 1)
@@ -129,7 +122,7 @@ async def create_network(net: NetworkCreate):
</ip>
</network>"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
n = rw_conn.networkDefineXML(xml)
n.setAutostart(1)
@@ -140,9 +133,9 @@ async def create_network(net: NetworkCreate):
@router.delete("/delete/{name}")
async def delete_network(name: str):
async def delete_network(name: str, host_id: str = Query("local")):
"""删除网络"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
net = rw_conn.networkLookupByName(name)
except libvirt.libvirtError:
@@ -155,9 +148,9 @@ async def delete_network(name: str):
@router.post("/action/{name}")
async def network_action(name: str, action: str):
async def network_action(name: str, action: str, host_id: str = Query("local")):
"""网络操作: start/stop"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
net = rw_conn.networkLookupByName(name)
except libvirt.libvirtError:
+13 -13
View File
@@ -1,10 +1,10 @@
"""快照管理路由"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional
from lxml import etree
from app.libvirt_conn import libvirt_conn
from app.libvirt_conn import conn_pool
import libvirt
router = APIRouter()
@@ -16,9 +16,9 @@ class SnapshotCreate(BaseModel):
@router.get("/list/{vm_name}")
async def list_snapshots(vm_name: str):
async def list_snapshots(vm_name: str, host_id: str = Query("local")):
"""列出虚拟机的所有快照"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
try:
dom = conn.lookupByName(vm_name)
except libvirt.libvirtError:
@@ -40,15 +40,15 @@ async def list_snapshots(vm_name: str):
"is_current": snap.isCurrent() == 1,
})
except libvirt.libvirtError:
pass # 没有快照
pass
return {"vm": vm_name, "snapshots": snapshots, "total": len(snapshots)}
@router.post("/create/{vm_name}")
async def create_snapshot(vm_name: str, snap: SnapshotCreate):
async def create_snapshot(vm_name: str, snap: SnapshotCreate, host_id: str = Query("local")):
"""创建快照"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
dom = rw_conn.lookupByName(vm_name)
except libvirt.libvirtError:
@@ -68,9 +68,9 @@ async def create_snapshot(vm_name: str, snap: SnapshotCreate):
@router.post("/revert/{vm_name}/{snap_name}")
async def revert_snapshot(vm_name: str, snap_name: str):
async def revert_snapshot(vm_name: str, snap_name: str, host_id: str = Query("local")):
"""恢复快照"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
dom = rw_conn.lookupByName(vm_name)
snap = dom.snapshotLookupByName(snap_name)
@@ -85,9 +85,9 @@ async def revert_snapshot(vm_name: str, snap_name: str):
@router.delete("/delete/{vm_name}/{snap_name}")
async def delete_snapshot(vm_name: str, snap_name: str):
async def delete_snapshot(vm_name: str, snap_name: str, host_id: str = Query("local")):
"""删除快照"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
dom = rw_conn.lookupByName(vm_name)
snap = dom.snapshotLookupByName(snap_name)
@@ -102,9 +102,9 @@ async def delete_snapshot(vm_name: str, snap_name: str):
@router.get("/detail/{vm_name}/{snap_name}")
async def get_snapshot_detail(vm_name: str, snap_name: str):
async def get_snapshot_detail(vm_name: str, snap_name: str, host_id: str = Query("local")):
"""获取快照详情"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
try:
dom = conn.lookupByName(vm_name)
snap = dom.snapshotLookupByName(snap_name)
+15 -16
View File
@@ -1,11 +1,11 @@
"""存储池管理路由"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional
from lxml import etree
import os
from app.libvirt_conn import libvirt_conn
from app.libvirt_conn import conn_pool
import libvirt
router = APIRouter()
@@ -24,9 +24,9 @@ class VolCreate(BaseModel):
@router.get("/pools")
async def list_pools():
async def list_pools(host_id: str = Query("local")):
"""列出所有存储池"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
pools = conn.listAllStoragePools(0)
result = []
for pool in pools:
@@ -47,9 +47,9 @@ async def list_pools():
@router.get("/pool/{name}")
async def get_pool(name: str):
async def get_pool(name: str, host_id: str = Query("local")):
"""获取存储池详情"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
try:
pool = conn.storagePoolLookupByName(name)
except libvirt.libvirtError:
@@ -58,7 +58,6 @@ async def get_pool(name: str):
info = pool.info()
xml = etree.fromstring(pool.XMLDesc(0).encode())
# 获取卷列表
volumes = []
try:
for vol_name in pool.listVolumes():
@@ -89,9 +88,9 @@ async def get_pool(name: str):
@router.post("/pool/create")
async def create_pool(pool: PoolCreate):
async def create_pool(pool: PoolCreate, host_id: str = Query("local")):
"""创建存储池"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
xml = f"""<pool type='{pool.type}'>
<name>{pool.name}</name>
<target>
@@ -109,9 +108,9 @@ async def create_pool(pool: PoolCreate):
@router.delete("/pool/{name}")
async def delete_pool(name: str):
async def delete_pool(name: str, host_id: str = Query("local")):
"""删除存储池"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
pool = rw_conn.storagePoolLookupByName(name)
except libvirt.libvirtError:
@@ -125,9 +124,9 @@ async def delete_pool(name: str):
@router.post("/pool/{name}/volume")
async def create_volume(name: str, vol: VolCreate):
async def create_volume(name: str, vol: VolCreate, host_id: str = Query("local")):
"""在存储池中创建卷"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
pool = rw_conn.storagePoolLookupByName(name)
except libvirt.libvirtError:
@@ -149,9 +148,9 @@ async def create_volume(name: str, vol: VolCreate):
@router.delete("/pool/{pool_name}/volume/{vol_name}")
async def delete_volume(pool_name: str, vol_name: str):
async def delete_volume(pool_name: str, vol_name: str, host_id: str = Query("local")):
"""删除卷"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
pool = rw_conn.storagePoolLookupByName(pool_name)
vol = pool.storageVolLookupByName(vol_name)
@@ -162,7 +161,7 @@ async def delete_volume(pool_name: str, vol_name: str):
@router.get("/isos")
async def list_isos():
async def list_isos(host_id: str = Query("local")):
"""列出可用的ISO镜像"""
iso_dirs = ["/var/lib/libvirt/iso", "/isos", "/mnt/isos"]
isos = []
+23 -35
View File
@@ -1,11 +1,11 @@
"""虚拟机管理路由"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional, List
from lxml import etree
import os
from app.libvirt_conn import libvirt_conn
from app.libvirt_conn import conn_pool
from app.utils import parse_vm_info, generate_vm_xml
import libvirt
@@ -36,9 +36,9 @@ class VMClone(BaseModel):
# ===== API =====
@router.get("/list")
async def list_vms():
async def list_vms(host_id: str = Query("local")):
"""获取所有虚拟机列表"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
domains = conn.listAllDomains(0)
vms = []
for dom in domains:
@@ -56,9 +56,9 @@ async def list_vms():
@router.get("/detail/{name}")
async def get_vm_detail(name: str):
async def get_vm_detail(name: str, host_id: str = Query("local")):
"""获取虚拟机详情"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
@@ -69,20 +69,17 @@ async def get_vm_detail(name: str):
# 运行中的虚拟机获取更多动态信息
if info["state"] == "running":
try:
# CPU 时间
_, _, cpu_time, _ = dom.info()
info["cpu_time_ns"] = cpu_time
except Exception:
pass
# 内存使用
try:
mem_stats = dom.memoryStats()
info["memory_stats"] = mem_stats
except Exception:
pass
# 块设备统计
try:
block_stats = []
for disk in info.get("disks", []):
@@ -99,7 +96,6 @@ async def get_vm_detail(name: str):
except Exception:
pass
# 网络统计
try:
net_stats = []
for i, iface in enumerate(info.get("interfaces", [])):
@@ -120,18 +116,18 @@ async def get_vm_detail(name: str):
@router.post("/create")
async def create_vm(vm: VMCreate):
async def create_vm(vm: VMCreate, host_id: str = Query("local")):
"""创建虚拟机"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
# 检查名称是否已存在
try:
conn.lookupByName(vm.name)
raise HTTPException(status_code=400, detail=f"虚拟机 '{vm.name}' 已存在")
except libvirt.libvirtError:
pass # 不存在,继续创建
pass
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
# 确定磁盘路径
pool = rw_conn.storagePoolLookupByName(vm.pool_name)
@@ -143,7 +139,6 @@ async def create_vm(vm: VMCreate):
disk_path = os.path.join(pool_path, f"{vm.name}.qcow2")
# 创建 qcow2 磁盘
# 创建卷的 XML
vol_xml = f"""<volume>
<name>{vm.name}.qcow2</name>
<capacity unit='GiB'>{vm.disk_gb}</capacity>
@@ -179,15 +174,15 @@ async def create_vm(vm: VMCreate):
@router.post("/action/{name}")
async def vm_action(name: str, action: VMAction):
async def vm_action(name: str, action: VMAction, host_id: str = Query("local")):
"""虚拟机操作"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
rw_dom = rw_conn.lookupByName(name)
except libvirt.libvirtError:
@@ -221,9 +216,9 @@ async def vm_action(name: str, action: VMAction):
@router.delete("/delete/{name}")
async def delete_vm(name: str, force: bool = False):
async def delete_vm(name: str, force: bool = False, host_id: str = Query("local")):
"""删除虚拟机"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
dom = rw_conn.lookupByName(name)
except libvirt.libvirtError:
@@ -262,9 +257,9 @@ async def delete_vm(name: str, force: bool = False):
@router.post("/clone/{name}")
async def clone_vm(name: str, clone: VMClone):
async def clone_vm(name: str, clone: VMClone, host_id: str = Query("local")):
"""克隆虚拟机"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
dom = rw_conn.lookupByName(name)
except libvirt.libvirtError:
@@ -278,19 +273,15 @@ async def clone_vm(name: str, clone: VMClone):
pass
try:
# 获取源虚拟机 XML
xml_desc = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE)
tree = etree.fromstring(xml_desc.encode())
# 修改名称
tree.find("name").text = clone.new_name
# 修改 UUID(删除让libvirt自动生成)
uuid_elem = tree.find("uuid")
if uuid_elem is not None:
tree.remove(uuid_elem)
# 修改磁盘路径
import uuid as uuid_mod
new_uuid = str(uuid_mod.uuid4())[:8]
for disk in tree.findall(".//disk[@device='disk']"):
@@ -300,7 +291,6 @@ async def clone_vm(name: str, clone: VMClone):
new_path = old_path.replace(f"{name}.qcow2", f"{clone.new_name}.qcow2")
source.set("file", new_path)
# 修改 MAC 地址
for mac in tree.findall(".//interface/mac"):
import random
mac_addr = "52:54:00:%02x:%02x:%02x" % (
@@ -310,7 +300,6 @@ async def clone_vm(name: str, clone: VMClone):
)
mac.set("address", mac_addr)
# 复制磁盘
old_disk_path = ""
new_disk_path = ""
for disk in tree.findall(".//disk[@device='disk']/source"):
@@ -325,7 +314,6 @@ async def clone_vm(name: str, clone: VMClone):
capture_output=True,
)
# 定义新虚拟机
new_xml = etree.tostring(tree, encoding="unicode")
rw_conn.defineXML(new_xml)
@@ -336,9 +324,9 @@ async def clone_vm(name: str, clone: VMClone):
@router.get("/xml/{name}")
async def get_vm_xml(name: str):
async def get_vm_xml(name: str, host_id: str = Query("local")):
"""获取虚拟机 XML 配置"""
conn = libvirt_conn.conn
conn = conn_pool.get_conn(host_id)
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
@@ -348,9 +336,9 @@ async def get_vm_xml(name: str):
@router.put("/xml/{name}")
async def update_vm_xml(name: str, xml: dict):
async def update_vm_xml(name: str, xml: dict, host_id: str = Query("local")):
"""更新虚拟机 XML 配置"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
dom = rw_conn.lookupByName(name)
except libvirt.libvirtError:
@@ -368,9 +356,9 @@ async def update_vm_xml(name: str, xml: dict):
@router.post("/migrate/{name}")
async def migrate_vm(name: str, dest_uri: str, live: bool = True):
async def migrate_vm(name: str, dest_uri: str, live: bool = True, host_id: str = Query("local")):
"""迁移虚拟机"""
with libvirt_conn.get_rw() as rw_conn:
with conn_pool.get_rw(host_id) as rw_conn:
try:
dom = rw_conn.lookupByName(name)
except libvirt.libvirtError:
+1
View File
@@ -6,6 +6,7 @@ pydantic==2.5.2
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
aiofiles==23.2.1
websockify==0.10.0
lxml==4.9.3
+4 -2
View File
@@ -25,10 +25,12 @@ server {
}
# WebSocket (VNC)
location /websockify {
proxy_pass http://backend:8004/websockify;
location /ws/ {
proxy_pass http://backend:8004/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+7
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@novnc/novnc": "^1.7.0",
"axios": "^1.15.2",
"echarts": "^6.0.0",
"element-plus": "^2.13.7",
@@ -193,6 +194,12 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@novnc/novnc": "^1.7.0",
"axios": "^1.15.2",
"echarts": "^6.0.0",
"element-plus": "^2.13.7",
+91
View File
@@ -0,0 +1,91 @@
"""Simple static file server for the KVM frontend with API proxy"""
import http.server
import urllib.request
import urllib.error
import os
import json
PORT = 8006
DIST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dist")
API_BASE = "http://127.0.0.1:8004"
class KVMHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=DIST_DIR, **kwargs)
def do_GET(self):
if self.path.startswith("/api/"):
self._proxy("GET")
elif self.path == "/" or self.path == "":
self.path = "/index.html"
super().do_GET()
else:
# SPA fallback: if file doesn't exist, serve index.html
file_path = os.path.join(DIST_DIR, self.path.lstrip("/"))
if os.path.isfile(file_path):
super().do_GET()
else:
self.path = "/index.html"
super().do_GET()
def do_POST(self):
if self.path.startswith("/api/"):
self._proxy("POST")
else:
self.send_error(404)
def do_PUT(self):
if self.path.startswith("/api/"):
self._proxy("PUT")
else:
self.send_error(404)
def do_DELETE(self):
if self.path.startswith("/api/"):
self._proxy("DELETE")
else:
self.send_error(404)
def _proxy(self, method):
"""Proxy API requests to backend"""
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else None
url = f"{API_BASE}{self.path}"
req = urllib.request.Request(url, data=body, method=method)
# Forward headers
for key in ["Content-Type", "Authorization"]:
if key in self.headers:
req.add_header(key, self.headers[key])
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_body = resp.read()
self.send_response(resp.status)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(resp_body)
except urllib.error.HTTPError as e:
resp_body = e.read()
self.send_response(e.code)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(resp_body)
except Exception as e:
self.send_response(502)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": str(e)}).encode())
def log_message(self, format, *args):
pass # Suppress logs
if __name__ == "__main__":
server = http.server.HTTPServer(("0.0.0.0", PORT), KVMHandler)
print(f"KVM Frontend serving on http://0.0.0.0:{PORT}")
server.serve_forever()
+143
View File
@@ -32,6 +32,9 @@ body {
background: transparent;
}
/* ========== Element Plus 深色主题全局覆盖 ========== */
/* 表格 */
.el-table {
--el-table-bg-color: #1a2633;
--el-table-tr-bg-color: #1a2633;
@@ -40,27 +43,167 @@ body {
--el-table-border-color: #2a3a4e;
--el-table-text-color: #c0ccda;
--el-table-header-text-color: #8aa4be;
--el-table-current-row-bg-color: #243447;
--el-table-expanded-cell-bg-color: #1a2633;
}
/* 表格斑马纹 */
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background: #162230 !important;
}
/* 表格展开行 */
.el-table__expanded-cell {
background: #1a2633 !important;
}
/* 卡片 */
.el-card {
--el-card-bg-color: #1a2633;
--el-card-border-color: #2a3a4e;
}
/* 对话框 */
.el-dialog {
--el-dialog-bg-color: #1a2633;
--el-dialog-title-font-size: 16px;
}
.el-dialog__title {
color: #e0e6ed !important;
}
.el-dialog__headerbtn .el-dialog__close {
color: #7a8fa3 !important;
}
/* 确认弹窗 (MessageBox) */
.el-message-box {
--el-messagebox-title-color: #e0e6ed;
--el-messagebox-content-color: #c0ccda;
background-color: #1a2633 !important;
border: 1px solid #2a3a4e !important;
}
.el-message-box__message p {
color: #c0ccda !important;
}
/* 遮罩层 */
.el-overlay {
background-color: rgba(0, 0, 0, 0.6) !important;
}
/* 表单 */
.el-form-item__label {
color: #8aa4be !important;
}
/* 输入框 */
.el-input__wrapper {
background-color: #0f1923 !important;
box-shadow: 0 0 0 1px #2a3a4e inset !important;
}
.el-input__inner {
color: #c0ccda !important;
}
.el-input__inner::placeholder {
color: #4a5a6a !important;
}
/* 下拉选择器 */
.el-select .el-input__wrapper {
background-color: #0f1923 !important;
}
/* 下拉弹出框 */
.el-select__popper,
.el-popper {
background: #1a2633 !important;
border: 1px solid #2a3a4e !important;
}
.el-select-dropdown__item {
color: #c0ccda !important;
}
.el-select-dropdown__item.hover,
.el-select-dropdown__item:hover {
background-color: #243447 !important;
}
.el-select-dropdown__item.selected {
color: #409eff !important;
}
/* 空状态 */
.el-empty {
--el-empty-description-color: #7a8fa3;
}
.el-empty__image svg {
fill: #2a3a4e;
}
/* 数字输入框 */
.el-input-number__decrease,
.el-input-number__increase {
background: #1e2d3d !important;
color: #c0ccda !important;
border-color: #2a3a4e !important;
}
/* 文本域 */
.el-textarea__inner {
background-color: #0f1923 !important;
color: #c0ccda !important;
border-color: #2a3a4e !important;
}
/* 开关 */
.el-switch__label {
color: #7a8fa3 !important;
}
.el-switch__label.is-active {
color: #409eff !important;
}
/* 提示信息 (Alert) */
.el-alert {
border: none;
}
/* Loading 遮罩 */
.el-loading-mask {
background-color: rgba(15, 25, 35, 0.8) !important;
}
/* 分页 */
.el-pagination {
--el-pagination-bg-color: #1a2633;
--el-pagination-text-color: #c0ccda;
--el-pagination-button-disabled-color: #4a5a6a;
}
/* 标签页 */
.el-tabs__item {
color: #7a8fa3 !important;
}
.el-tabs__item.is-active {
color: #409eff !important;
}
/* 气泡确认框 */
.el-popconfirm {
background: #1a2633 !important;
}
/* 面包屑 */
.el-breadcrumb__inner {
color: #7a8fa3 !important;
}
/* 按钮组 */
.el-button-group .el-button--default {
background-color: #1e2d3d !important;
border-color: #2a3a4e !important;
color: #c0ccda !important;
}
.el-button-group .el-button--default:hover {
background-color: #243447 !important;
color: #e0e6ed !important;
}
</style>
+49
View File
@@ -5,10 +5,59 @@ const api = axios.create({
timeout: 30000,
})
// Token 管理
const TOKEN_KEY = 'kvm_token'
const USER_KEY = 'kvm_user'
export function getToken() {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token)
}
export function removeToken() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
export function getUser() {
try {
return JSON.parse(localStorage.getItem(USER_KEY) || 'null')
} catch {
return null
}
}
export function setUser(user) {
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
export function isAuthenticated() {
return !!getToken()
}
// 请求拦截 - 添加 Token
api.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截
api.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
removeToken()
window.location.href = '/login'
}
console.error('API Error:', err.response?.data?.detail || err.message)
return Promise.reject(err)
}
+21 -4
View File
@@ -19,6 +19,10 @@
<el-icon><Monitor /></el-icon>
<template #title>仪表盘</template>
</el-menu-item>
<el-menu-item index="/hosts">
<el-icon><OfficeBuilding /></el-icon>
<template #title>主机管理</template>
</el-menu-item>
<el-menu-item index="/vms">
<el-icon><Coin /></el-icon>
<template #title>虚拟机</template>
@@ -45,8 +49,9 @@
<span class="page-title">{{ currentTitle }}</span>
</div>
<div class="topbar-right">
<span class="host-name">{{ hostInfo.hostname || '...' }}</span>
<span class="user-info" v-if="user">{{ user.username }}</span>
<el-tag type="success" size="small" effect="dark">在线</el-tag>
<el-button text type="danger" @click="handleLogout" size="small">退出</el-button>
</div>
</div>
@@ -60,17 +65,24 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Monitor, Coin, Files, Connection, Fold, Expand } from '@element-plus/icons-vue'
import api from '../api'
import { useRouter, useRoute } from 'vue-router'
import { Monitor, Coin, Files, Connection, Fold, Expand, OfficeBuilding } from '@element-plus/icons-vue'
import api, { getUser, removeToken } from '../api'
const route = useRoute()
const router = useRouter()
const sidebarCollapsed = ref(false)
const hostInfo = ref({})
const user = getUser()
const currentRoute = computed(() => route.path)
const currentTitle = computed(() => route.meta.title || '')
function handleLogout() {
removeToken()
router.push('/login')
}
onMounted(async () => {
try {
hostInfo.value = await api.get('/host')
@@ -165,6 +177,11 @@ onMounted(async () => {
gap: 12px;
}
.user-info {
color: #c0ccda;
font-size: 13px;
}
.host-name {
color: #7a8fa3;
font-size: 13px;
+27
View File
@@ -1,6 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import { isAuthenticated, removeToken } from '../api'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { title: '登录', public: true },
},
{
path: '/',
component: () => import('../layout/MainLayout.vue'),
@@ -12,6 +19,12 @@ const routes = [
component: () => import('../views/Dashboard.vue'),
meta: { title: '仪表盘' },
},
{
path: 'hosts',
name: 'Hosts',
component: () => import('../views/Hosts.vue'),
meta: { title: '主机管理' },
},
{
path: 'vms',
name: 'VMList',
@@ -24,6 +37,12 @@ const routes = [
component: () => import('../views/VMDetail.vue'),
meta: { title: '虚拟机详情' },
},
{
path: 'console/:name',
name: 'Console',
component: () => import('../views/Console.vue'),
meta: { title: '控制台' },
},
{
path: 'storage',
name: 'Storage',
@@ -47,6 +66,14 @@ const router = createRouter({
router.beforeEach((to) => {
document.title = `${to.meta.title || 'KVM'} - KVM管理平台`
// 不需要认证的页面直接放行
if (to.meta.public) return true
// 未登录跳转到登录页
if (!isAuthenticated()) {
return { name: 'Login' }
}
})
export default router
+185
View File
@@ -0,0 +1,185 @@
<template>
<div class="vnc-page">
<div class="vnc-toolbar">
<el-button text @click="$router.back()">
<el-icon><ArrowLeft /></el-icon> 返回
</el-button>
<span class="vnc-title">控制台 - {{ vmName }}</span>
<div class="vnc-toolbar-right">
<el-tag v-if="connected" type="success" size="small">已连接</el-tag>
<el-tag v-else type="danger" size="small">未连接</el-tag>
<el-button size="small" @click="sendCtrlAltDel">Ctrl+Alt+Del</el-button>
<el-button size="small" @click="toggleFullscreen">全屏</el-button>
</div>
</div>
<div class="vnc-container" ref="containerRef" id="vnc-container">
<div ref="screenRef" class="vnc-screen"></div>
<div v-if="!connected && !error" class="vnc-connecting">
<p>正在连接到虚拟机控制台...</p>
</div>
<div v-if="error" class="vnc-error">
<p>{{ error }}</p>
<el-button type="primary" @click="connect">重新连接</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import RFB from '@novnc/novnc'
const route = useRoute()
const vmName = route.params.name
const hostId = route.query.host_id || 'local'
const containerRef = ref(null)
const screenRef = ref(null)
const connected = ref(false)
const error = ref('')
let rfb = null
function connect() {
error.value = ''
connected.value = false
if (rfb) {
rfb.disconnect()
rfb = null
}
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const url = `${protocol}//${host}/ws/vnc/${vmName}?host_id=${hostId}`
rfb = new RFB(screenRef.value, url)
rfb.scaleViewport = true
rfb.resizeSession = true
rfb.background = '#000'
rfb.addEventListener('connect', () => {
connected.value = true
error.value = ''
})
rfb.addEventListener('disconnect', (e) => {
connected.value = false
if (!e.detail.clean) {
error.value = '连接已断开,虚拟机可能未运行或 VNC 未配置'
}
})
rfb.addEventListener('credentialsrequired', () => {
rfb.sendCredentials({ password: '' })
})
} catch (e) {
error.value = `连接失败: ${e.message}`
}
}
function sendCtrlAltDel() {
if (rfb) {
rfb.sendCtrlAltDel()
}
}
function toggleFullscreen() {
const el = containerRef.value
if (!el) return
if (!document.fullscreenElement) {
el.requestFullscreen()
} else {
document.exitFullscreen()
}
}
onMounted(() => {
connect()
})
onBeforeUnmount(() => {
if (rfb) {
rfb.disconnect()
rfb = null
}
})
</script>
<style scoped>
.vnc-page {
display: flex;
flex-direction: column;
height: calc(100vh - 100px);
}
.vnc-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: #1a2633;
border: 1px solid #2a3a4e;
border-radius: 8px 8px 0 0;
flex-shrink: 0;
}
.vnc-title {
color: #e0e6ed;
font-size: 15px;
font-weight: 600;
}
.vnc-toolbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.vnc-container {
flex: 1;
background: #000;
border: 1px solid #2a3a4e;
border-top: none;
border-radius: 0 0 8px 8px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.vnc-container:fullscreen {
height: 100vh;
border-radius: 0;
}
.vnc-screen {
width: 100%;
height: 100%;
}
.vnc-screen :deep(canvas) {
cursor: default;
}
.vnc-connecting,
.vnc-error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #e0e6ed;
font-size: 14px;
z-index: 10;
}
.vnc-error p {
margin-bottom: 12px;
color: #f56c6c;
}
</style>
+21 -9
View File
@@ -53,7 +53,7 @@
<el-table :data="vms" stripe style="width: 100%">
<el-table-column prop="name" label="名称" min-width="140">
<template #default="{ row }">
<el-link type="primary" @click="$router.push(`/vm/${row.name}`)">{{ row.name }}</el-link>
<el-link type="primary" @click="$router.push(`/vm/${row.name}?host_id=${hostId()}`)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
@@ -133,10 +133,14 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../api'
const route = useRoute()
const hostId = () => route.query.host_id || 'local'
const overview = ref({})
const vms = ref([])
const pools = ref([])
@@ -174,13 +178,15 @@ function poolColor(pool) {
return '#409eff'
}
let refreshTimer = null
async function loadData() {
try {
const [o, v, p, n] = await Promise.all([
api.get('/monitor/overview'),
api.get('/vm/list'),
api.get('/storage/pools'),
api.get('/network/list'),
api.get('/monitor/overview', { params: { host_id: hostId() } }),
api.get('/vm/list', { params: { host_id: hostId() } }),
api.get('/storage/pools', { params: { host_id: hostId() } }),
api.get('/network/list', { params: { host_id: hostId() } }),
])
overview.value = o
vms.value = v.vms || []
@@ -197,7 +203,7 @@ async function vmAction(name, action) {
await ElMessageBox.confirm(`确定要${labels[action]}虚拟机 ${name} 吗?`, '确认操作', {
type: action === 'force_stop' ? 'warning' : 'info',
})
await api.post(`/vm/action/${name}`, { action })
await api.post(`/vm/action/${name}`, { action }, { params: { host_id: hostId() } })
ElMessage.success(`${labels[action]}操作已发送`)
setTimeout(loadData, 2000)
} catch (e) {
@@ -207,8 +213,14 @@ async function vmAction(name, action) {
onMounted(() => {
loadData()
const timer = setInterval(loadData, 10000)
// cleanup on unmount handled by vue
refreshTimer = setInterval(loadData, 10000)
})
onBeforeUnmount(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
</script>
+306
View File
@@ -0,0 +1,306 @@
<template>
<div class="hosts-page">
<div class="toolbar">
<el-button type="primary" @click="showDialog = true">
<el-icon><Plus /></el-icon> 添加主机
</el-button>
<el-button @click="loadData"><el-icon><Refresh /></el-icon> 刷新</el-button>
</div>
<!-- 主机列表 -->
<el-row :gutter="16">
<el-col :span="8" v-for="host in hosts" :key="host.id" style="margin-bottom: 16px;">
<div class="host-card" :class="{ offline: host.status === 'offline' }">
<div class="host-header">
<div>
<h3>
{{ host.name }}
<el-tag v-if="host.type === 'local'" type="success" size="small" effect="plain">本机</el-tag>
</h3>
<span class="host-uri">{{ host.uri }}</span>
</div>
<div class="host-status">
<el-tag :type="host.status === 'online' ? 'success' : 'danger'" size="small" effect="dark">
{{ host.status === 'online' ? '在线' : '离线' }}
</el-tag>
</div>
</div>
<div v-if="host.status === 'online'" class="host-info">
<el-row :gutter="8">
<el-col :span="8">
<div class="stat">
<div class="stat-val">{{ host.cpu_cores || '-' }}</div>
<div class="stat-lbl">CPU核心</div>
</div>
</el-col>
<el-col :span="8">
<div class="stat">
<div class="stat-val">{{ host.memory_mb ? (host.memory_mb / 1024).toFixed(1) + 'G' : '-' }}</div>
<div class="stat-lbl">内存</div>
</div>
</el-col>
<el-col :span="8">
<div class="stat">
<div class="stat-val">{{ host.vm_total ?? '-' }}</div>
<div class="stat-lbl">虚拟机</div>
</div>
</el-col>
</el-row>
<div class="vm-stat" v-if="host.vm_total">
运行 {{ host.vm_running || 0 }} / 总计 {{ host.vm_total }}
</div>
</div>
<div v-else class="host-offline">
<span>无法连接到该主机</span>
</div>
<div class="host-actions">
<el-button type="primary" size="small" @click="enterHost(host)"
:disabled="host.status !== 'online'">
管理虚拟机
</el-button>
<el-button size="small" @click="refreshHost(host.id)">刷新状态</el-button>
<el-button size="small" type="danger" @click="deleteHost(host)"
:disabled="host.type === 'local'">删除</el-button>
</div>
</div>
</el-col>
</el-row>
<el-empty v-if="hosts.length === 0" description="暂无主机" />
<!-- 添加主机对话框 -->
<el-dialog v-model="showDialog" title="添加主机" width="550px" :close-on-click-modal="false">
<el-form :model="form" label-width="110px">
<el-form-item label="主机名称">
<el-input v-model="form.name" placeholder="如:生产服务器-1" />
</el-form-item>
<el-form-item label="连接URI">
<el-select v-model="form.mode" style="margin-bottom: 8px; width: 100%;" @change="onModeChange">
<el-option label="TCP 直连" value="tcp" />
<el-option label="SSH 隧道" value="ssh" />
</el-select>
<el-input v-model="form.uri" placeholder="qemu+tcp://192.168.1.2/system" />
</el-form-item>
<el-form-item label="SSH密钥路径" v-if="form.mode === 'ssh'">
<el-input v-model="form.ssh_key_path" placeholder="/root/.ssh/id_rsa(可选)" />
</el-form-item>
<el-form-item>
<el-button @click="testConn" :loading="testing">
测试连接
</el-button>
<span v-if="testResult" style="margin-left: 12px;">
<el-tag v-if="testResult.success" type="success" size="small">
{{ testResult.hostname }} - {{ testResult.cpu_cores }} / {{ testResult.memory_mb }}MB
</el-tag>
<el-tag v-else type="danger" size="small">{{ testResult.error }}</el-tag>
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="addHost" :loading="adding">添加</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const router = useRouter()
const hosts = ref([])
const showDialog = ref(false)
const testing = ref(false)
const adding = ref(false)
const testResult = ref(null)
const form = ref({
name: '',
mode: 'tcp',
uri: '',
ssh_key_path: '',
})
async function loadData() {
try {
const data = await api.get('/hosts/list')
hosts.value = data.hosts || []
} catch (e) {
console.error(e)
}
}
function onModeChange(mode) {
if (mode === 'tcp') {
form.value.uri = 'qemu+tcp://192.168.1.2/system'
} else {
form.value.uri = 'qemu+ssh://root@192.168.1.2/system'
}
}
async function testConn() {
if (!form.value.uri) {
ElMessage.warning('请输入连接 URI')
return
}
testing.value = true
testResult.value = null
try {
testResult.value = await api.post('/hosts/test', {
name: form.value.name,
uri: form.value.uri,
ssh_key_path: form.value.ssh_key_path || null,
})
} catch (e) {
testResult.value = { success: false, error: e.response?.data?.detail || '测试失败' }
}
testing.value = false
}
async function addHost() {
if (!form.value.name || !form.value.uri) {
ElMessage.warning('请填写名称和 URI')
return
}
adding.value = true
try {
await api.post('/hosts/add', {
name: form.value.name,
uri: form.value.uri,
ssh_key_path: form.value.ssh_key_path || null,
})
ElMessage.success('主机添加成功')
showDialog.value = false
form.value = { name: '', mode: 'tcp', uri: '', ssh_key_path: '' }
testResult.value = null
loadData()
} catch (e) {
ElMessage.error(e.response?.data?.detail || '添加失败')
}
adding.value = false
}
async function refreshHost(hostId) {
try {
await api.post(`/hosts/refresh/${hostId}`)
ElMessage.success('已刷新')
loadData()
} catch (e) {
ElMessage.error('刷新失败')
}
}
async function deleteHost(host) {
if (host.type === 'local') return
try {
await ElMessageBox.confirm(`确定删除主机 ${host.name} 吗?`, '确认', { type: 'error' })
await api.delete(`/hosts/delete/${host.id}`)
ElMessage.success('已删除')
loadData()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.detail || '删除失败')
}
}
function enterHost(host) {
router.push({ path: '/vms', query: { host_id: host.id } })
}
onMounted(loadData)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 8px;
}
.host-card {
background: #1a2633;
border: 1px solid #2a3a4e;
border-radius: 8px;
padding: 20px;
transition: border-color 0.3s;
}
.host-card:hover {
border-color: #409eff;
}
.host-card.offline {
opacity: 0.7;
}
.host-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.host-header h3 {
color: #e0e6ed;
font-size: 16px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.host-uri {
color: #5a6a7a;
font-size: 12px;
word-break: break-all;
}
.host-info {
margin-bottom: 16px;
}
.stat {
text-align: center;
padding: 8px;
background: #0f1923;
border-radius: 6px;
}
.stat-val {
color: #e0e6ed;
font-size: 18px;
font-weight: 700;
}
.stat-lbl {
color: #5a6a7a;
font-size: 11px;
margin-top: 4px;
}
.vm-stat {
color: #7a8fa3;
font-size: 12px;
margin-top: 10px;
text-align: center;
}
.host-offline {
color: #5a6a7a;
text-align: center;
padding: 20px 0;
font-size: 13px;
}
.host-actions {
display: flex;
gap: 8px;
border-top: 1px solid #2a3a4e;
padding-top: 12px;
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<div class="logo-icon">🖥</div>
<h1>KVM Manager</h1>
<p>虚拟化管理平台</p>
</div>
<el-form :model="form" @keyup.enter="handleLogin" class="login-form">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" size="large" prefix-icon="User" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" size="large"
prefix-icon="Lock" show-password />
</el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin"
style="width: 100%;"> </el-button>
</el-form>
<div class="login-footer">
<span>默认账号: admin / admin123</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import api, { setToken, setUser } from '../api'
const router = useRouter()
const loading = ref(false)
const form = ref({ username: '', password: '' })
async function handleLogin() {
if (!form.value.username || !form.value.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const data = await api.post('/auth/login', new URLSearchParams({
username: form.value.username,
password: form.value.password,
}))
setToken(data.access_token)
setUser({ username: data.username, role: data.role })
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (e) {
ElMessage.error(e.response?.data?.detail || '登录失败')
}
loading.value = false
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f1923;
}
.login-card {
width: 400px;
background: #1a2633;
border: 1px solid #2a3a4e;
border-radius: 12px;
padding: 40px 36px;
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.logo-icon {
font-size: 40px;
margin-bottom: 12px;
}
.login-header h1 {
color: #e0e6ed;
font-size: 24px;
margin-bottom: 6px;
}
.login-header p {
color: #7a8fa3;
font-size: 14px;
}
.login-form {
margin-bottom: 16px;
}
.login-footer {
text-align: center;
color: #5a6a7a;
font-size: 12px;
}
</style>
+8 -4
View File
@@ -92,10 +92,14 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const route = useRoute()
const hostId = () => route.query.host_id || 'local'
const networks = ref([])
const showDialog = ref(false)
const form = ref({
@@ -109,7 +113,7 @@ const form = ref({
async function loadData() {
try {
const data = await api.get('/network/list')
const data = await api.get('/network/list', { params: { host_id: hostId() } })
networks.value = data.networks || []
} catch (e) {}
}
@@ -122,7 +126,7 @@ async function createNet() {
return
}
try {
await api.post('/network/create', form.value)
await api.post('/network/create', form.value, { params: { host_id: hostId() } })
ElMessage.success('网络创建成功')
showDialog.value = false
form.value = { name: '', mode: 'nat', subnet: '192.168.100.0/24', bridge: '', dhcp_start: '', dhcp_end: '' }
@@ -134,7 +138,7 @@ async function createNet() {
async function toggleNet(name, action) {
try {
await api.post(`/network/action/${name}?action=${action}`)
await api.post(`/network/action/${name}?action=${action}&host_id=${hostId()}`)
ElMessage.success('操作成功')
setTimeout(loadData, 1000)
} catch (e) {
@@ -145,7 +149,7 @@ async function toggleNet(name, action) {
async function deleteNet(name) {
try {
await ElMessageBox.confirm(`确定删除网络 ${name} 吗?`, '确认', { type: 'error' })
await api.delete(`/network/delete/${name}`)
await api.delete(`/network/delete/${name}`, { params: { host_id: hostId() } })
ElMessage.success('网络已删除')
loadData()
} catch (e) {
+11 -7
View File
@@ -116,10 +116,14 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const route = useRoute()
const hostId = () => route.query.host_id || 'local'
const pools = ref([])
const isos = ref([])
const volumes = ref([])
@@ -134,8 +138,8 @@ const volForm = ref({ name: '', capacity_gb: 20, format: 'qcow2' })
async function loadData() {
try {
const [p, i] = await Promise.all([
api.get('/storage/pools'),
api.get('/storage/isos'),
api.get('/storage/pools', { params: { host_id: hostId() } }),
api.get('/storage/isos', { params: { host_id: hostId() } }),
])
pools.value = p.pools || []
isos.value = i.isos || []
@@ -145,7 +149,7 @@ async function loadData() {
async function viewVolumes(poolName) {
currentPool.value = poolName
try {
const data = await api.get(`/storage/pool/${poolName}`)
const data = await api.get(`/storage/pool/${poolName}`, { params: { host_id: hostId() } })
volumes.value = data.volumes || []
showVolumesDialog.value = true
} catch (e) {}
@@ -153,7 +157,7 @@ async function viewVolumes(poolName) {
async function createPool() {
try {
await api.post('/storage/pool/create', poolForm.value)
await api.post('/storage/pool/create', poolForm.value, { params: { host_id: hostId() } })
ElMessage.success('存储池创建成功')
showPoolDialog.value = false
poolForm.value = { name: '', path: '', type: 'dir' }
@@ -166,7 +170,7 @@ async function createPool() {
async function deletePool(name) {
try {
await ElMessageBox.confirm(`确定删除存储池 ${name} 吗?`, '确认', { type: 'error' })
await api.delete(`/storage/pool/${name}`)
await api.delete(`/storage/pool/${name}`, { params: { host_id: hostId() } })
ElMessage.success('存储池已删除')
loadData()
} catch (e) {
@@ -176,7 +180,7 @@ async function deletePool(name) {
async function createVol() {
try {
await api.post(`/storage/pool/${currentPool.value}/volume`, volForm.value)
await api.post(`/storage/pool/${currentPool.value}/volume`, volForm.value, { params: { host_id: hostId() } })
ElMessage.success('卷创建成功')
showVolDialog.value = false
volForm.value = { name: '', capacity_gb: 20, format: 'qcow2' }
@@ -189,7 +193,7 @@ async function createVol() {
async function deleteVol(volName) {
try {
await ElMessageBox.confirm(`确定删除卷 ${volName} 吗?`, '确认', { type: 'error' })
await api.delete(`/storage/pool/${currentPool.value}/volume/${volName}`)
await api.delete(`/storage/pool/${currentPool.value}/volume/${volName}`, { params: { host_id: hostId() } })
ElMessage.success('卷已删除')
viewVolumes(currentPool.value)
} catch (e) {
+181 -9
View File
@@ -18,6 +18,10 @@
<el-button v-if="vm.state === 'paused'" type="success" @click="doAction('resume')">恢复</el-button>
<el-button v-if="vm.state === 'running'" type="danger" @click="doAction('force_stop')">强制关机</el-button>
<el-button v-if="vm.state === 'running'" @click="doAction('restart')">重启</el-button>
<el-button v-if="vm.vnc_port > 0" type="primary" @click="openConsole">控制台</el-button>
<el-button @click="showCloneDialog = true">克隆</el-button>
<el-button @click="showMigrateDialog = true">迁移</el-button>
<el-button @click="loadXml">XML配置</el-button>
</div>
</div>
@@ -130,6 +134,61 @@
</el-table>
</div>
<!-- VNC 控制台 -->
<div v-if="showConsole" class="info-card" style="margin-top: 16px;">
<div class="section-header">
<h3>VNC 控制台</h3>
<div style="display: flex; gap: 8px;">
<el-button size="small" @click="toggleFullscreen">全屏</el-button>
<el-button size="small" type="danger" @click="showConsole = false">关闭</el-button>
</div>
</div>
<div class="vnc-container" id="vnc-container">
<div id="vnc-screen"></div>
</div>
</div>
<!-- 克隆对话框 -->
<el-dialog v-model="showCloneDialog" title="克隆虚拟机" width="450px">
<el-form :model="cloneForm" label-width="100px">
<el-form-item label="新虚拟机名">
<el-input v-model="cloneForm.new_name" placeholder="输入新虚拟机名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCloneDialog = false">取消</el-button>
<el-button type="primary" @click="cloneVM" :loading="cloning">克隆</el-button>
</template>
</el-dialog>
<!-- 迁移对话框 -->
<el-dialog v-model="showMigrateDialog" title="迁移虚拟机" width="500px">
<el-alert type="warning" :closable="false" style="margin-bottom: 16px;">
虚拟机将被迁移到目标宿主机请确保目标主机已配置 libvirt 并允许远程连接
</el-alert>
<el-form :model="migrateForm" label-width="120px">
<el-form-item label="目标URI">
<el-input v-model="migrateForm.dest_uri" placeholder="qemu+tcp://192.168.1.2/system" />
</el-form-item>
<el-form-item label="迁移模式">
<el-switch v-model="migrateForm.live" active-text="在线迁移" inactive-text="离线迁移" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showMigrateDialog = false">取消</el-button>
<el-button type="primary" @click="migrateVM" :loading="migrating">迁移</el-button>
</template>
</el-dialog>
<!-- XML 编辑对话框 -->
<el-dialog v-model="showXmlDialog" title="XML 配置" width="700px">
<el-input v-model="xmlContent" type="textarea" :rows="20" style="font-family: monospace;" />
<template #footer>
<el-button @click="showXmlDialog = false">取消</el-button>
<el-button type="primary" @click="saveXml" :loading="savingXml">保存</el-button>
</template>
</el-dialog>
<!-- 创建快照对话框 -->
<el-dialog v-model="showSnapDialog" title="创建快照" width="400px">
<el-form :model="snapForm" label-width="80px">
@@ -149,20 +208,33 @@
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import api from '../api'
const route = useRoute()
const router = useRouter()
const vmName = route.params.name
const hostId = () => route.query.host_id || 'local'
const loading = ref(true)
const vm = ref({})
const monitor = ref({})
const snapshots = ref([])
const snapLoading = ref(false)
const showSnapDialog = ref(false)
const showConsole = ref(false)
const showCloneDialog = ref(false)
const showXmlDialog = ref(false)
const showMigrateDialog = ref(false)
const cloning = ref(false)
const savingXml = ref(false)
const migrating = ref(false)
const xmlContent = ref('')
const cloneForm = ref({ new_name: '' })
const migrateForm = ref({ dest_uri: '', live: true })
let rfb = null
const snapForm = ref({ name: '', description: '' })
let timer = null
@@ -197,21 +269,21 @@ function formatTime(ts) {
async function loadVM() {
try {
vm.value = await api.get(`/vm/detail/${vmName}`)
vm.value = await api.get(`/vm/detail/${vmName}`, { params: { host_id: hostId() } })
} catch (e) {}
loading.value = false
}
async function loadMonitor() {
try {
monitor.value = await api.get(`/monitor/vm/${vmName}`)
monitor.value = await api.get(`/monitor/vm/${vmName}`, { params: { host_id: hostId() } })
} catch (e) {}
}
async function loadSnapshots() {
snapLoading.value = true
try {
const data = await api.get(`/snapshot/list/${vmName}`)
const data = await api.get(`/snapshot/list/${vmName}`, { params: { host_id: hostId() } })
snapshots.value = data.snapshots || []
} catch (e) {}
snapLoading.value = false
@@ -221,7 +293,7 @@ async function doAction(action) {
const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复', restart: '重启' }
try {
await ElMessageBox.confirm(`确定要${labels[action]}吗?`, '确认', { type: 'info' })
await api.post(`/vm/action/${vmName}`, { action })
await api.post(`/vm/action/${vmName}`, { action }, { params: { host_id: hostId() } })
ElMessage.success(`${labels[action]}操作已发送`)
setTimeout(() => { loadVM(); loadMonitor() }, 2000)
} catch (e) {
@@ -235,7 +307,7 @@ async function createSnap() {
return
}
try {
await api.post(`/snapshot/create/${vmName}`, snapForm.value)
await api.post(`/snapshot/create/${vmName}`, snapForm.value, { params: { host_id: hostId() } })
ElMessage.success('快照创建成功')
showSnapDialog.value = false
snapForm.value = { name: '', description: '' }
@@ -248,7 +320,7 @@ async function createSnap() {
async function revertSnap(name) {
try {
await ElMessageBox.confirm(`确定恢复到快照 ${name} 吗?虚拟机将重启。`, '确认', { type: 'warning' })
await api.post(`/snapshot/revert/${vmName}/${name}`)
await api.post(`/snapshot/revert/${vmName}/${name}`, null, { params: { host_id: hostId() } })
ElMessage.success('快照已恢复')
loadVM()
loadSnapshots()
@@ -260,7 +332,7 @@ async function revertSnap(name) {
async function deleteSnap(name) {
try {
await ElMessageBox.confirm(`确定删除快照 ${name} 吗?`, '确认', { type: 'error' })
await api.delete(`/snapshot/delete/${vmName}/${name}`)
await api.delete(`/snapshot/delete/${vmName}/${name}`, { params: { host_id: hostId() } })
ElMessage.success('快照已删除')
loadSnapshots()
} catch (e) {
@@ -268,6 +340,85 @@ async function deleteSnap(name) {
}
}
async function cloneVM() {
if (!cloneForm.value.new_name) {
ElMessage.warning('请输入新虚拟机名称')
return
}
cloning.value = true
try {
await api.post(`/vm/clone/${vmName}`, cloneForm.value, { params: { host_id: hostId() } })
ElMessage.success('克隆成功')
showCloneDialog.value = false
cloneForm.value = { new_name: '' }
} catch (e) {
ElMessage.error(e.response?.data?.detail || '克隆失败')
}
cloning.value = false
}
async function migrateVM() {
if (!migrateForm.value.dest_uri) {
ElMessage.warning('请输入目标 URI')
return
}
migrating.value = true
try {
await ElMessageBox.confirm(
`确定将虚拟机迁移到 ${migrateForm.value.dest_uri} 吗?`,
'确认迁移',
{ type: 'warning' }
)
await api.post(`/vm/migrate/${vmName}`, null, {
params: { dest_uri: migrateForm.value.dest_uri, live: migrateForm.value.live, host_id: hostId() }
})
ElMessage.success('迁移成功')
showMigrateDialog.value = false
migrateForm.value = { dest_uri: '', live: true }
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.detail || '迁移失败')
}
migrating.value = false
}
async function loadXml() {
try {
const data = await api.get(`/vm/xml/${vmName}`, { params: { host_id: hostId() } })
xmlContent.value = data.xml || ''
showXmlDialog.value = true
} catch (e) {
ElMessage.error('获取 XML 失败')
}
}
async function saveXml() {
savingXml.value = true
try {
await api.put(`/vm/xml/${vmName}`, { xml: xmlContent.value }, { params: { host_id: hostId() } })
ElMessage.success('XML 配置已更新')
showXmlDialog.value = false
loadVM()
} catch (e) {
ElMessage.error(e.response?.data?.detail || '保存失败')
}
savingXml.value = false
}
function openConsole() {
router.push({ path: `/console/${vmName}`, query: { host_id: hostId() } })
}
function toggleFullscreen() {
const container = document.getElementById('vnc-container')
if (container) {
if (!document.fullscreenElement) {
container.requestFullscreen()
} else {
document.exitFullscreen()
}
}
}
onMounted(() => {
loadVM()
loadMonitor()
@@ -277,6 +428,7 @@ onMounted(() => {
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
if (rfb) { rfb.disconnect(); rfb = null }
})
</script>
@@ -388,4 +540,24 @@ onBeforeUnmount(() => {
margin-bottom: 0;
padding-bottom: 0;
}
.vnc-container {
width: 100%;
height: 500px;
background: #000;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.vnc-container:fullscreen {
height: 100vh;
}
#vnc-screen {
width: 100%;
height: 100%;
}
</style>
+13 -9
View File
@@ -36,7 +36,7 @@
</el-table-column>
<el-table-column prop="name" label="名称" min-width="150">
<template #default="{ row }">
<el-link type="primary" @click="$router.push(`/vm/${row.name}`)">{{ row.name }}</el-link>
<el-link type="primary" @click="$router.push(`/vm/${row.name}?host_id=${hostId()}`)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
@@ -77,7 +77,7 @@
<el-button v-if="row.state === 'running'" type="danger" size="small"
@click="doAction(row.name, 'force_stop')">强制关</el-button>
<el-button type="primary" size="small"
@click="$router.push(`/vm/${row.name}`)">详情</el-button>
@click="$router.push(`/vm/${row.name}?host_id=${hostId()}`)">详情</el-button>
<el-button type="danger" size="small"
@click="deleteVM(row)">删除</el-button>
</el-button-group>
@@ -133,10 +133,14 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const route = useRoute()
const hostId = () => route.query.host_id || 'local'
const loading = ref(false)
const creating = ref(false)
const vms = ref([])
@@ -173,7 +177,7 @@ function formatMem(mb) {
async function loadData() {
loading.value = true
try {
const data = await api.get('/vm/list')
const data = await api.get('/vm/list', { params: { host_id: hostId() } })
vms.value = data.vms || []
} catch (e) {}
loading.value = false
@@ -182,9 +186,9 @@ async function loadData() {
async function loadOptions() {
try {
const [pools, nets, isos] = await Promise.all([
api.get('/storage/pools'),
api.get('/network/list'),
api.get('/storage/isos'),
api.get('/storage/pools', { params: { host_id: hostId() } }),
api.get('/network/list', { params: { host_id: hostId() } }),
api.get('/storage/isos', { params: { host_id: hostId() } }),
])
poolOptions.value = (pools.pools || []).map(p => p.name)
networkOptions.value = (nets.networks || []).map(n => n.name)
@@ -196,7 +200,7 @@ async function doAction(name, action) {
const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复' }
try {
await ElMessageBox.confirm(`确定要${labels[action]}虚拟机 ${name} 吗?`, '确认', { type: 'info' })
await api.post(`/vm/action/${name}`, { action })
await api.post(`/vm/action/${name}`, { action }, { params: { host_id: hostId() } })
ElMessage.success(`${labels[action]}操作已发送`)
setTimeout(loadData, 2000)
} catch (e) {
@@ -211,7 +215,7 @@ async function createVM() {
}
creating.value = true
try {
await api.post('/vm/create', createForm.value)
await api.post('/vm/create', createForm.value, { params: { host_id: hostId() } })
ElMessage.success('虚拟机创建成功')
showCreateDialog.value = false
loadData()
@@ -228,7 +232,7 @@ async function deleteVM(row) {
'危险操作',
{ type: 'error', confirmButtonText: '确定删除', confirmButtonClass: 'el-button--danger' }
)
await api.delete(`/vm/delete/${row.name}`, { params: { force: row.state === 'running' } })
await api.delete(`/vm/delete/${row.name}`, { params: { force: row.state === 'running', host_id: hostId() } })
ElMessage.success('虚拟机已删除')
loadData()
} catch (e) {
+9
View File
@@ -11,6 +11,15 @@ export default defineConfig({
target: 'http://localhost:8004',
changeOrigin: true,
},
'/ws': {
target: 'http://localhost:8004',
ws: true,
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8004',
changeOrigin: true,
},
},
},
})