Browse Source

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版本兼容性修复
admin 2 weeks ago
parent
commit
8ccccf8f52

+ 2 - 0
.gitignore

@@ -6,3 +6,5 @@ node_modules/
 dist/
 dist/
 .DS_Store
 .DS_Store
 *.log
 *.log
+hosts.json
+*.lingma/

+ 102 - 0
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

+ 175 - 0
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)}

+ 88 - 13
backend/app/libvirt_conn.py

@@ -1,38 +1,40 @@
-"""libvirt 连接管理 - 使用连接池模式"""
+"""libvirt 多主机连接管理器"""
 import libvirt
 import libvirt
 from contextlib import contextmanager
 from contextlib import contextmanager
-from app.config import settings
 import logging
 import logging
+from app.hosts import get_host, update_host_status, list_hosts
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
 class LibvirtConnection:
 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
         self._conn = None
 
 
     def _connect(self):
     def _connect(self):
-        """建立新的 libvirt 连接"""
+        """建立只读连接"""
         try:
         try:
-            conn = libvirt.openReadOnly(settings.LIBVIRT_URI)
+            conn = libvirt.openReadOnly(self._uri)
             if conn is None:
             if conn is None:
-                raise ConnectionError(f"无法连接到 libvirt: {settings.LIBVIRT_URI}")
+                raise ConnectionError(f"无法连接到 libvirt: {self._uri}")
             return conn
             return conn
         except libvirt.libvirtError as e:
         except libvirt.libvirtError as e:
-            logger.error(f"libvirt 连接错误: {e}")
+            logger.error(f"libvirt 连接错误 ({self._host_id}): {e}")
             raise
             raise
 
 
     def _connect_rw(self):
     def _connect_rw(self):
         """建立可读写连接"""
         """建立可读写连接"""
         try:
         try:
-            conn = libvirt.open(settings.LIBVIRT_URI)
+            conn = libvirt.open(self._uri)
             if conn is None:
             if conn is None:
-                raise ConnectionError(f"无法连接到 libvirt (RW): {settings.LIBVIRT_URI}")
+                raise ConnectionError(f"无法连接到 libvirt (RW): {self._uri}")
             return conn
             return conn
         except libvirt.libvirtError as e:
         except libvirt.libvirtError as e:
-            logger.error(f"libvirt RW 连接错误: {e}")
+            logger.error(f"libvirt RW 连接错误 ({self._host_id}): {e}")
             raise
             raise
 
 
     @property
     @property
@@ -73,6 +75,79 @@ class LibvirtConnection:
             "cpu_speed": c.getInfo()[3],  # MHz
             "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
+
+
+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 = LibvirtConnection()
+# 全局兼容单例
+libvirt_conn = _CompatConn()

+ 135 - 4
backend/app/main.py

