diff --git a/.gitignore b/.gitignore index 71a057f..afa5c5f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ node_modules/ dist/ .DS_Store *.log +hosts.json +*.lingma/ diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..1f1ceef --- /dev/null +++ b/backend/app/auth.py @@ -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 diff --git a/backend/app/hosts.py b/backend/app/hosts.py new file mode 100644 index 0000000..26c80d5 --- /dev/null +++ b/backend/app/hosts.py @@ -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)} diff --git a/backend/app/libvirt_conn.py b/backend/app/libvirt_conn.py index 6d95dae..793e37e 100644 --- a/backend/app/libvirt_conn.py +++ b/backend/app/libvirt_conn.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 6aaa7ac..7ca800f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..4265477 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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}' 已删除"} diff --git a/backend/app/routers/host.py b/backend/app/routers/host.py new file mode 100644 index 0000000..108c0ed --- /dev/null +++ b/backend/app/routers/host.py @@ -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)} diff --git a/backend/app/routers/monitor.py b/backend/app/routers/monitor.py index 9e50b03..be6621e 100644 --- a/backend/app/routers/monitor.py +++ b/backend/app/routers/monitor.py @@ -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) diff --git a/backend/app/routers/network.py b/backend/app/routers/network.py index ec1010d..d32817b 100644 --- a/backend/app/routers/network.py +++ b/backend/app/routers/network.py @@ -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): """ 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): """ - 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: diff --git a/backend/app/routers/snapshot.py b/backend/app/routers/snapshot.py index 47a3360..d4f9be6 100644 --- a/backend/app/routers/snapshot.py +++ b/backend/app/routers/snapshot.py @@ -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) diff --git a/backend/app/routers/storage.py b/backend/app/routers/storage.py index c61e02b..e9a4d99 100644 --- a/backend/app/routers/storage.py +++ b/backend/app/routers/storage.py @@ -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.name} @@ -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 = [] diff --git a/backend/app/routers/vm.py b/backend/app/routers/vm.py index 29a7c69..0baeb3e 100644 --- a/backend/app/routers/vm.py +++ b/backend/app/routers/vm.py @@ -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""" {vm.name}.qcow2 {vm.disk_gb} @@ -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: diff --git a/backend/requirements.txt b/backend/requirements.txt index 6fcccd4..a6ca7d8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 223ac4f..798c9cd 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 88609f2..dabb159 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 759efe5..4becc08 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/serve.py b/frontend/serve.py new file mode 100644 index 0000000..b483a6e --- /dev/null +++ b/frontend/serve.py @@ -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() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ad4878c..6f78ea1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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; +} diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e4ae62a..d81149f 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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) } diff --git a/frontend/src/layout/MainLayout.vue b/frontend/src/layout/MainLayout.vue index 92b53af..1d6bcb5 100644 --- a/frontend/src/layout/MainLayout.vue +++ b/frontend/src/layout/MainLayout.vue @@ -19,6 +19,10 @@ + + + + @@ -45,8 +49,9 @@ {{ currentTitle }}
- {{ hostInfo.hostname || '...' }} + 在线 + 退出
@@ -60,17 +65,24 @@ + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index db414ee..2cdde3d 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -53,7 +53,7 @@ @@ -133,10 +133,14 @@ diff --git a/frontend/src/views/Hosts.vue b/frontend/src/views/Hosts.vue new file mode 100644 index 0000000..0e359f0 --- /dev/null +++ b/frontend/src/views/Hosts.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..9a528b6 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/views/Network.vue b/frontend/src/views/Network.vue index 1c4afd2..337f544 100644 --- a/frontend/src/views/Network.vue +++ b/frontend/src/views/Network.vue @@ -92,10 +92,14 @@ @@ -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%; +} diff --git a/frontend/src/views/VMList.vue b/frontend/src/views/VMList.vue index 4c522b9..bea3664 100644 --- a/frontend/src/views/VMList.vue +++ b/frontend/src/views/VMList.vue @@ -36,7 +36,7 @@ @@ -77,7 +77,7 @@ 强制关 详情 + @click="$router.push(`/vm/${row.name}?host_id=${hostId()}`)">详情 删除 @@ -133,10 +133,14 @@