Просмотр исходного кода

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 недель назад
Родитель
Сommit
8ccccf8f52

+ 2 - 0
.gitignore

@@ -6,3 +6,5 @@ node_modules/
 dist/
 .DS_Store
 *.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
 from contextlib import contextmanager
-from app.config import settings
 import logging
+from app.hosts import get_host, update_host_status, list_hosts
 
 logger = logging.getLogger(__name__)
 
 
 class LibvirtConnection:
-    """libvirt 连接管理器(只读连接保持,写操作用短连接)"""
+    """单个 libvirt 连接管理器(只读保持,写短连接)"""
 
-    def __init__(self):
+    def __init__(self, uri: str, host_id: str = "local"):
+        self._uri = uri
+        self._host_id = host_id
         self._conn = None
 
     def _connect(self):
-        """建立新的 libvirt 连接"""
+        """建立只读连接"""
         try:
-            conn = libvirt.openReadOnly(settings.LIBVIRT_URI)
+            conn = libvirt.openReadOnly(self._uri)
             if conn is None:
-                raise ConnectionError(f"无法连接到 libvirt: {settings.LIBVIRT_URI}")
+                raise ConnectionError(f"无法连接到 libvirt: {self._uri}")
             return conn
         except libvirt.libvirtError as e:
-            logger.error(f"libvirt 连接错误: {e}")
+            logger.error(f"libvirt 连接错误 ({self._host_id}): {e}")
             raise
 
     def _connect_rw(self):
         """建立可读写连接"""
         try:
-            conn = libvirt.open(settings.LIBVIRT_URI)
+            conn = libvirt.open(self._uri)
             if conn is None:
-                raise ConnectionError(f"无法连接到 libvirt (RW): {settings.LIBVIRT_URI}")
+                raise ConnectionError(f"无法连接到 libvirt (RW): {self._uri}")
             return conn
         except libvirt.libvirtError as e:
-            logger.error(f"libvirt RW 连接错误: {e}")
+            logger.error(f"libvirt RW 连接错误 ({self._host_id}): {e}")
             raise
 
     @property
@@ -73,6 +75,79 @@ class LibvirtConnection:
             "cpu_speed": c.getInfo()[3],  # MHz
         }
 