@@ -1,8 +1,9 @@
 """FastAPI 主应用"""
 """FastAPI 主应用"""
 from fastapi import FastAPI
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
 from app.config import settings
 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(
 app = FastAPI(
     title=settings.APP_NAME,
     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(vm.router, prefix=f"{settings.API_PREFIX}/vm", tags=["虚拟机管理"])
 app.include_router(storage.router, prefix=f"{settings.API_PREFIX}/storage", 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=["网络管理"])
 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")
 @app.get(f"{settings.API_PREFIX}/host")
 async def host_info():
 async def host_info():
-    """获取宿主机信息"""
+    """获取本机宿主机信息(兼容旧接口)"""
     from app.libvirt_conn import libvirt_conn
     from app.libvirt_conn import libvirt_conn
     return libvirt_conn.get_host_info()
     return libvirt_conn.get_host_info()
 
 
@@ -42,9 +45,137 @@ async def host_info():
 @app.get("/health")
 @app.get("/health")
 async def health():
 async def health():
     """健康检查"""
     """健康检查"""
-    from app.libvirt_conn import libvirt_conn
+    from app.libvirt_conn import conn_pool
     try:
     try:
-        conn = libvirt_conn.conn
+        conn = conn_pool.get_conn("local")
         return {"status": "ok", "libvirt": conn.isAlive()}
         return {"status": "ok", "libvirt": conn.isAlive()}
     except Exception as e:
     except Exception as e:
         return {"status": "error", "message": str(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 - 0
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}' 已删除"}

+ 116 - 0
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)}

+ 10 - 25
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 libvirt
 import time
 import time
 import threading
 import threading
@@ -13,23 +13,19 @@ _cache_lock = threading.Lock()
 
 
 
 
 @router.get("/overview")
 @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()
     host_info = conn.getInfo()
     hostname = conn.getHostname()
     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_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_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
     cpu_percent = round(cpu_used / cpu_total * 100, 1) if cpu_total > 0 else 0
 
 
-    # 内存
     memory_total_kb = host_info[1]
     memory_total_kb = host_info[1]
-    # 获取可用内存
     try:
     try:
         with open("/proc/meminfo", "r") as f:
         with open("/proc/meminfo", "r") as f:
             meminfo = {}
             meminfo = {}
@@ -48,7 +44,6 @@ async def monitor_overview():
         mem_available_mb = mem_total_mb
         mem_available_mb = mem_total_mb
         mem_percent = 0
         mem_percent = 0
 
 
-    # 虚拟机统计
     domains = conn.listAllDomains(0)
     domains = conn.listAllDomains(0)
     running = sum(1 for d in domains if d.isActive())
     running = sum(1 for d in domains if d.isActive())
     stopped = len(domains) - running
     stopped = len(domains) - running
@@ -75,9 +70,9 @@ async def monitor_overview():
 
 
 
 
 @router.get("/vm/{name}")
 @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:
     try:
         dom = conn.lookupByName(name)
         dom = conn.lookupByName(name)
     except libvirt.libvirtError:
     except libvirt.libvirtError:
@@ -86,10 +81,9 @@ async def monitor_vm(name: str):
     if not dom.isActive():
     if not dom.isActive():
         return {"name": name, "state": "stopped", "cpu_percent": 0, "memory": {}}
         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 = {}
     mem_stats = {}
     try:
     try:
         raw = dom.memoryStats()
         raw = dom.memoryStats()
@@ -104,10 +98,7 @@ async def monitor_vm(name: str):
     except Exception:
     except Exception:
         pass
         pass
 
 
-    # 磁盘IO
     disk_stats = _get_vm_disk_stats(dom)
     disk_stats = _get_vm_disk_stats(dom)
-
-    # 网络IO
     net_stats = _get_vm_net_stats(dom)
     net_stats = _get_vm_net_stats(dom)
 
 
     return {
     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 使用率"""
     """计算虚拟机 CPU 使用率"""
-    cache_key = f"cpu_{dom.name()}"
-
     try:
     try:
-        # 第一次采样
         info1 = dom.info()
         info1 = dom.info()
         cpu_time1 = info1[2]
         cpu_time1 = info1[2]
         t1 = time.time()
         t1 = time.time()
 
 
-        # 从缓存获取上一次数据
         with _cache_lock:
         with _cache_lock:
             prev = _stats_cache.get(cache_key)
             prev = _stats_cache.get(cache_key)
 
 
@@ -138,13 +125,11 @@ def _get_vm_cpu_percent(dom) -> float:
             cpu_time0, t0 = prev
             cpu_time0, t0 = prev
             elapsed = t1 - t0
             elapsed = t1 - t0
             cpu_diff = cpu_time1 - cpu_time0
             cpu_diff = cpu_time1 - cpu_time0
-            # CPU时间单位是纳秒
             cpu_percent = round((cpu_diff / 1e9) / elapsed * 100, 1)
             cpu_percent = round((cpu_diff / 1e9) / elapsed * 100, 1)
             cpu_percent = min(cpu_percent, 100.0)
             cpu_percent = min(cpu_percent, 100.0)
         else:
         else:
             cpu_percent = 0.0
             cpu_percent = 0.0
 
 
-        # 更新缓存
         with _cache_lock:
         with _cache_lock:
             _stats_cache[cache_key] = (cpu_time1, t1)
             _stats_cache[cache_key] = (cpu_time1, t1)
 
 

+ 13 - 20
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 pydantic import BaseModel, Field
 from typing import Optional, List
 from typing import Optional, List
 from lxml import etree
 from lxml import etree
 
 
-from app.libvirt_conn import libvirt_conn
+from app.libvirt_conn import conn_pool
 import libvirt
 import libvirt
 
 
 router = APIRouter()
 router = APIRouter()
@@ -20,15 +20,14 @@ class NetworkCreate(BaseModel):
 
 
 
 
 @router.get("/list")
 @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)
     networks = conn.listAllNetworks(0)
     result = []
     result = []
     for net in networks:
     for net in networks:
         xml = etree.fromstring(net.XMLDesc(0).encode())
         xml = etree.fromstring(net.XMLDesc(0).encode())
 
 
-        # 解析网络信息
         forward = xml.find("forward")
         forward = xml.find("forward")
         mode = forward.get("mode", "isolated") if forward is not None else "isolated"
         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 = xml.find("bridge")
         bridge_name = bridge.get("name", "") if bridge is not None else ""
         bridge_name = bridge.get("name", "") if bridge is not None else ""
 
 
-        # DHCP范围
         dhcp_range = None
         dhcp_range = None
         dhcp = xml.find(".//dhcp")
         dhcp = xml.find(".//dhcp")
         if dhcp is not None:
         if dhcp is not None:
             r = dhcp.find("range")
             r = dhcp.find("range")
             if r is not None:
             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 = []
         leases = []
         try:
         try:
             for lease in net.DHCPLeases():
             for lease in net.DHCPLeases():
@@ -79,9 +73,9 @@ async def list_networks():
 
 
 
 
 @router.get("/detail/{name}")
 @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:
     try:
         net = conn.networkLookupByName(name)
         net = conn.networkLookupByName(name)
     except libvirt.libvirtError:
     except libvirt.libvirtError:
@@ -92,7 +86,7 @@ async def get_network(name: str):
 
 
 
 
 @router.post("/create")
 @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:
     if net.mode == "bridge" and not net.bridge:
         raise HTTPException(status_code=400, detail="桥接模式必须指定桥接网卡")
         raise HTTPException(status_code=400, detail="桥接模式必须指定桥接网卡")
@@ -104,7 +98,6 @@ async def create_network(net: NetworkCreate):
   <bridge name='{net.bridge}'/>
   <bridge name='{net.bridge}'/>
 </network>"""
 </network>"""
     else:
     else:
-        # NAT或隔离模式
         import ipaddress
         import ipaddress
         network = ipaddress.ip_network(net.subnet, strict=False)
         network = ipaddress.ip_network(net.subnet, strict=False)
         gateway = str(network.network_address + 1)
         gateway = str(network.network_address + 1)
@@ -129,7 +122,7 @@ async def create_network(net: NetworkCreate):
   </ip>
   </ip>
 </network>"""
 </network>"""
 
 
-    with libvirt_conn.get_rw() as rw_conn:
+    with conn_pool.get_rw(host_id) as rw_conn:
         try:
         try:
             n = rw_conn.networkDefineXML(xml)
             n = rw_conn.networkDefineXML(xml)
             n.setAutostart(1)
             n.setAutostart(1)
@@ -140,9 +133,9 @@ async def create_network(net: NetworkCreate):
 
 
 
 
 @router.delete("/delete/{name}")
 @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:
         try:
             net = rw_conn.networkLookupByName(name)
             net = rw_conn.networkLookupByName(name)
         except libvirt.libvirtError:
         except libvirt.libvirtError:
@@ -155,9 +148,9 @@ async def delete_network(name: str):
 
 
 
 
 @router.post("/action/{name}")
 @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"""
     """网络操作: start/stop"""
-    with libvirt_conn.get_rw() as rw_conn:
+    with conn_pool.get_rw(host_id) as rw_conn:
         try:
         try:
             net = rw_conn.networkLookupByName(name)
             net = rw_conn.networkLookupByName(name)
         except libvirt.libvirtError:
         except libvirt.libvirtError:

+ 13 - 13
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 pydantic import BaseModel, Field
 from typing import Optional
 from typing import Optional
 from lxml import etree
 from lxml import etree
 
 
-from app.libvirt_conn import libvirt_conn
+from app.libvirt_conn import conn_pool
 import libvirt
 import libvirt
 
 
 router = APIRouter()
 router = APIRouter()
@@ -16,9 +16,9 @@ class SnapshotCreate(BaseModel):
 
 
 
 
 @router.get("/list/{vm_name}")
 @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:
     try:
         dom = conn.lookupByName(vm_name)
         dom = conn.lookupByName(vm_name)
     except libvirt.libvirtError:
     except libvirt.libvirtError:
@@ -40,15 +40,15 @@ async def list_snapshots(vm_name: str):
                 "is_current": snap.isCurrent() == 1,
                 "is_current": snap.isCurrent() == 1,
             })
             })
     except libvirt.libvirtError:
     except libvirt.libvirtError:
-        pass  # 没有快照
+        pass
 
 
     return {"vm": vm_name, "snapshots": snapshots, "total": len(snapshots)}
     return {"vm": vm_name, "snapshots": snapshots, "total": len(snapshots)}
 
 
 
 
 @router.post("/create/{vm_name}")
 @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:
         try:
             dom = rw_conn.lookupByName(vm_name)
             dom = rw_conn.lookupByName(vm_name)
         except libvirt.libvirtError:
         except libvirt.libvirtError:
@@ -68,9 +68,9 @@ async def create_snapshot(vm_name: str, snap: SnapshotCreate):
 
 
 
 
 @router.post("/revert/{vm_name}/{snap_name}")
 @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:
         try:
             dom = rw_conn.lookupByName(vm_name)
             dom = rw_conn.lookupByName(vm_name)
             snap = dom.snapshotLookupByName(snap_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}")
 @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:
         try:
             dom = rw_conn.lookupByName(vm_name)
             dom = rw_conn.lookupByName(vm_name)
             snap = dom.snapshotLookupByName(snap_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}")
 @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:
     try:
         dom = conn.lookupByName(vm_name)
         dom = conn.lookupByName(vm_name)
         snap = dom.snapshotLookupByName(snap_name)
         snap = dom.snapshotLookupByName(snap_name)

+ 15 - 16
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 pydantic import BaseModel, Field
 from typing import Optional
 from typing import Optional
 from lxml import etree
 from lxml import etree
 import os
 import os
 
 
-from app.libvirt_conn import libvirt_conn
+from app.libvirt_conn import conn_pool
 import libvirt
 import libvirt
 
 
 router = APIRouter()
 router = APIRouter()
@@ -24,9 +24,9 @@ class VolCreate(BaseModel):
 
 
 
 
 @router.get("/pools")
 @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)
     pools = conn.listAllStoragePools(0)
     result = []
     result = []
     for pool in pools:
     for pool in pools:
@@ -47,9 +47,9 @@ async def list_pools():
 
 
 
 
 @router.get("/pool/{name}")
 @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:
     try:
         pool = conn.storagePoolLookupByName(name)
         pool = conn.storagePoolLookupByName(name)
     except libvirt.libvirtError:
     except libvirt.libvirtError:
@@ -58,7 +58,6 @@ async def get_pool(name: str):
     info = pool.info()
     info = pool.info()
     xml = etree.fromstring(pool.XMLDesc(0).encode())
     xml = etree.fromstring(pool.XMLDesc(0).encode())
 
 
-    # 获取卷列表
     volumes = []
     volumes = []
     try:
     try:
         for vol_name in pool.listVolumes():
         for vol_name in pool.listVolumes():
@@ -89,9 +88,9 @@ async def get_pool(name: str):
 
 
 
 
 @router.post("/pool/create")
 @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}'>
         xml = f"""<pool type='{pool.type}'>
   <name>{pool.name}</name>
   <name>{pool.name}</name>
   <target>
   <target>
@@ -109,9 +108,9 @@ async def create_pool(pool: PoolCreate):
 
 
 
 
 @router.delete("/pool/{name}")
 @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:
         try:
             pool = rw_conn.storagePoolLookupByName(name)
             pool = rw_conn.storagePoolLookupByName(name)
         except libvirt.libvirtError:
         except libvirt.libvirtError:
@@ -125,9 +124,9 @@ async def delete_pool(name: str):
 
 
 
 
 @router.post("/pool/{name}/volume")
 @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:
         try:
             pool = rw_conn.storagePoolLookupByName(name)
             pool = rw_conn.storagePoolLookupByName(name)
         except libvirt.libvirtError:
         except libvirt.libvirtError:
@@ -149,9 +148,9 @@ async def create_volume(name: str, vol: VolCreate):
 
 
 
 
 @router.delete("/pool/{pool_name}/volume/{vol_name}")
 @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:
         try:
             pool = rw_conn.storagePoolLookupByName(pool_name)
             pool = rw_conn.storagePoolLookupByName(pool_name)
             vol = pool.storageVolLookupByName(vol_name)
             vol = pool.storageVolLookupByName(vol_name)
@@ -162,7 +161,7 @@ async def delete_volume(pool_name: str, vol_name: str):
 
 
 
 
 @router.get("/isos")
 @router.get("/isos")
-async def list_isos():
+async def list_isos(host_id: str = Query("local")):
     """列出可用的ISO镜像"""
     """列出可用的ISO镜像"""
     iso_dirs = ["/var/lib/libvirt/iso", "/isos", "/mnt/isos"]
     iso_dirs = ["/var/lib/libvirt/iso", "/isos", "/mnt/isos"]
     isos = []
     isos = []

+ 23 - 35
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 pydantic import BaseModel, Field
 from typing import Optional, List
 from typing import Optional, List
 from lxml import etree
 from lxml import etree
 import os
 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
 from app.utils import parse_vm_info, generate_vm_xml
 import libvirt
 import libvirt
 
 
@@ -36,9 +36,9 @@ class VMClone(BaseModel):
 # ===== API =====
 # ===== API =====
 
 
 @router.get("/list")
 @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)
     domains = conn.listAllDomains(0)
     vms = []
     vms = []
     for dom in domains:
     for dom in domains:
@@ -56,9 +56,9 @@ async def list_vms():
 
 
 
 
 @router.get("/detail/{name}")
 @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:
     try:
         dom = conn.lookupByName(name)
         dom = conn.lookupByName(name)
     except libvirt.libvirtError:
     except libvirt.libvirtError:
@@ -69,20 +69,17 @@ async def get_vm_detail(name: str):
     # 运行中的虚拟机获取更多动态信息
     # 运行中的虚拟机获取更多动态信息
     if info["state"] == "running":
     if info["state"] == "running":
         try:
         try:
-            # CPU 时间
             _, _, cpu_time, _ = dom.info()
             _, _, cpu_time, _ = dom.info()
             info["cpu_time_ns"] = cpu_time
             info["cpu_time_ns"] = cpu_time
         except Exception:
         except Exception:
             pass
             pass
 
 
-        # 内存使用
         try:
         try:
             mem_stats = dom.memoryStats()
             mem_stats = dom.memoryStats()
             info["memory_stats"] = mem_stats
             info["memory_stats"] = mem_stats
         except Exception:
         except Exception:
             pass
             pass
 
 
-        # 块设备统计
         try:
         try:
             block_stats = []
             block_stats = []
             for disk in info.get("disks", []):
             for disk in info.get("disks", []):
@@ -99,7 +96,6 @@ async def get_vm_detail(name: str):
         except Exception:
         except Exception:
             pass
             pass
 
 
-        # 网络统计
         try:
         try:
             net_stats = []
             net_stats = []
             for i, iface in enumerate(info.get("interfaces", [])):
             for i, iface in enumerate(info.get("interfaces", [])):
@@ -120,18 +116,18 @@ async def get_vm_detail(name: str):
 
 
 
 
 @router.post("/create")
 @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:
     try:
         conn.lookupByName(vm.name)
         conn.lookupByName(vm.name)
         raise HTTPException(status_code=400, detail=f"虚拟机 '{vm.name}' 已存在")
         raise HTTPException(status_code=400, detail=f"虚拟机 '{vm.name}' 已存在")
     except libvirt.libvirtError:
     except libvirt.libvirtError:
-        pass  # 不存在,继续创建
+        pass
 
 
-    with libvirt_conn.get_rw() as rw_conn:
+    with conn_pool.get_rw(host_id) as rw_conn:
         try:
         try:
             # 确定磁盘路径
             # 确定磁盘路径
             pool = rw_conn.storagePoolLookupByName(vm.pool_name)
             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")
             disk_path = os.path.join(pool_path, f"{vm.name}.qcow2")
 
 
             # 创建 qcow2 磁盘
             # 创建 qcow2 磁盘
-            # 创建卷的 XML
             vol_xml = f"""<volume>
             vol_xml = f"""<volume>
   <name>{vm.name}.qcow2</name>
   <name>{vm.name}.qcow2</name>
   <capacity unit='GiB'>{vm.disk_gb}</capacity>
   <capacity unit='GiB'>{vm.disk_gb}</capacity>
@@ -179,15 +174,15 @@ async def create_vm(vm: VMCreate):
 
 
 
 
 @router.post("/action/{name}")
 @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:
     try:
         dom = conn.lookupByName(name)
         dom = conn.lookupByName(name)
     except libvirt.libvirtError:
     except libvirt.libvirtError:
         raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
         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:
         try:
             rw_dom = rw_conn.lookupByName(name)
             rw_dom = rw_conn.lookupByName(name)
         except libvirt.libvirtError:
         except libvirt.libvirtError:
@@ -221,9 +216,9 @@ async def vm_action(name: str, action: VMAction):
 
 
 
 
 @router.delete("/delete/{name}")
 @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:
         try:
             dom = rw_conn.lookupByName(name)
             dom = rw_conn.lookupByName(name)
         except libvirt.libvirtError:
         except libvirt.libvirtError:
@@ -262,9 +257,9 @@ async def delete_vm(name: str, force: bool = False):
 
 
 
 
 @router.post("/clone/{name}")
 @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:
         try:
             dom = rw_conn.lookupByName(name)
             dom = rw_conn.lookupByName(name)
         except libvirt.libvirtError:
         except libvirt.libvirtError:
@@ -278,19 +273,15 @@ async def clone_vm(name: str, clone: VMClone):
             pass
             pass
 
 
         try:
         try:
-            # 获取源虚拟机 XML
             xml_desc = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE)
             xml_desc = dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE)
             tree = etree.fromstring(xml_desc.encode())
             tree = etree.fromstring(xml_desc.encode())
 
 
-            # 修改名称
             tree.find("name").text = clone.new_name
             tree.find("name").text = clone.new_name
 
 
-            # 修改 UUID(删除让libvirt自动生成)
             uuid_elem = tree.find("uuid")
             uuid_elem = tree.find("uuid")
             if uuid_elem is not None:
             if uuid_elem is not None:
                 tree.remove(uuid_elem)
                 tree.remove(uuid_elem)
 
 
-            # 修改磁盘路径
             import uuid as uuid_mod
             import uuid as uuid_mod
             new_uuid = str(uuid_mod.uuid4())[:8]
             new_uuid = str(uuid_mod.uuid4())[:8]
             for disk in tree.findall(".//disk[@device='disk']"):
             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")
                     new_path = old_path.replace(f"{name}.qcow2", f"{clone.new_name}.qcow2")
                     source.set("file", new_path)
                     source.set("file", new_path)
 
 
-            # 修改 MAC 地址
             for mac in tree.findall(".//interface/mac"):
             for mac in tree.findall(".//interface/mac"):
                 import random
                 import random
                 mac_addr = "52:54:00:%02x:%02x:%02x" % (
                 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)
                 mac.set("address", mac_addr)
 
 
-            # 复制磁盘
             old_disk_path = ""
             old_disk_path = ""
             new_disk_path = ""
             new_disk_path = ""
             for disk in tree.findall(".//disk[@device='disk']/source"):
             for disk in tree.findall(".//disk[@device='disk']/source"):
@@ -325,7 +314,6 @@ async def clone_vm(name: str, clone: VMClone):
                     capture_output=True,
                     capture_output=True,
                 )
                 )
 
 
-            # 定义新虚拟机
             new_xml = etree.tostring(tree, encoding="unicode")
             new_xml = etree.tostring(tree, encoding="unicode")
             rw_conn.defineXML(new_xml)
             rw_conn.defineXML(new_xml)
 
 
@@ -336,9 +324,9 @@ async def clone_vm(name: str, clone: VMClone):
 
 
 
 
 @router.get("/xml/{name}")
 @router.get("/xml/{name}")
-async def get_vm_xml(name: str):
+async def get_vm_xml(name: str, host_id: str = Query("local")):
     """获取虚拟机 XML 配置"""
     """获取虚拟机 XML 配置"""
-    conn = libvirt_conn.conn
+    conn = conn_pool.get_conn(host_id)
     try:
     try:
         dom = conn.lookupByName(name)
         dom = conn.lookupByName(name)
     except libvirt.libvirtError:
     except libvirt.libvirtError:
@@ -348,9 +336,9 @@ async def get_vm_xml(name: str):
 
 
 
 
 @router.put("/xml/{name}")
 @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 配置"""
     """更新虚拟机 XML 配置"""
-    with libvirt_conn.get_rw() as rw_conn:
+    with conn_pool.get_rw(host_id) as rw_conn:
         try:
         try:
             dom = rw_conn.lookupByName(name)
             dom = rw_conn.lookupByName(name)
         except libvirt.libvirtError:
         except libvirt.libvirtError:
@@ -368,9 +356,9 @@ async def update_vm_xml(name: str, xml: dict):
 
 
 
 
 @router.post("/migrate/{name}")
 @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:
         try:
             dom = rw_conn.lookupByName(name)
             dom = rw_conn.lookupByName(name)
         except libvirt.libvirtError:
         except libvirt.libvirtError:

+ 1 - 0
backend/requirements.txt

@@ -6,6 +6,7 @@ pydantic==2.5.2
 pydantic-settings==2.1.0
 pydantic-settings==2.1.0
 python-jose[cryptography]==3.3.0
 python-jose[cryptography]==3.3.0
 passlib[bcrypt]==1.7.4
 passlib[bcrypt]==1.7.4