+    def close(self):
+        """关闭连接"""
+        if self._conn is not None:
+            try:
+                self._conn.close()
+            except Exception:
+                pass
+            self._conn = None
+
+
+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 主应用"""
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
 from app.config import settings
-from app.routers import vm, storage, network, snapshot, monitor
+from app.routers import vm, storage, network, snapshot, monitor, auth, host as host_router
 
 app = FastAPI(
     title=settings.APP_NAME,
@@ -20,6 +21,8 @@ app.add_middleware(
 )
 
 # 注册路由
+app.include_router(auth.router, prefix=f"{settings.API_PREFIX}/auth", tags=["认证"])
+app.include_router(host_router.router, prefix=f"{settings.API_PREFIX}/hosts", tags=["主机管理"])
 app.include_router(vm.router, prefix=f"{settings.API_PREFIX}/vm", tags=["虚拟机管理"])
 app.include_router(storage.router, prefix=f"{settings.API_PREFIX}/storage", tags=["存储管理"])
 app.include_router(network.router, prefix=f"{settings.API_PREFIX}/network", tags=["网络管理"])
@@ -34,7 +37,7 @@ async def root():
 
 @app.get(f"{settings.API_PREFIX}/host")
 async def host_info():
-    """获取宿主机信息"""
+    """获取本机宿主机信息(兼容旧接口)"""
     from app.libvirt_conn import libvirt_conn
     return libvirt_conn.get_host_info()
 
@@ -42,9 +45,137 @@ async def host_info():
 @app.get("/health")
 async def health():
     """健康检查"""
-    from app.libvirt_conn import libvirt_conn
+    from app.libvirt_conn import conn_pool
     try:
-        conn = libvirt_conn.conn
+        conn = conn_pool.get_conn("local")
         return {"status": "ok", "libvirt": conn.isAlive()}
     except Exception as e:
         return {"status": "error", "message": str(e)}
+
+
+# VNC WebSocket 代理
+from starlette.websockets import WebSocket
+from starlette.responses import HTMLResponse
+import asyncio
+import socket
+import struct
+
+
+@app.websocket("/ws/vnc/{vm_name}")
+async def vnc_websocket(websocket: WebSocket, vm_name: str, host_id: str = "local"):
+    """WebSocket 代理到虚拟机 VNC"""
+    await websocket.accept()
+
+    from app.libvirt_conn import conn_pool
+    from app.hosts import get_host as get_host_info
+    from lxml import etree
+
+    try:
+        conn = conn_pool.get_conn(host_id)
+        dom = conn.lookupByName(vm_name)
+        xml_desc = dom.XMLDesc(0)
+        tree = etree.fromstring(xml_desc.encode())
+
+        # 获取 VNC 端口
+        graphics = tree.find(".//graphics[@type='vnc']")
+        if graphics is None:
+            await websocket.close(code=1000, reason="虚拟机没有 VNC 配置")
+            return
+
+        vnc_port = int(graphics.get("port", -1))
+        vnc_listen = graphics.get("listen", "127.0.0.1")
+
+        if vnc_port <= 0:
+            await websocket.close(code=1000, reason="VNC 端口未分配,虚拟机可能未运行")
+            return
+
+        # 根据主机类型决定 VNC 连接目标
+        host_info = get_host_info(host_id)
+        target_host = vnc_listen
+        is_remote = host_info and host_info.type != "local"
+
+        if is_remote:
+            from urllib.parse import urlparse
+            parsed = urlparse(host_info.uri)
+            remote_host = parsed.hostname or "127.0.0.1"
+
+            if vnc_listen in ("127.0.0.1", "localhost", ""):
+                # VNC 只监听本地回环,需要 SSH 隧道
+                if host_info.type == "ssh":
+                    import subprocess, time
+                    local_port = 20000 + (hash(vm_name + str(vnc_port)) % 10000)
+                    # 先杀掉可能存在的旧隧道
+                    subprocess.run(
+                        ["fuser", "-k", f"{local_port}/tcp"],
+                        capture_output=True, timeout=3,
+                    )
+                    ssh_args = ["ssh", "-f", "-N"]
+                    if host_info.ssh_key_path:
+                        ssh_args.extend(["-i", host_info.ssh_key_path])
+                    ssh_args.extend([
+                        "-o", "StrictHostKeyChecking=no",
+                        "-o", "ServerAliveInterval=30",
+                        "-L", f"127.0.0.1:{local_port}:127.0.0.1:{vnc_port}",
+                        remote_host,
+                    ])
+                    result = subprocess.run(ssh_args, capture_output=True, timeout=10)
+                    if result.returncode != 0:
+                        err = result.stderr.decode(errors='replace').strip()
+                        await websocket.close(code=1011, reason=f"SSH隧道建立失败: {err}")
+                        return
+                    target_host = "127.0.0.1"
+                    vnc_port = local_port
+                else:
+                    # TCP 模式但 VNC 只听 localhost,无法直接连
+                    await websocket.close(code=1011, reason="远程主机 VNC 监听在 localhost,需要使用 SSH 模式")
+                    return
+            elif vnc_listen == "0.0.0.0":
+                # VNC 监听所有接口,直接连远程主机 IP
+                target_host = remote_host
+            else:
+                # VNC 监听特定地址
+                target_host = vnc_listen
+        else:
+            # 本地主机
+            if vnc_listen in ("0.0.0.0", ""):
+                target_host = "127.0.0.1"
+            # else 保持 vnc_listen (127.0.0.1 或其他)
+
+        # 连接到 VNC 服务器
+        reader, writer = await asyncio.open_connection(target_host, vnc_port)
+
+        async def ws_to_vnc():
+            """WebSocket -> VNC"""
+            try:
+                while True:
+                    data = await websocket.receive_bytes()
+                    writer.write(data)
+                    await writer.drain()
+            except Exception:
+                pass
+            finally:
+                writer.close()
+
+        async def vnc_to_ws():
+            """VNC -> WebSocket"""
+            try:
+                while True:
+                    data = await reader.read(4096)
+                    if not data:
+                        break
+                    await websocket.send_bytes(data)
+            except Exception:
+                pass
+            finally:
+                try:
+                    await websocket.close()
+                except Exception:
+                    pass
+
+        await asyncio.gather(ws_to_vnc(), vnc_to_ws())
+
+    except Exception as e:
+        try:
+            await websocket.close(code=1011, reason=str(e))
+        except Exception:
+            pass

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

+ 13 - 20
backend/app/routers/network.py

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

+ 13 - 13
backend/app/routers/snapshot.py

@@ -1,10 +1,10 @@
 """快照管理路由"""
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Query
 from pydantic import BaseModel, Field
 from typing import Optional
 from lxml import etree
 
-from app.libvirt_conn import libvirt_conn
+from app.libvirt_conn import conn_pool
 import libvirt
 
 router = APIRouter()
@@ -16,9 +16,9 @@ class SnapshotCreate(BaseModel):
 
 
 @router.get("/list/{vm_name}")
-async def list_snapshots(vm_name: str):
+async def list_snapshots(vm_name: str, host_id: str = Query("local")):
     """列出虚拟机的所有快照"""
-    conn = libvirt_conn.conn
+    conn = conn_pool.get_conn(host_id)
     try:
         dom = conn.lookupByName(vm_name)
     except libvirt.libvirtError:
@@ -40,15 +40,15 @@ async def list_snapshots(vm_name: str):
                 "is_current": snap.isCurrent() == 1,
             })
     except libvirt.libvirtError:
-        pass  # 没有快照
+        pass
 
     return {"vm": vm_name, "snapshots": snapshots, "total": len(snapshots)}
 
 
 @router.post("/create/{vm_name}")
-async def create_snapshot(vm_name: str, snap: SnapshotCreate):
+async def create_snapshot(vm_name: str, snap: SnapshotCreate, host_id: str = Query("local")):
     """创建快照"""
-    with libvirt_conn.get_rw() as rw_conn:
+    with conn_pool.get_rw(host_id) as rw_conn:
         try:
             dom = rw_conn.lookupByName(vm_name)
         except libvirt.libvirtError:
@@ -68,9 +68,9 @@ async def create_snapshot(vm_name: str, snap: SnapshotCreate):
 
 
 @router.post("/revert/{vm_name}/{snap_name}")
-async def revert_snapshot(vm_name: str, snap_name: str):
+async def revert_snapshot(vm_name: str, snap_name: str, host_id: str = Query("local")):
     """恢复快照"""
-    with libvirt_conn.get_rw() as rw_conn:
+    with conn_pool.get_rw(host_id) as rw_conn:
         try:
             dom = rw_conn.lookupByName(vm_name)
             snap = dom.snapshotLookupByName(snap_name)
@@ -85,9 +85,9 @@ async def revert_snapshot(vm_name: str, snap_name: str):
 
 
 @router.delete("/delete/{vm_name}/{snap_name}")
-async def delete_snapshot(vm_name: str, snap_name: str):
+async def delete_snapshot(vm_name: str, snap_name: str, host_id: str = Query("local")):
     """删除快照"""
-    with libvirt_conn.get_rw() as rw_conn:
+    with conn_pool.get_rw(host_id) as rw_conn:
         try:
             dom = rw_conn.lookupByName(vm_name)
             snap = dom.snapshotLookupByName(snap_name)
@@ -102,9 +102,9 @@ async def delete_snapshot(vm_name: str, snap_name: str):
 
 
 @router.get("/detail/{vm_name}/{snap_name}")
-async def get_snapshot_detail(vm_name: str, snap_name: str):
+async def get_snapshot_detail(vm_name: str, snap_name: str, host_id: str = Query("local")):
     """获取快照详情"""
-    conn = libvirt_conn.conn
+    conn = conn_pool.get_conn(host_id)
     try:
         dom = conn.lookupByName(vm_name)
         snap = dom.snapshotLookupByName(snap_name)

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

+ 23 - 35
backend/app/routers/vm.py

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

+ 1 - 0
backend/requirements.txt

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

+ 4 - 2
frontend/nginx.conf

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

+ 7 - 0
frontend/package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.0.0",
       "dependencies": {
         "@element-plus/icons-vue": "^2.3.2",
+        "@novnc/novnc": "^1.7.0",
         "axios": "^1.15.2",
         "echarts": "^6.0.0",
         "element-plus": "^2.13.7",
@@ -193,6 +194,12 @@
         "@emnapi/runtime": "^1.7.1"
       }
     },
+    "node_modules/@novnc/novnc": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
+      "integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
+      "license": "MPL-2.0"
+    },
     "node_modules/@oxc-project/types": {
       "version": "0.127.0",
       "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",

+ 1 - 0
frontend/package.json

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

+ 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;
 }
 
+/* ========== Element Plus 深色主题全局覆盖 ========== */
+
+/* 表格 */
 .el-table {
   --el-table-bg-color: #1a2633;
   --el-table-tr-bg-color: #1a2633;
@@ -40,27 +43,167 @@ body {
   --el-table-border-color: #2a3a4e;
   --el-table-text-color: #c0ccda;
   --el-table-header-text-color: #8aa4be;
+  --el-table-current-row-bg-color: #243447;
+  --el-table-expanded-cell-bg-color: #1a2633;
+}
+
+/* 表格斑马纹 */
+.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
+  background: #162230 !important;
 }
 
+/* 表格展开行 */
+.el-table__expanded-cell {
+  background: #1a2633 !important;
+}
+
+/* 卡片 */
 .el-card {
   --el-card-bg-color: #1a2633;
   --el-card-border-color: #2a3a4e;
 }
 
+/* 对话框 */
 .el-dialog {
   --el-dialog-bg-color: #1a2633;
+  --el-dialog-title-font-size: 16px;
+}
+.el-dialog__title {
+  color: #e0e6ed !important;
+}
+.el-dialog__headerbtn .el-dialog__close {
+  color: #7a8fa3 !important;
+}
+
+/* 确认弹窗 (MessageBox) */
+.el-message-box {
+  --el-messagebox-title-color: #e0e6ed;
+  --el-messagebox-content-color: #c0ccda;
+  background-color: #1a2633 !important;
+  border: 1px solid #2a3a4e !important;
+}
+.el-message-box__message p {
+  color: #c0ccda !important;
+}
+
+/* 遮罩层 */
+.el-overlay {
+  background-color: rgba(0, 0, 0, 0.6) !important;
 }
 
+/* 表单 */
 .el-form-item__label {
   color: #8aa4be !important;
 }
 
+/* 输入框 */
 .el-input__wrapper {
   background-color: #0f1923 !important;
   box-shadow: 0 0 0 1px #2a3a4e inset !important;
 }
+.el-input__inner {
+  color: #c0ccda !important;
+}
+.el-input__inner::placeholder {
+  color: #4a5a6a !important;
+}
 
+/* 下拉选择器 */
 .el-select .el-input__wrapper {
   background-color: #0f1923 !important;
 }
+
+/* 下拉弹出框 */
+.el-select__popper,
+.el-popper {
+  background: #1a2633 !important;
+  border: 1px solid #2a3a4e !important;
+}
+.el-select-dropdown__item {
+  color: #c0ccda !important;
+}
+.el-select-dropdown__item.hover,
+.el-select-dropdown__item:hover {
+  background-color: #243447 !important;
+}
+.el-select-dropdown__item.selected {
+  color: #409eff !important;
+}
+
+/* 空状态 */
+.el-empty {
+  --el-empty-description-color: #7a8fa3;
+}
+.el-empty__image svg {
+  fill: #2a3a4e;
+}
+
+/* 数字输入框 */
+.el-input-number__decrease,
+.el-input-number__increase {
+  background: #1e2d3d !important;
+  color: #c0ccda !important;
+  border-color: #2a3a4e !important;
+}
+
+/* 文本域 */
+.el-textarea__inner {
+  background-color: #0f1923 !important;
+  color: #c0ccda !important;
+  border-color: #2a3a4e !important;
+}
+
+/* 开关 */
+.el-switch__label {
+  color: #7a8fa3 !important;
+}
+.el-switch__label.is-active {
+  color: #409eff !important;
+}
+
+/* 提示信息 (Alert) */
+.el-alert {
+  border: none;
+}
+
+/* Loading 遮罩 */
+.el-loading-mask {
+  background-color: rgba(15, 25, 35, 0.8) !important;
+}
+
+/* 分页 */
+.el-pagination {
+  --el-pagination-bg-color: #1a2633;
+  --el-pagination-text-color: #c0ccda;
+  --el-pagination-button-disabled-color: #4a5a6a;
+}
+
+/* 标签页 */
+.el-tabs__item {
+  color: #7a8fa3 !important;
+}
+.el-tabs__item.is-active {
+  color: #409eff !important;
+}
+
+/* 气泡确认框 */
+.el-popconfirm {
+  background: #1a2633 !important;
+}
+
+/* 面包屑 */
+.el-breadcrumb__inner {
+  color: #7a8fa3 !important;
+}
+
+/* 按钮组 */
+.el-button-group .el-button--default {
+  background-color: #1e2d3d !important;
+  border-color: #2a3a4e !important;
+  color: #c0ccda !important;
+}
+.el-button-group .el-button--default:hover {
+  background-color: #243447 !important;
+  color: #e0e6ed !important;
+}
 </style>

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

@@ -5,10 +5,59 @@ const api = axios.create({
   timeout: 30000,
 })
 
+// Token 管理
+const TOKEN_KEY = 'kvm_token'
+const USER_KEY = 'kvm_user'
+
+export function getToken() {
+  return localStorage.getItem(TOKEN_KEY)
+}
+
+export function setToken(token) {
+  localStorage.setItem(TOKEN_KEY, token)
+}
+
+export function removeToken() {
+  localStorage.removeItem(TOKEN_KEY)
+  localStorage.removeItem(USER_KEY)
+}
+
+export function getUser() {
+  try {
+    return JSON.parse(localStorage.getItem(USER_KEY) || 'null')
+  } catch {
+    return null
+  }
+}
+
+export function setUser(user) {
+  localStorage.setItem(USER_KEY, JSON.stringify(user))
+}
+
+export function isAuthenticated() {
+  return !!getToken()
+}
+
+// 请求拦截 - 添加 Token
+api.interceptors.request.use(
+  (config) => {
+    const token = getToken()
+    if (token) {
+      config.headers.Authorization = `Bearer ${token}`
+    }
+    return config
+  },
+  (error) => Promise.reject(error)
+)
+
 // 响应拦截
 api.interceptors.response.use(
   (res) => res.data,
   (err) => {
+    if (err.response?.status === 401) {
+      removeToken()
+      window.location.href = '/login'
+    }
     console.error('API Error:', err.response?.data?.detail || err.message)
     return Promise.reject(err)
   }

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

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

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

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

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

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

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

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

+ 181 - 9
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 === 'running'" type="danger" @click="doAction('force_stop')">强制关机</el-button>
         <el-button v-if="vm.state === 'running'" @click="doAction('restart')">重启</el-button>
+        <el-button v-if="vm.vnc_port > 0" type="primary" @click="openConsole">控制台</el-button>
+        <el-button @click="showCloneDialog = true">克隆</el-button>
+        <el-button @click="showMigrateDialog = true">迁移</el-button>
+        <el-button @click="loadXml">XML配置</el-button>
       </div>
     </div>
 
@@ -130,6 +134,61 @@
       </el-table>
     </div>
 
+    <!-- VNC 控制台 -->
+    <div v-if="showConsole" class="info-card" style="margin-top: 16px;">
+      <div class="section-header">
+        <h3>VNC 控制台</h3>
+        <div style="display: flex; gap: 8px;">
+          <el-button size="small" @click="toggleFullscreen">全屏</el-button>
+          <el-button size="small" type="danger" @click="showConsole = false">关闭</el-button>
+        </div>
+      </div>
+      <div class="vnc-container" id="vnc-container">
+        <div id="vnc-screen"></div>
+      </div>
+    </div>
+
+    <!-- 克隆对话框 -->
+    <el-dialog v-model="showCloneDialog" title="克隆虚拟机" width="450px">
+      <el-form :model="cloneForm" label-width="100px">
+        <el-form-item label="新虚拟机名">
+          <el-input v-model="cloneForm.new_name" placeholder="输入新虚拟机名称" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showCloneDialog = false">取消</el-button>
+        <el-button type="primary" @click="cloneVM" :loading="cloning">克隆</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 迁移对话框 -->
+    <el-dialog v-model="showMigrateDialog" title="迁移虚拟机" width="500px">
+      <el-alert type="warning" :closable="false" style="margin-bottom: 16px;">
+        虚拟机将被迁移到目标宿主机,请确保目标主机已配置 libvirt 并允许远程连接。
+      </el-alert>
+      <el-form :model="migrateForm" label-width="120px">
+        <el-form-item label="目标URI">
+          <el-input v-model="migrateForm.dest_uri" placeholder="qemu+tcp://192.168.1.2/system" />
+        </el-form-item>
+        <el-form-item label="迁移模式">
+          <el-switch v-model="migrateForm.live" active-text="在线迁移" inactive-text="离线迁移" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showMigrateDialog = false">取消</el-button>
+        <el-button type="primary" @click="migrateVM" :loading="migrating">迁移</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- XML 编辑对话框 -->
+    <el-dialog v-model="showXmlDialog" title="XML 配置" width="700px">
+      <el-input v-model="xmlContent" type="textarea" :rows="20" style="font-family: monospace;" />
+      <template #footer>
+        <el-button @click="showXmlDialog = false">取消</el-button>
+        <el-button type="primary" @click="saveXml" :loading="savingXml">保存</el-button>
+      </template>
+    </el-dialog>
+
     <!-- 创建快照对话框 -->
     <el-dialog v-model="showSnapDialog" title="创建快照" width="400px">
       <el-form :model="snapForm" label-width="80px">
@@ -149,20 +208,33 @@
 </template>
 
 <script setup>
-import { ref, onMounted, onBeforeUnmount } from 'vue'
-import { useRoute } from 'vue-router'
+import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { ArrowLeft } from '@element-plus/icons-vue'
 import api from '../api'
 
 const route = useRoute()
+const router = useRouter()
 const vmName = route.params.name
+const hostId = () => route.query.host_id || 'local'
 const loading = ref(true)
 const vm = ref({})
 const monitor = ref({})
 const snapshots = ref([])
 const snapLoading = ref(false)
 const showSnapDialog = ref(false)
+const showConsole = ref(false)
+const showCloneDialog = ref(false)
+const showXmlDialog = ref(false)
+const showMigrateDialog = ref(false)
+const cloning = ref(false)
+const savingXml = ref(false)
+const migrating = ref(false)
+const xmlContent = ref('')
+const cloneForm = ref({ new_name: '' })
+const migrateForm = ref({ dest_uri: '', live: true })
+let rfb = null
 const snapForm = ref({ name: '', description: '' })
 let timer = null
 
@@ -197,21 +269,21 @@ function formatTime(ts) {
 
 async function loadVM() {
   try {
-    vm.value = await api.get(`/vm/detail/${vmName}`)
+    vm.value = await api.get(`/vm/detail/${vmName}`, { params: { host_id: hostId() } })
   } catch (e) {}
   loading.value = false
 }
 
 async function loadMonitor() {
   try {
-    monitor.value = await api.get(`/monitor/vm/${vmName}`)
+    monitor.value = await api.get(`/monitor/vm/${vmName}`, { params: { host_id: hostId() } })
   } catch (e) {}
 }
 
 async function loadSnapshots() {
   snapLoading.value = true
   try {
-    const data = await api.get(`/snapshot/list/${vmName}`)
+    const data = await api.get(`/snapshot/list/${vmName}`, { params: { host_id: hostId() } })
     snapshots.value = data.snapshots || []
   } catch (e) {}
   snapLoading.value = false
@@ -221,7 +293,7 @@ async function doAction(action) {
   const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复', restart: '重启' }
   try {
     await ElMessageBox.confirm(`确定要${labels[action]}吗?`, '确认', { type: 'info' })
-    await api.post(`/vm/action/${vmName}`, { action })
+    await api.post(`/vm/action/${vmName}`, { action }, { params: { host_id: hostId() } })
     ElMessage.success(`${labels[action]}操作已发送`)
     setTimeout(() => { loadVM(); loadMonitor() }, 2000)
   } catch (e) {
@@ -235,7 +307,7 @@ async function createSnap() {
     return
   }
   try {
-    await api.post(`/snapshot/create/${vmName}`, snapForm.value)
+    await api.post(`/snapshot/create/${vmName}`, snapForm.value, { params: { host_id: hostId() } })
     ElMessage.success('快照创建成功')
     showSnapDialog.value = false
     snapForm.value = { name: '', description: '' }
@@ -248,7 +320,7 @@ async function createSnap() {
 async function revertSnap(name) {
   try {
     await ElMessageBox.confirm(`确定恢复到快照 ${name} 吗?虚拟机将重启。`, '确认', { type: 'warning' })
-    await api.post(`/snapshot/revert/${vmName}/${name}`)
+    await api.post(`/snapshot/revert/${vmName}/${name}`, null, { params: { host_id: hostId() } })
     ElMessage.success('快照已恢复')
     loadVM()
     loadSnapshots()
@@ -260,7 +332,7 @@ async function revertSnap(name) {
 async function deleteSnap(name) {
   try {
     await ElMessageBox.confirm(`确定删除快照 ${name} 吗?`, '确认', { type: 'error' })
-    await api.delete(`/snapshot/delete/${vmName}/${name}`)
+    await api.delete(`/snapshot/delete/${vmName}/${name}`, { params: { host_id: hostId() } })
     ElMessage.success('快照已删除')
     loadSnapshots()
   } catch (e) {
@@ -268,6 +340,85 @@ async function deleteSnap(name) {
   }
 }
 
+async function cloneVM() {
+  if (!cloneForm.value.new_name) {
+    ElMessage.warning('请输入新虚拟机名称')
+    return
+  }
+  cloning.value = true
+  try {
+    await api.post(`/vm/clone/${vmName}`, cloneForm.value, { params: { host_id: hostId() } })
+    ElMessage.success('克隆成功')
+    showCloneDialog.value = false
+    cloneForm.value = { new_name: '' }
+  } catch (e) {
+    ElMessage.error(e.response?.data?.detail || '克隆失败')
+  }
+  cloning.value = false
+}
+
+async function migrateVM() {
+  if (!migrateForm.value.dest_uri) {
+    ElMessage.warning('请输入目标 URI')
+    return
+  }
+  migrating.value = true
+  try {
+    await ElMessageBox.confirm(
+      `确定将虚拟机迁移到 ${migrateForm.value.dest_uri} 吗?`,
+      '确认迁移',
+      { type: 'warning' }
+    )
+    await api.post(`/vm/migrate/${vmName}`, null, {
+      params: { dest_uri: migrateForm.value.dest_uri, live: migrateForm.value.live, host_id: hostId() }
+    })
+    ElMessage.success('迁移成功')
+    showMigrateDialog.value = false
+    migrateForm.value = { dest_uri: '', live: true }
+  } catch (e) {
+    if (e !== 'cancel') ElMessage.error(e.response?.data?.detail || '迁移失败')
+  }
+  migrating.value = false
+}
+
+async function loadXml() {
+  try {
+    const data = await api.get(`/vm/xml/${vmName}`, { params: { host_id: hostId() } })
+    xmlContent.value = data.xml || ''
+    showXmlDialog.value = true
+  } catch (e) {
+    ElMessage.error('获取 XML 失败')
+  }
+}
+
+async function saveXml() {
+  savingXml.value = true
+  try {
+    await api.put(`/vm/xml/${vmName}`, { xml: xmlContent.value }, { params: { host_id: hostId() } })
+    ElMessage.success('XML 配置已更新')
+    showXmlDialog.value = false
+    loadVM()
+  } catch (e) {
+    ElMessage.error(e.response?.data?.detail || '保存失败')
+  }
+  savingXml.value = false
+}
+
+function openConsole() {
+  router.push({ path: `/console/${vmName}`, query: { host_id: hostId() } })
+}
+
+function toggleFullscreen() {
+  const container = document.getElementById('vnc-container')
+  if (container) {
+    if (!document.fullscreenElement) {
+      container.requestFullscreen()
+    } else {
+      document.exitFullscreen()
+    }
+  }
+}
+
 onMounted(() => {
   loadVM()
   loadMonitor()
@@ -277,6 +428,7 @@ onMounted(() => {
 
 onBeforeUnmount(() => {
   if (timer) clearInterval(timer)
+  if (rfb) { rfb.disconnect(); rfb = null }
 })
 </script>
 
@@ -388,4 +540,24 @@ onBeforeUnmount(() => {
   margin-bottom: 0;
   padding-bottom: 0;
 }
+
+.vnc-container {
+  width: 100%;
+  height: 500px;
+  background: #000;
+  border-radius: 6px;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.vnc-container:fullscreen {
+  height: 100vh;
+}
+
+#vnc-screen {
+  width: 100%;
+  height: 100%;
+}
 </style>

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

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

+ 9 - 0
frontend/vite.config.js

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