+bcrypt==4.0.1
 aiofiles==23.2.1
 aiofiles==23.2.1
 websockify==0.10.0
 websockify==0.10.0
 lxml==4.9.3
 lxml==4.9.3

+ 4 - 2
frontend/nginx.conf

@@ -25,10 +25,12 @@ server {
     }
     }
 
 
     # WebSocket (VNC)
     # WebSocket (VNC)
-    location /websockify {
-        proxy_pass http://backend:8004/websockify;
+    location /ws/ {
+        proxy_pass http://backend:8004/ws/;
         proxy_http_version 1.1;
         proxy_http_version 1.1;
         proxy_set_header Upgrade $http_upgrade;
         proxy_set_header Upgrade $http_upgrade;
         proxy_set_header Connection "upgrade";
         proxy_set_header Connection "upgrade";
+        proxy_read_timeout 3600s;
+        proxy_send_timeout 3600s;
     }
     }
 }
 }

+ 7 - 0
frontend/package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.0.0",
       "version": "0.0.0",
       "dependencies": {
       "dependencies": {
         "@element-plus/icons-vue": "^2.3.2",
         "@element-plus/icons-vue": "^2.3.2",
+        "@novnc/novnc": "^1.7.0",
         "axios": "^1.15.2",
         "axios": "^1.15.2",
         "echarts": "^6.0.0",
         "echarts": "^6.0.0",
         "element-plus": "^2.13.7",
         "element-plus": "^2.13.7",
@@ -193,6 +194,12 @@
         "@emnapi/runtime": "^1.7.1"
         "@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": {
     "node_modules/@oxc-project/types": {
       "version": "0.127.0",
       "version": "0.127.0",
       "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
       "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",

+ 1 - 0
frontend/package.json

@@ -10,6 +10,7 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.2",
     "@element-plus/icons-vue": "^2.3.2",
+    "@novnc/novnc": "^1.7.0",
     "axios": "^1.15.2",
     "axios": "^1.15.2",
     "echarts": "^6.0.0",
     "echarts": "^6.0.0",
     "element-plus": "^2.13.7",
     "element-plus": "^2.13.7",

+ 91 - 0
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()

+ 143 - 0
frontend/src/App.vue

@@ -32,6 +32,9 @@ body {
   background: transparent;
   background: transparent;
 }
 }
 
 
+/* ========== Element Plus 深色主题全局覆盖 ========== */
+
+/* 表格 */
 .el-table {
 .el-table {
   --el-table-bg-color: #1a2633;
   --el-table-bg-color: #1a2633;
   --el-table-tr-bg-color: #1a2633;
   --el-table-tr-bg-color: #1a2633;
@@ -40,27 +43,167 @@ body {
   --el-table-border-color: #2a3a4e;
   --el-table-border-color: #2a3a4e;
   --el-table-text-color: #c0ccda;
   --el-table-text-color: #c0ccda;
   --el-table-header-text-color: #8aa4be;
   --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 {
   --el-card-bg-color: #1a2633;
   --el-card-bg-color: #1a2633;
   --el-card-border-color: #2a3a4e;
   --el-card-border-color: #2a3a4e;
 }
 }
 
 
+/* 对话框 */
 .el-dialog {
 .el-dialog {
   --el-dialog-bg-color: #1a2633;
   --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 {
 .el-form-item__label {
   color: #8aa4be !important;
   color: #8aa4be !important;
 }
 }
 
 
+/* 输入框 */
 .el-input__wrapper {
 .el-input__wrapper {
   background-color: #0f1923 !important;
   background-color: #0f1923 !important;
   box-shadow: 0 0 0 1px #2a3a4e inset !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 {
 .el-select .el-input__wrapper {
   background-color: #0f1923 !important;
   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>
 </style>

+ 49 - 0
frontend/src/api/index.js

@@ -5,10 +5,59 @@ const api = axios.create({
   timeout: 30000,
   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(
 api.interceptors.response.use(
   (res) => res.data,
   (res) => res.data,
   (err) => {
   (err) => {
+    if (err.response?.status === 401) {
+      removeToken()
+      window.location.href = '/login'
+    }
     console.error('API Error:', err.response?.data?.detail || err.message)
     console.error('API Error:', err.response?.data?.detail || err.message)
     return Promise.reject(err)
     return Promise.reject(err)
   }
   }

+ 21 - 4
frontend/src/layout/MainLayout.vue

@@ -19,6 +19,10 @@
           <el-icon><Monitor /></el-icon>
           <el-icon><Monitor /></el-icon>
           <template #title>仪表盘</template>
           <template #title>仪表盘</template>
         </el-menu-item>
         </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-menu-item index="/vms">
           <el-icon><Coin /></el-icon>
           <el-icon><Coin /></el-icon>
           <template #title>虚拟机</template>
           <template #title>虚拟机</template>
@@ -45,8 +49,9 @@
           <span class="page-title">{{ currentTitle }}</span>
           <span class="page-title">{{ currentTitle }}</span>
         </div>
         </div>
         <div class="topbar-right">
         <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-tag type="success" size="small" effect="dark">在线</el-tag>
+          <el-button text type="danger" @click="handleLogout" size="small">退出</el-button>
         </div>
         </div>
       </div>
       </div>
 
 
@@ -60,17 +65,24 @@
 
 
 <script setup>
 <script setup>
 import { ref, computed, onMounted } from 'vue'
 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 route = useRoute()
+const router = useRouter()
 const sidebarCollapsed = ref(false)
 const sidebarCollapsed = ref(false)
 const hostInfo = ref({})
 const hostInfo = ref({})
+const user = getUser()
 
 
 const currentRoute = computed(() => route.path)
 const currentRoute = computed(() => route.path)
 const currentTitle = computed(() => route.meta.title || '')
 const currentTitle = computed(() => route.meta.title || '')
 
 
+function handleLogout() {
+  removeToken()
+  router.push('/login')
+}
+
 onMounted(async () => {
 onMounted(async () => {
   try {
   try {
     hostInfo.value = await api.get('/host')
     hostInfo.value = await api.get('/host')
@@ -165,6 +177,11 @@ onMounted(async () => {
   gap: 12px;
   gap: 12px;
 }
 }
 
 
+.user-info {
+  color: #c0ccda;
+  font-size: 13px;
+}
+
 .host-name {
 .host-name {
   color: #7a8fa3;
   color: #7a8fa3;
   font-size: 13px;
   font-size: 13px;

+ 27 - 0
frontend/src/router/index.js

@@ -1,6 +1,13 @@
 import { createRouter, createWebHistory } from 'vue-router'
 import { createRouter, createWebHistory } from 'vue-router'
+import { isAuthenticated, removeToken } from '../api'
 
 
 const routes = [
 const routes = [
+  {
+    path: '/login',
+    name: 'Login',
+    component: () => import('../views/Login.vue'),
+    meta: { title: '登录', public: true },
+  },
   {
   {
     path: '/',
     path: '/',
     component: () => import('../layout/MainLayout.vue'),
     component: () => import('../layout/MainLayout.vue'),
@@ -12,6 +19,12 @@ const routes = [
         component: () => import('../views/Dashboard.vue'),
         component: () => import('../views/Dashboard.vue'),
         meta: { title: '仪表盘' },
         meta: { title: '仪表盘' },
       },
       },
+      {
+        path: 'hosts',
+        name: 'Hosts',
+        component: () => import('../views/Hosts.vue'),
+        meta: { title: '主机管理' },
+      },
       {
       {
         path: 'vms',
         path: 'vms',
         name: 'VMList',
         name: 'VMList',
@@ -24,6 +37,12 @@ const routes = [
         component: () => import('../views/VMDetail.vue'),
         component: () => import('../views/VMDetail.vue'),
         meta: { title: '虚拟机详情' },
         meta: { title: '虚拟机详情' },
       },
       },
+      {
+        path: 'console/:name',
+        name: 'Console',
+        component: () => import('../views/Console.vue'),
+        meta: { title: '控制台' },
+      },
       {
       {
         path: 'storage',
         path: 'storage',
         name: 'Storage',
         name: 'Storage',
@@ -47,6 +66,14 @@ const router = createRouter({
 
 
 router.beforeEach((to) => {
 router.beforeEach((to) => {
   document.title = `${to.meta.title || 'KVM'} - KVM管理平台`
   document.title = `${to.meta.title || 'KVM'} - KVM管理平台`
+
+  // 不需要认证的页面直接放行
+  if (to.meta.public) return true
+
+  // 未登录跳转到登录页
+  if (!isAuthenticated()) {
+    return { name: 'Login' }
+  }
 })
 })
 
 
 export default router
 export default router

+ 185 - 0
frontend/src/views/Console.vue

@@ -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
frontend/src/views/Dashboard.vue

@@ -53,7 +53,7 @@
           <el-table :data="vms" stripe style="width: 100%">
           <el-table :data="vms" stripe style="width: 100%">
             <el-table-column prop="name" label="名称" min-width="140">
             <el-table-column prop="name" label="名称" min-width="140">
               <template #default="{ row }">
               <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>
               </template>
             </el-table-column>
             </el-table-column>
             <el-table-column label="状态" width="100" align="center">
             <el-table-column label="状态" width="100" align="center">
@@ -133,10 +133,14 @@
 </template>
 </template>
 
 
 <script setup>
 <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 { ElMessage, ElMessageBox } from 'element-plus'
 import api from '../api'
 import api from '../api'
 
 
+const route = useRoute()
+const hostId = () => route.query.host_id || 'local'
+
 const overview = ref({})
 const overview = ref({})
 const vms = ref([])
 const vms = ref([])
 const pools = ref([])
 const pools = ref([])
@@ -174,13 +178,15 @@ function poolColor(pool) {
   return '#409eff'
   return '#409eff'
 }
 }
 
 
+let refreshTimer = null
+
 async function loadData() {
 async function loadData() {
   try {
   try {
     const [o, v, p, n] = await Promise.all([
     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
     overview.value = o
     vms.value = v.vms || []
     vms.value = v.vms || []
@@ -197,7 +203,7 @@ async function vmAction(name, action) {
     await ElMessageBox.confirm(`确定要${labels[action]}虚拟机 ${name} 吗?`, '确认操作', {
     await ElMessageBox.confirm(`确定要${labels[action]}虚拟机 ${name} 吗?`, '确认操作', {
       type: action === 'force_stop' ? 'warning' : 'info',
       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]}操作已发送`)
     ElMessage.success(`${labels[action]}操作已发送`)
     setTimeout(loadData, 2000)
     setTimeout(loadData, 2000)
   } catch (e) {
   } catch (e) {
@@ -207,8 +213,14 @@ async function vmAction(name, action) {
 
 
 onMounted(() => {
 onMounted(() => {
   loadData()
   loadData()
-  const timer = setInterval(loadData, 10000)
-  // cleanup on unmount handled by vue
+  refreshTimer = setInterval(loadData, 10000)
+})
+
+onBeforeUnmount(() => {
+  if (refreshTimer) {
+    clearInterval(refreshTimer)
+    refreshTimer = null
+  }
 })
 })
 </script>
 </script>
 
 

+ 306 - 0
frontend/src/views/Hosts.vue

@@ -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 - 0
frontend/src/views/Login.vue

@@ -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
frontend/src/views/Network.vue

@@ -92,10 +92,14 @@
 
 
 <script setup>
 <script setup>
 import { ref, onMounted } from 'vue'
 import { ref, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Plus, Refresh } from '@element-plus/icons-vue'
 import { Plus, Refresh } from '@element-plus/icons-vue'
 import api from '../api'
 import api from '../api'
 
 
+const route = useRoute()
+const hostId = () => route.query.host_id || 'local'
+
 const networks = ref([])
 const networks = ref([])
 const showDialog = ref(false)
 const showDialog = ref(false)
 const form = ref({
 const form = ref({
@@ -109,7 +113,7 @@ const form = ref({
 
 
 async function loadData() {
 async function loadData() {
   try {
   try {
-    const data = await api.get('/network/list')
+    const data = await api.get('/network/list', { params: { host_id: hostId() } })
     networks.value = data.networks || []
     networks.value = data.networks || []
   } catch (e) {}
   } catch (e) {}
 }
 }
@@ -122,7 +126,7 @@ async function createNet() {
     return
     return
   }
   }
   try {
   try {
-    await api.post('/network/create', form.value)
+    await api.post('/network/create', form.value, { params: { host_id: hostId() } })
     ElMessage.success('网络创建成功')
     ElMessage.success('网络创建成功')
     showDialog.value = false
     showDialog.value = false
     form.value = { name: '', mode: 'nat', subnet: '192.168.100.0/24', bridge: '', dhcp_start: '', dhcp_end: '' }
     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) {
 async function toggleNet(name, action) {
   try {
   try {
-    await api.post(`/network/action/${name}?action=${action}`)
+    await api.post(`/network/action/${name}?action=${action}&host_id=${hostId()}`)
     ElMessage.success('操作成功')
     ElMessage.success('操作成功')
     setTimeout(loadData, 1000)
     setTimeout(loadData, 1000)
   } catch (e) {
   } catch (e) {
@@ -145,7 +149,7 @@ async function toggleNet(name, action) {
 async function deleteNet(name) {
 async function deleteNet(name) {
   try {
   try {
     await ElMessageBox.confirm(`确定删除网络 ${name} 吗?`, '确认', { type: 'error' })
     await ElMessageBox.confirm(`确定删除网络 ${name} 吗?`, '确认', { type: 'error' })
-    await api.delete(`/network/delete/${name}`)
+    await api.delete(`/network/delete/${name}`, { params: { host_id: hostId() } })
     ElMessage.success('网络已删除')
     ElMessage.success('网络已删除')
     loadData()
     loadData()
   } catch (e) {
   } catch (e) {

+ 11 - 7
frontend/src/views/Storage.vue

@@ -116,10 +116,14 @@
 
 
 <script setup>
 <script setup>
 import { ref, onMounted } from 'vue'
 import { ref, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Plus, Refresh } from '@element-plus/icons-vue'
 import { Plus, Refresh } from '@element-plus/icons-vue'
 import api from '../api'
 import api from '../api'
 
 
+const route = useRoute()
+const hostId = () => route.query.host_id || 'local'
+
 const pools = ref([])
 const pools = ref([])
 const isos = ref([])
 const isos = ref([])
 const volumes = ref([])
 const volumes = ref([])
@@ -134,8 +138,8 @@ const volForm = ref({ name: '', capacity_gb: 20, format: 'qcow2' })
 async function loadData() {
 async function loadData() {
   try {
   try {
     const [p, i] = await Promise.all([
     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 || []
     pools.value = p.pools || []
     isos.value = i.isos || []
     isos.value = i.isos || []
@@ -145,7 +149,7 @@ async function loadData() {
 async function viewVolumes(poolName) {
 async function viewVolumes(poolName) {
   currentPool.value = poolName
   currentPool.value = poolName
   try {
   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 || []
     volumes.value = data.volumes || []
     showVolumesDialog.value = true
     showVolumesDialog.value = true
   } catch (e) {}
   } catch (e) {}
@@ -153,7 +157,7 @@ async function viewVolumes(poolName) {
 
 
 async function createPool() {
 async function createPool() {
   try {
   try {
-    await api.post('/storage/pool/create', poolForm.value)
+    await api.post('/storage/pool/create', poolForm.value, { params: { host_id: hostId() } })
     ElMessage.success('存储池创建成功')
     ElMessage.success('存储池创建成功')
     showPoolDialog.value = false
     showPoolDialog.value = false
     poolForm.value = { name: '', path: '', type: 'dir' }
     poolForm.value = { name: '', path: '', type: 'dir' }
@@ -166,7 +170,7 @@ async function createPool() {
 async function deletePool(name) {
 async function deletePool(name) {
   try {
   try {
     await ElMessageBox.confirm(`确定删除存储池 ${name} 吗?`, '确认', { type: 'error' })
     await ElMessageBox.confirm(`确定删除存储池 ${name} 吗?`, '确认', { type: 'error' })
-    await api.delete(`/storage/pool/${name}`)
+    await api.delete(`/storage/pool/${name}`, { params: { host_id: hostId() } })
     ElMessage.success('存储池已删除')
     ElMessage.success('存储池已删除')
     loadData()
     loadData()
   } catch (e) {
   } catch (e) {
@@ -176,7 +180,7 @@ async function deletePool(name) {
 
 
 async function createVol() {
 async function createVol() {
   try {
   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('卷创建成功')
     ElMessage.success('卷创建成功')
     showVolDialog.value = false
     showVolDialog.value = false
     volForm.value = { name: '', capacity_gb: 20, format: 'qcow2' }
     volForm.value = { name: '', capacity_gb: 20, format: 'qcow2' }
@@ -189,7 +193,7 @@ async function createVol() {
 async function deleteVol(volName) {
 async function deleteVol(volName) {
   try {
   try {
     await ElMessageBox.confirm(`确定删除卷 ${volName} 吗?`, '确认', { type: 'error' })
     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('卷已删除')
     ElMessage.success('卷已删除')
     viewVolumes(currentPool.value)
     viewVolumes(currentPool.value)
   } catch (e) {
   } catch (e) {

+ 181 - 9
frontend/src/views/VMDetail.vue

@@ -18,6 +18,10 @@
         <el-button v-if="vm.state === 'paused'" type="success" @click="doAction('resume')">恢复</el-button>
         <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'" 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.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>
     </div>
     </div>
 
 
@@ -130,6 +134,61 @@
       </el-table>
       </el-table>
     </div>
     </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-dialog v-model="showSnapDialog" title="创建快照" width="400px">
       <el-form :model="snapForm" label-width="80px">
       <el-form :model="snapForm" label-width="80px">
@@ -149,20 +208,33 @@
 </template>
 </template>
 
 
 <script setup>
 <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 { ElMessage, ElMessageBox } from 'element-plus'
 import { ArrowLeft } from '@element-plus/icons-vue'
 import { ArrowLeft } from '@element-plus/icons-vue'
 import api from '../api'
 import api from '../api'
 
 
 const route = useRoute()
 const route = useRoute()
+const router = useRouter()
 const vmName = route.params.name
 const vmName = route.params.name
+const hostId = () => route.query.host_id || 'local'
 const loading = ref(true)
 const loading = ref(true)
 const vm = ref({})
 const vm = ref({})
 const monitor = ref({})
 const monitor = ref({})
 const snapshots = ref([])
 const snapshots = ref([])
 const snapLoading = ref(false)
 const snapLoading = ref(false)
 const showSnapDialog = 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: '' })
 const snapForm = ref({ name: '', description: '' })
 let timer = null
 let timer = null
 
 
@@ -197,21 +269,21 @@ function formatTime(ts) {
 
 
 async function loadVM() {
 async function loadVM() {
   try {
   try {
-    vm.value = await api.get(`/vm/detail/${vmName}`)
+    vm.value = await api.get(`/vm/detail/${vmName}`, { params: { host_id: hostId() } })
   } catch (e) {}
   } catch (e) {}
   loading.value = false
   loading.value = false
 }
 }
 
 
 async function loadMonitor() {
 async function loadMonitor() {
   try {
   try {
-    monitor.value = await api.get(`/monitor/vm/${vmName}`)
+    monitor.value = await api.get(`/monitor/vm/${vmName}`, { params: { host_id: hostId() } })
   } catch (e) {}
   } catch (e) {}
 }
 }
 
 
 async function loadSnapshots() {
 async function loadSnapshots() {
   snapLoading.value = true
   snapLoading.value = true
   try {
   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 || []
     snapshots.value = data.snapshots || []
   } catch (e) {}
   } catch (e) {}
   snapLoading.value = false
   snapLoading.value = false
@@ -221,7 +293,7 @@ async function doAction(action) {
   const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复', restart: '重启' }
   const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复', restart: '重启' }
   try {
   try {
     await ElMessageBox.confirm(`确定要${labels[action]}吗?`, '确认', { type: 'info' })
     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]}操作已发送`)
     ElMessage.success(`${labels[action]}操作已发送`)
     setTimeout(() => { loadVM(); loadMonitor() }, 2000)
     setTimeout(() => { loadVM(); loadMonitor() }, 2000)
   } catch (e) {
   } catch (e) {
@@ -235,7 +307,7 @@ async function createSnap() {
     return
     return
   }
   }
   try {
   try {
-    await api.post(`/snapshot/create/${vmName}`, snapForm.value)
+    await api.post(`/snapshot/create/${vmName}`, snapForm.value, { params: { host_id: hostId() } })
     ElMessage.success('快照创建成功')
     ElMessage.success('快照创建成功')
     showSnapDialog.value = false
     showSnapDialog.value = false
     snapForm.value = { name: '', description: '' }
     snapForm.value = { name: '', description: '' }
@@ -248,7 +320,7 @@ async function createSnap() {
 async function revertSnap(name) {
 async function revertSnap(name) {
   try {
   try {
     await ElMessageBox.confirm(`确定恢复到快照 ${name} 吗?虚拟机将重启。`, '确认', { type: 'warning' })
     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('快照已恢复')
     ElMessage.success('快照已恢复')
     loadVM()
     loadVM()
     loadSnapshots()
     loadSnapshots()
@@ -260,7 +332,7 @@ async function revertSnap(name) {
 async function deleteSnap(name) {
 async function deleteSnap(name) {
   try {
   try {
     await ElMessageBox.confirm(`确定删除快照 ${name} 吗?`, '确认', { type: 'error' })
     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('快照已删除')
     ElMessage.success('快照已删除')
     loadSnapshots()
     loadSnapshots()
   } catch (e) {
   } 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(() => {
 onMounted(() => {
   loadVM()
   loadVM()
   loadMonitor()
   loadMonitor()
@@ -277,6 +428,7 @@ onMounted(() => {
 
 
 onBeforeUnmount(() => {
 onBeforeUnmount(() => {
   if (timer) clearInterval(timer)
   if (timer) clearInterval(timer)
+  if (rfb) { rfb.disconnect(); rfb = null }
 })
 })
 </script>
 </script>
 
 
@@ -388,4 +540,24 @@ onBeforeUnmount(() => {
   margin-bottom: 0;
   margin-bottom: 0;
   padding-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>
 </style>

+ 13 - 9
frontend/src/views/VMList.vue

@@ -36,7 +36,7 @@
       </el-table-column>
       </el-table-column>
       <el-table-column prop="name" label="名称" min-width="150">
       <el-table-column prop="name" label="名称" min-width="150">
         <template #default="{ row }">
         <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>
         </template>
       </el-table-column>
       </el-table-column>
       <el-table-column label="状态" width="100" align="center">
       <el-table-column label="状态" width="100" align="center">
@@ -77,7 +77,7 @@
             <el-button v-if="row.state === 'running'" type="danger" size="small"
             <el-button v-if="row.state === 'running'" type="danger" size="small"
               @click="doAction(row.name, 'force_stop')">强制关</el-button>
               @click="doAction(row.name, 'force_stop')">强制关</el-button>
             <el-button type="primary" size="small"
             <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"
             <el-button type="danger" size="small"
               @click="deleteVM(row)">删除</el-button>
               @click="deleteVM(row)">删除</el-button>
           </el-button-group>
           </el-button-group>
@@ -133,10 +133,14 @@
 
 
 <script setup>
 <script setup>
 import { ref, onMounted } from 'vue'
 import { ref, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Plus, Refresh } from '@element-plus/icons-vue'
 import { Plus, Refresh } from '@element-plus/icons-vue'
 import api from '../api'
 import api from '../api'
 
 
+const route = useRoute()
+const hostId = () => route.query.host_id || 'local'
+
 const loading = ref(false)
 const loading = ref(false)
 const creating = ref(false)
 const creating = ref(false)
 const vms = ref([])
 const vms = ref([])
@@ -173,7 +177,7 @@ function formatMem(mb) {
 async function loadData() {
 async function loadData() {
   loading.value = true
   loading.value = true
   try {
   try {
-    const data = await api.get('/vm/list')
+    const data = await api.get('/vm/list', { params: { host_id: hostId() } })
     vms.value = data.vms || []
     vms.value = data.vms || []
   } catch (e) {}
   } catch (e) {}
   loading.value = false
   loading.value = false
@@ -182,9 +186,9 @@ async function loadData() {
 async function loadOptions() {
 async function loadOptions() {
   try {
   try {
     const [pools, nets, isos] = await Promise.all([
     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)
     poolOptions.value = (pools.pools || []).map(p => p.name)
     networkOptions.value = (nets.networks || []).map(n => n.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: '恢复' }
   const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复' }
   try {
   try {
     await ElMessageBox.confirm(`确定要${labels[action]}虚拟机 ${name} 吗?`, '确认', { type: 'info' })
     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]}操作已发送`)
     ElMessage.success(`${labels[action]}操作已发送`)
     setTimeout(loadData, 2000)
     setTimeout(loadData, 2000)
   } catch (e) {
   } catch (e) {
@@ -211,7 +215,7 @@ async function createVM() {
   }
   }
   creating.value = true
   creating.value = true
   try {
   try {
-    await api.post('/vm/create', createForm.value)
+    await api.post('/vm/create', createForm.value, { params: { host_id: hostId() } })
     ElMessage.success('虚拟机创建成功')
     ElMessage.success('虚拟机创建成功')
     showCreateDialog.value = false
     showCreateDialog.value = false
     loadData()
     loadData()
@@ -228,7 +232,7 @@ async function deleteVM(row) {
       '危险操作',
       '危险操作',
       { type: 'error', confirmButtonText: '确定删除', confirmButtonClass: 'el-button--danger' }
       { 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('虚拟机已删除')
     ElMessage.success('虚拟机已删除')
     loadData()
     loadData()
   } catch (e) {
   } catch (e) {

+ 9 - 0
frontend/vite.config.js

@@ -11,6 +11,15 @@ export default defineConfig({
         target: 'http://localhost:8004',
         target: 'http://localhost:8004',
         changeOrigin: true,
         changeOrigin: true,
       },
       },
+      '/ws': {
+        target: 'http://localhost:8004',
+        ws: true,
+        changeOrigin: true,
+      },
+      '/health': {
+        target: 'http://localhost:8004',
+        changeOrigin: true,
+      },
     },
     },
   },
   },
 })
 })