feat: 优化虚拟机列表,支持多主机聚合显示

- 新增 /vm/list-all API 聚合所有主机虚拟机
- parse_vm_info 支持 include_ip 参数控制IP获取
- VMList 添加主机选择器,显示宿主机列
- 修复 API 路径 /host/list -> /hosts/list
- 新增启动脚本 scripts/start.sh
- 新增 Guest Agent 安装脚本 scripts/install-guest-agent.sh
- 更新 README 文档
This commit is contained in:
admin
2026-05-07 14:52:45 +08:00
parent 8ccccf8f52
commit dbba1694d8
8 changed files with 899 additions and 76 deletions
+126 -23
View File
@@ -1,22 +1,25 @@
# KVM 虚拟化管理平台 # KVM 虚拟化管理平台
基于 FastAPI + Vue 3 + Element Plus 的 KVM 虚拟机管理平台,通过 libvirt API 管理虚拟机。 基于 FastAPI + Vue 3 + Element Plus 的 KVM 虚拟机管理平台,通过 libvirt API 管理虚拟机,支持多主机纳管
## 技术栈 ## 技术栈
- **后端**: FastAPI + libvirt Python API - **后端**: FastAPI + libvirt Python API
- **前端**: Vue 3 + Element Plus + Vite - **前端**: Vue 3 + Element Plus + Vite + noVNC
- **虚拟化**: QEMU/KVM + libvirt - **虚拟化**: QEMU/KVM + libvirt
- **认证**: JWT Token
## 功能 ## 功能
- 🖥️ 虚拟机管理(创建/启动/停止/删除/快照 - 🖥️ 虚拟机管理(创建/启动/停止/删除/克隆/迁移
- 📊 资源监控(CPU/内存/磁盘/网络) - 📊 资源监控(CPU/内存/磁盘/网络)
- 💾 存储池管理 - 💾 存储池管理(支持多种存储类型)
- 🌐 网络管理 - 🌐 网络管理(桥接/NAT/独立网络)
- 📋 控制台访问(noVNC - 📋 控制台访问(WebSocket VNC
- 📸 快照管理 - 📸 快照管理
- 🔐 用户认证 - 🔐 用户认证JWT
- 🖧 多主机纳管(支持 SSH/TCP 连接远程 KVM
- 📡 IP 地址自动获取(需要配置 QEMU Guest Agent
## 项目结构 ## 项目结构
@@ -24,42 +27,142 @@
kvm-manager/ kvm-manager/
├── backend/ ├── backend/
│ ├── app/ │ ├── app/
│ │ ├── main.py │ │ ├── main.py # FastAPI 应用入口
│ │ ├── config.py │ │ ├── config.py # 配置
│ │ ├── database.py │ │ ├── libvirt_conn.py # libvirt 连接池
│ │ ├── models.py │ │ ├── hosts.py # 主机注册表
│ │ ├── auth.py │ │ ├── utils.py # 工具函数
│ │ └── routers/ │ │ └── routers/ # API 路由
│ │ ├── vm.py │ │ ├── vm.py # 虚拟机管理
│ │ ├── storage.py │ │ ├── storage.py # 存储管理
│ │ ├── network.py │ │ ├── network.py # 网络管理
│ │ ├── snapshot.py │ │ ├── snapshot.py # 快照管理
│ │ ── monitor.py │ │ ── monitor.py # 资源监控
│ │ └── host.py # 主机管理
│ ├── requirements.txt │ ├── requirements.txt
│ └── Dockerfile │ └── Dockerfile
├── frontend/ ├── frontend/
│ ├── src/ │ ├── src/
│ │ ├── views/ │ │ ├── views/ # 页面组件
│ │ ├── components/ │ │ ├── components/ # 通用组件
│ │ ├── api/ │ │ ├── api/ # API 调用
│ │ ├── router/ │ │ ├── router/ # 路由配置
│ │ └── App.vue │ │ └── App.vue
│ ├── package.json │ ├── package.json
│ └── vite.config.js │ └── vite.config.js
├── scripts/ # 工具脚本
│ ├── start.sh # 服务启动脚本
│ └── install-guest-agent.sh # Guest Agent 安装脚本
├── docker-compose.yml ├── docker-compose.yml
└── README.md └── README.md
``` ```
## 快速开始 ## 快速开始
### 方式一:使用启动脚本(推荐)
```bash
cd kvm-manager/scripts
# 启动所有服务
./start.sh
# 查看服务状态
./start.sh status
# 停止服务
./start.sh -s
```
### 方式二:手动启动
```bash ```bash
# 后端 # 后端
cd backend cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8004 uvicorn app.main:app --host 0.0.0.0 --port 8004
# 前端 # 前端(另开终端)
cd frontend cd frontend
npm install npm install
npm run dev npm run dev
``` ```
### 方式三:Docker 部署
```bash
docker-compose up -d
```
## 访问地址
- 前端界面:http://localhost:8005
- API 接口:http://localhost:8004
- API 文档:http://localhost:8004/docs
## 多主机纳管
在「主机管理」页面添加远程 KVM 主机,支持以下连接方式:
- **本地连接**`qemu:///system`
- **SSH 连接**`qemu+ssh://user@host/system`
- **TCP 连接**`qemu+tcp://host/system`
## QEMU Guest Agent
用于自动获取虚拟机 IP 地址。
### 安装脚本
```bash
# 单个虚拟机
./scripts/install-guest-agent.sh VM_NAME
# 所有虚拟机
./scripts/install-guest-agent.sh --all
```
### 手动配置
1. 关机虚拟机
2. 编辑配置:`virsh edit VM_NAME`
3.`<devices>` 中添加:
```xml
<controller type='virtio-serial' index='0'/>
<channel type='unix'>
<source mode='bind'/>
<target type='virtio' name='org.qemu.guest_agent.0'/>
</channel>
```
4. 开机后安装 Agent
```bash
# CentOS/RHEL
yum install qemu-guest-agent
systemctl enable qemu-guest-agent
# Ubuntu/Debian
apt install qemu-guest-agent
systemctl enable qemu-guest-agent
```
5. 验证:`virsh qemu-agent-command VM_NAME '{"execute":"guest-info"}'`
## 配置说明
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| `LIBVIRT_URI` | 本地 libvirt 连接 URI | `qemu:///system` |
| `API_PREFIX` | API 路径前缀 | `/api` |
| `SECRET_KEY` | JWT 密钥 | 自动生成 |
| `KVM_DATA_DIR` | 主机数据存储目录 | `/var/lib/kvm-manager` |
## 注意事项
- 远程主机 VNC 需要监听 `0.0.0.0` 才能被代理访问
- SSH 模式需要配置无密码 SSH 登录
- Guest Agent 需要虚拟机内部安装并运行才能获取 IP
+150 -5
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from lxml import etree from lxml import etree
from app.hosts import list_hosts
import os import os
from app.libvirt_conn import conn_pool from app.libvirt_conn import conn_pool
@@ -36,14 +37,14 @@ class VMClone(BaseModel):
# ===== API ===== # ===== API =====
@router.get("/list") @router.get("/list")
async def list_vms(host_id: str = Query("local")): async def list_vms(host_id: str = Query("local"), include_ip: bool = False):
"""获取所有虚拟机列表""" """获取指定主机所有虚拟机列表(轻量模式,默认不获取IP"""
conn = conn_pool.get_conn(host_id) conn = conn_pool.get_conn(host_id)
domains = conn.listAllDomains(0) domains = conn.listAllDomains(0)
vms = [] vms = []
for dom in domains: for dom in domains:
try: try:
vm_info = parse_vm_info(dom) vm_info = parse_vm_info(dom, host_id, include_ip=include_ip)
vms.append(vm_info) vms.append(vm_info)
except Exception as e: except Exception as e:
vms.append({ vms.append({
@@ -52,7 +53,37 @@ async def list_vms(host_id: str = Query("local")):
"state": "error", "state": "error",
"error": str(e), "error": str(e),
}) })
return {"vms": vms, "total": len(vms)} return {"vms": vms, "total": len(vms), "host_id": host_id}
@router.get("/list-all")
async def list_all_vms(include_ip: bool = False):
"""获取所有主机所有虚拟机列表(聚合模式)"""
hosts = list_hosts()
all_vms = []
for host in hosts:
try:
conn = conn_pool.get_conn(host.id)
domains = conn.listAllDomains(0)
for dom in domains:
try:
vm_info = parse_vm_info(dom, host.id, include_ip=include_ip)
vm_info["host_id"] = host.id
vm_info["host_name"] = host.name
all_vms.append(vm_info)
except Exception:
all_vms.append({
"name": dom.name(),
"uuid": dom.UUIDString(),
"state": "error",
"host_id": host.id,
"host_name": host.name,
})
except Exception:
pass # 跳过无法连接的主机
return {"vms": all_vms, "total": len(all_vms), "host_count": len(hosts)}
@router.get("/detail/{name}") @router.get("/detail/{name}")
@@ -64,7 +95,7 @@ async def get_vm_detail(name: str, host_id: str = Query("local")):
except libvirt.libvirtError: except libvirt.libvirtError:
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在") raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
info = parse_vm_info(dom) info = parse_vm_info(dom, host_id)
# 运行中的虚拟机获取更多动态信息 # 运行中的虚拟机获取更多动态信息
if info["state"] == "running": if info["state"] == "running":
@@ -115,6 +146,120 @@ async def get_vm_detail(name: str, host_id: str = Query("local")):
return info return info
@router.get("/ip/{name}")
async def get_vm_ip(name: str, host_id: str = Query("local")):
"""获取虚拟机 IP 地址"""
conn = conn_pool.get_conn(host_id)
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
raise HTTPException(status_code=404, detail=f"虚拟机 '{name}' 不存在")
if not dom.isActive():
return {"name": name, "ips": [], "message": "虚拟机未运行"}
xml_desc = dom.XMLDesc(0)
tree = etree.fromstring(xml_desc.encode())
interfaces = []
for iface in tree.findall(".//interface"):
source = iface.find("source")
mac_elem = iface.find("mac")
if mac_elem is None:
continue
mac = mac_elem.get("address", "")
network = source.get("network", "") if source is not None else ""
ips = []
# 方式1: QEMU Guest Agent
try:
ifaces = dom.interfaceAddresses(
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, 0
)
if ifaces:
for ifname, ifdata in ifaces.items():
if ifdata.get("hwaddr", "").lower() == mac.lower():
addrs = ifdata.get("addrs", [])
for a in addrs:
addr = a.get("addr", "")
if "." in addr:
ips.append({"ip": addr, "type": "ipv4", "source": "guest-agent"})
elif ":" in addr:
ips.append({"ip": addr, "type": "ipv6", "source": "guest-agent"})
except Exception:
pass
# 方式2: DHCP 租约
if not ips:
try:
ifaces = dom.interfaceAddresses(
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, 0
)
if ifaces:
for ifname, ifdata in ifaces.items():
hwaddr = ifdata.get("hwaddr", "").lower()
if hwaddr == mac.lower():
addrs = ifdata.get("addrs", [])
for a in addrs:
addr = a.get("addr", "")
if "." in addr:
ips.append({"ip": addr, "type": "ipv4", "source": "dhcp-lease"})
except Exception:
pass
# 方式3: ARP 表查找
if not ips:
ip_list = _arp_lookup(mac, host_id)
for ip in ip_list:
ips.append({"ip": ip, "type": "ipv4", "source": "arp"})
interfaces.append({
"mac": mac,
"network": network,
"ips": ips,
})
return {"name": name, "interfaces": interfaces}
def _arp_lookup(mac: str, host_id: str = "local") -> list:
"""通过 ARP 表查找 MAC 对应的 IP"""
import subprocess
from app.hosts import get_host as get_host_info
host_info = get_host_info(host_id)
is_remote = host_info and host_info.type != "local"
try:
if is_remote and host_info.type == "ssh":
from urllib.parse import urlparse
parsed = urlparse(host_info.uri)
remote_host = parsed.hostname
ssh_args = ["ssh", "-o", "StrictHostKeyChecking=no"]
if host_info.ssh_key_path:
ssh_args.extend(["-i", host_info.ssh_key_path])
ssh_args.extend([remote_host, "ip", "neigh", "show"])
result = subprocess.run(ssh_args, capture_output=True, text=True, timeout=5)
else:
result = subprocess.run(
["ip", "neigh", "show"],
capture_output=True, text=True, timeout=3,
)
if result.returncode == 0:
found = []
for line in result.stdout.strip().split("\n"):
if mac.lower() in line.lower():
parts = line.split()
if parts and "." in parts[0]:
found.append(parts[0])
return found
except Exception:
pass
return []
@router.post("/create") @router.post("/create")
async def create_vm(vm: VMCreate, host_id: str = Query("local")): async def create_vm(vm: VMCreate, host_id: str = Query("local")):
"""创建虚拟机""" """创建虚拟机"""
+118 -31
View File
@@ -96,8 +96,14 @@ def generate_vm_xml(
return xml_parts return xml_parts
def parse_vm_info(dom) -> dict: def parse_vm_info(dom, host_id: str = "local", include_ip: bool = False) -> dict:
"""从 libvirt domain 对象提取虚拟机信息""" """从 libvirt domain 对象提取虚拟机信息
Args:
dom: libvirt domain 对象
host_id: 主机ID
include_ip: 是否获取IP地址(开启会明显变慢,默认关闭)
"""
from app.libvirt_conn import libvirt_conn from app.libvirt_conn import libvirt_conn
# 基本信息 # 基本信息
@@ -161,43 +167,37 @@ def parse_vm_info(dom) -> dict:
for iface in tree.findall(".//interface"): for iface in tree.findall(".//interface"):
source = iface.find("source") source = iface.find("source")
model = iface.find("model") model = iface.find("model")
target = iface.find("target")
iface_info = { iface_info = {
"type": iface.get("type", ""), "type": iface.get("type", ""),
"network": source.get("network", "") if source is not None else "", "network": source.get("network", "") if source is not None else "",
"bridge": source.get("bridge", "") if source is not None else "",
"model": model.get("type", "") if model is not None else "", "model": model.get("type", "") if model is not None else "",
"dev": target.get("dev", "") if target is not None else "",
} }
# 如果运行中,获取MAC和IP mac_elem = iface.find("mac")
if info["state"] == "running": if mac_elem is not None:
mac = iface.find("mac") iface_info["mac"] = mac_elem.get("address", "")
if mac is not None:
iface_info["mac"] = mac.get("address", "")
# 尝试获取IP地址
try:
ifaces = dom.interfaceAddresses(
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, 0
)
if ifaces:
for ifname, ifdata in ifaces.items():
if mac is not None and ifdata.get("hwaddr", "") == iface_info.get("mac", ""):
addrs = ifdata.get("addrs", [])
if addrs:
iface_info["ip"] = addrs[0].get("addr", "")
except Exception:
try:
ifaces = dom.interfaceAddresses(
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, 0
)
if ifaces:
for ifname, ifdata in ifaces.items():
addrs = ifdata.get("addrs", [])
if addrs:
iface_info["ip"] = addrs[0].get("addr", "")
break
except Exception:
pass
interfaces.append(iface_info) interfaces.append(iface_info)
info["interfaces"] = interfaces info["interfaces"] = interfaces
# 获取 IP 地址(仅在需要时)
if include_ip and info["state"] == "running":
arp_cache = _get_arp_table(host_id)
for iface in interfaces:
if "mac" in iface:
# 先尝试 Guest Agent 和 DHCP 租约
iface["ip"] = _get_vm_ip(dom, iface["mac"], iface.get("network", ""), host_id, None)
# 如果没找到再用 ARP 表
if not iface.get("ip") and arp_cache:
mac_lower = iface["mac"].lower()
if mac_lower in arp_cache:
iface["ip"] = arp_cache[mac_lower]
else:
# 不获取 IP 时,初始化 ip 字段为空
for iface in interfaces:
iface["ip"] = ""
# VNC # VNC
graphics = tree.find(".//graphics[@type='vnc']") graphics = tree.find(".//graphics[@type='vnc']")
if graphics is not None: if graphics is not None:
@@ -214,6 +214,93 @@ def parse_vm_info(dom) -> dict:
return info return info
def _get_arp_table(host_id: str = "local") -> dict:
"""获取 ARP 表(MAC -> IP 映射),返回 {mac_lower: ip}"""
import subprocess
from app.hosts import get_host as get_host_info
host_info = get_host_info(host_id)
is_remote = host_info and host_info.type != "local"
try:
if is_remote and host_info.type == "ssh":
from urllib.parse import urlparse
parsed = urlparse(host_info.uri)
remote_host = parsed.hostname
ssh_args = ["ssh", "-o", "StrictHostKeyChecking=no"]
if host_info.ssh_key_path:
ssh_args.extend(["-i", host_info.ssh_key_path])
ssh_args.extend([remote_host, "ip", "neigh", "show"])
result = subprocess.run(ssh_args, capture_output=True, text=True, timeout=5)
else:
result = subprocess.run(
["ip", "neigh", "show"],
capture_output=True, text=True, timeout=3,
)
if result.returncode == 0:
arp = {}
for line in result.stdout.strip().split("\n"):
parts = line.split()
if len(parts) >= 5:
ip = parts[0]
# lladdr 行格式: IP dev IFACE lladdr MAC ...
for i, p in enumerate(parts):
if p == "lladdr" and i + 1 < len(parts) and "." in ip:
arp[parts[i + 1].lower()] = ip
return arp
except Exception:
pass
return {}
def _get_vm_ip(dom, mac: str, network: str = "", host_id: str = "local", arp_cache: dict = None) -> str:
"""多种方式尝试获取虚拟机 IP 地址"""
# 方式1: QEMU Guest Agent(最准确)
try:
ifaces = dom.interfaceAddresses(
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT, 0
)
if ifaces:
for ifname, ifdata in ifaces.items():
if ifdata.get("hwaddr", "").lower() == mac.lower():
addrs = ifdata.get("addrs", [])
for a in addrs:
addr = a.get("addr", "")
# 优先返回 IPv4
if "." in addr:
return addr
if addrs:
return addrs[0].get("addr", "")
except Exception:
pass
# 方式2: DHCP 租约(通过 libvirt 网络)
try:
ifaces = dom.interfaceAddresses(
libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE, 0
)
if ifaces:
for ifname, ifdata in ifaces.items():
hwaddr = ifdata.get("hwaddr", "").lower()
if hwaddr == mac.lower():
addrs = ifdata.get("addrs", [])
for a in addrs:
addr = a.get("addr", "")
if "." in addr:
return addr
if addrs:
return addrs[0].get("addr", "")
except Exception:
pass
# 方式3: ARP 表查找(通过 MAC 地址查 IP)
if arp_cache is not None and mac.lower() in arp_cache:
return arp_cache[mac.lower()]
return ""
def _get_state(dom) -> str: def _get_state(dom) -> str:
"""获取虚拟机运行状态""" """获取虚拟机运行状态"""
raw = dom.info() raw = dom.info()
+10
View File
@@ -67,6 +67,16 @@
<el-table-column label="内存" width="110" align="center"> <el-table-column label="内存" width="110" align="center">
<template #default="{ row }">{{ row.memory_mb }} MB</template> <template #default="{ row }">{{ row.memory_mb }} MB</template>
</el-table-column> </el-table-column>
<el-table-column label="IP 地址" min-width="130">
<template #default="{ row }">
<template v-if="row.interfaces?.length">
<span v-for="i in row.interfaces" :key="i.mac || i.dev">
<el-tag v-if="i.ip" type="success" size="small" style="margin: 2px;">{{ i.ip }}</el-tag>
</span>
</template>
<span v-else style="color: #5a6a7a;">-</span>
</template>
</el-table-column>
<el-table-column label="磁盘" min-width="180"> <el-table-column label="磁盘" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
<span v-for="d in row.disks" :key="d.dev" class="disk-info"> <span v-for="d in row.disks" :key="d.dev" class="disk-info">
+29 -1
View File
@@ -83,7 +83,10 @@
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<div class="info-card"> <div class="info-card">
<h3>网络</h3> <div class="section-header">
<h3>网络</h3>
<el-button size="small" @click="refreshIP" :loading="ipLoading">刷新 IP</el-button>
</div>
<el-table :data="vm.interfaces" size="small"> <el-table :data="vm.interfaces" size="small">
<el-table-column prop="type" label="类型" width="80" /> <el-table-column prop="type" label="类型" width="80" />
<el-table-column prop="network" label="网络/桥" min-width="120" /> <el-table-column prop="network" label="网络/桥" min-width="120" />
@@ -231,6 +234,7 @@ const showMigrateDialog = ref(false)
const cloning = ref(false) const cloning = ref(false)
const savingXml = ref(false) const savingXml = ref(false)
const migrating = ref(false) const migrating = ref(false)
const ipLoading = ref(false)
const xmlContent = ref('') const xmlContent = ref('')
const cloneForm = ref({ new_name: '' }) const cloneForm = ref({ new_name: '' })
const migrateForm = ref({ dest_uri: '', live: true }) const migrateForm = ref({ dest_uri: '', live: true })
@@ -404,6 +408,30 @@ async function saveXml() {
savingXml.value = false savingXml.value = false
} }
async function refreshIP() {
ipLoading.value = true
try {
const data = await api.get(`/vm/ip/${vmName}`, { params: { host_id: hostId() } })
// 将查到的 IP 更新到 vm.interfaces
if (data.interfaces) {
for (const ipIf of data.interfaces) {
const match = (vm.value.interfaces || []).find(i => i.mac === ipIf.mac)
if (match && ipIf.ips?.length) {
match.ip = ipIf.ips[0].ip
}
}
}
if (data.interfaces?.some(i => i.ips?.length)) {
ElMessage.success('IP 地址已刷新')
} else {
ElMessage.info('未能获取到 IP 地址,虚拟机可能未安装 Guest Agent 或未通过 DHCP 获取地址')
}
} catch (e) {
ElMessage.error('获取 IP 失败')
}
ipLoading.value = false
}
function openConsole() { function openConsole() {
router.push({ path: `/console/${vmName}`, query: { host_id: hostId() } }) router.push({ path: `/console/${vmName}`, query: { host_id: hostId() } })
} }
+77 -16
View File
@@ -2,12 +2,17 @@
<div class="vm-list"> <div class="vm-list">
<!-- 操作栏 --> <!-- 操作栏 -->
<div class="toolbar"> <div class="toolbar">
<el-select v-model="selectedHost" placeholder="选择主机" clearable @change="onHostChange" style="width: 200px; margin-right: 8px;">
<el-option v-for="h in hosts" :key="h.id" :label="h.name" :value="h.id" />
<el-option label="所有主机虚拟机" value="all" />
</el-select>
<el-button type="primary" @click="showCreateDialog = true"> <el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon> 创建虚拟机 <el-icon><Plus /></el-icon> 创建虚拟机
</el-button> </el-button>
<el-button @click="loadData"> <el-button @click="loadData">
<el-icon><Refresh /></el-icon> 刷新 <el-icon><Refresh /></el-icon> 刷新
</el-button> </el-button>
<span v-if="vmTotal" style="margin-left: 16px; color: #7a8fa3;"> {{ vmTotal }} 台虚拟机</span>
</div> </div>
<!-- 虚拟机列表 --> <!-- 虚拟机列表 -->
@@ -36,7 +41,12 @@
</el-table-column> </el-table-column>
<el-table-column prop="name" label="名称" min-width="150"> <el-table-column prop="name" label="名称" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
<el-link type="primary" @click="$router.push(`/vm/${row.name}?host_id=${hostId()}`)">{{ row.name }}</el-link> <el-link type="primary" @click="goToDetail(row)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column label="宿主机" width="140">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.host_name || row.host_id }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="100" align="center"> <el-table-column label="状态" width="100" align="center">
@@ -50,6 +60,16 @@
<el-table-column label="内存" width="110" align="center"> <el-table-column label="内存" width="110" align="center">
<template #default="{ row }">{{ formatMem(row.memory_mb) }}</template> <template #default="{ row }">{{ formatMem(row.memory_mb) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="IP 地址" min-width="140">
<template #default="{ row }">
<template v-if="row.interfaces?.length">
<span v-for="i in row.interfaces" :key="i.mac || i.dev">
<el-tag v-if="i.ip" type="success" size="small" style="margin: 2px;">{{ i.ip }}</el-tag>
</span>
</template>
<span v-else style="color: #5a6a7a;">-</span>
</template>
</el-table-column>
<el-table-column label="自动启动" width="90" align="center"> <el-table-column label="自动启动" width="90" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.autostart ? 'success' : 'info'" size="small"> <el-tag :type="row.autostart ? 'success' : 'info'" size="small">
@@ -71,13 +91,13 @@
<el-button v-if="row.state === 'running'" type="warning" size="small" <el-button v-if="row.state === 'running'" type="warning" size="small"
@click="doAction(row.name, 'stop')">关机</el-button> @click="doAction(row.name, 'stop')">关机</el-button>
<el-button v-if="row.state === 'running'" type="info" size="small" <el-button v-if="row.state === 'running'" type="info" size="small"
@click="doAction(row.name, 'pause')">暂停</el-button> @click="doAction(row.name, 'pause', row.host_id)">暂停</el-button>
<el-button v-if="row.state === 'paused'" type="success" size="small" <el-button v-if="row.state === 'paused'" type="success" size="small"
@click="doAction(row.name, 'resume')">恢复</el-button> @click="doAction(row.name, 'resume', row.host_id)">恢复</el-button>
<el-button v-if="row.state === 'running'" type="danger" size="small" <el-button v-if="row.state === 'running'" type="danger" size="small"
@click="doAction(row.name, 'force_stop')">强制关</el-button> @click="doAction(row.name, 'force_stop', row.host_id)">强制关</el-button>
<el-button type="primary" size="small" <el-button type="primary" size="small"
@click="$router.push(`/vm/${row.name}?host_id=${hostId()}`)">详情</el-button> @click="$router.push(`/vm/${row.name}?host_id=${row.host_id}`)">详情</el-button>
<el-button type="danger" size="small" <el-button type="danger" size="small"
@click="deleteVM(row)">删除</el-button> @click="deleteVM(row)">删除</el-button>
</el-button-group> </el-button-group>
@@ -88,6 +108,12 @@
<!-- 创建虚拟机对话框 --> <!-- 创建虚拟机对话框 -->
<el-dialog v-model="showCreateDialog" title="创建虚拟机" width="600px" :close-on-click-modal="false"> <el-dialog v-model="showCreateDialog" title="创建虚拟机" width="600px" :close-on-click-modal="false">
<el-form :model="createForm" label-width="100px"> <el-form :model="createForm" label-width="100px">
<el-form-item label="目标主机">
<el-select v-model="createForm.host_id" placeholder="选择创建到哪台主机">
<el-option label="本机 (local)" value="local" />
<el-option v-for="h in hosts" :key="h.id" :label="h.name" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="名称"> <el-form-item label="名称">
<el-input v-model="createForm.name" placeholder="虚拟机名称" /> <el-input v-model="createForm.name" placeholder="虚拟机名称" />
</el-form-item> </el-form-item>
@@ -133,23 +159,28 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue' import { Plus, Refresh } from '@element-plus/icons-vue'
import api from '../api' import api from '../api'
const route = useRoute() const route = useRoute()
const router = useRouter()
const hostId = () => route.query.host_id || 'local' const hostId = () => route.query.host_id || 'local'
const loading = ref(false) const loading = ref(false)
const creating = ref(false) const creating = ref(false)
const vms = ref([]) const vms = ref([])
const hosts = ref([])
const selectedHost = ref('all')
const vmTotal = ref(0)
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
const poolOptions = ref([]) const poolOptions = ref([])
const networkOptions = ref([]) const networkOptions = ref([])
const isoOptions = ref([]) const isoOptions = ref([])
const createForm = ref({ const createForm = ref({
host_id: 'local',
name: '', name: '',
vcpus: 2, vcpus: 2,
memory_mb: 2048, memory_mb: 2048,
@@ -177,18 +208,42 @@ function formatMem(mb) {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const data = await api.get('/vm/list', { params: { host_id: hostId() } }) let data
if (selectedHost.value === 'all') {
// 聚合所有主机(包含 IP
data = await api.get('/vm/list-all', { params: { include_ip: true } })
} else {
data = await api.get('/vm/list', { params: { host_id: selectedHost.value, include_ip: true } })
}
vms.value = data.vms || [] vms.value = data.vms || []
} catch (e) {} vmTotal.value = data.total || 0
} catch (e) {
console.error('加载失败:', e)
}
loading.value = false loading.value = false
} }
async function loadHosts() {
try {
const data = await api.get('/hosts/list')
hosts.value = data.hosts || []
} catch (e) {
console.error('加载主机列表失败:', e)
}
}
async function onHostChange() {
await loadData()
loadOptions()
}
async function loadOptions() { async function loadOptions() {
const hid = createForm.value.host_id || 'local'
try { try {
const [pools, nets, isos] = await Promise.all([ const [pools, nets, isos] = await Promise.all([
api.get('/storage/pools', { params: { host_id: hostId() } }), api.get('/storage/pools', { params: { host_id: hid } }),
api.get('/network/list', { params: { host_id: hostId() } }), api.get('/network/list', { params: { host_id: hid } }),
api.get('/storage/isos', { params: { host_id: hostId() } }), api.get('/storage/isos', { params: { host_id: hid } }),
]) ])
poolOptions.value = (pools.pools || []).map(p => p.name) poolOptions.value = (pools.pools || []).map(p => p.name)
networkOptions.value = (nets.networks || []).map(n => n.name) networkOptions.value = (nets.networks || []).map(n => n.name)
@@ -196,11 +251,12 @@ async function loadOptions() {
} catch (e) {} } catch (e) {}
} }
async function doAction(name, action) { async function doAction(name, action, hid = null) {
const host = hid ? hid : (selectedHost.value === 'all' ? 'local' : selectedHost.value)
const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复' } const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复' }
try { try {
await ElMessageBox.confirm(`确定要${labels[action]}虚拟机 ${name} 吗?`, '确认', { type: 'info' }) await ElMessageBox.confirm(`确定要${labels[action]}虚拟机 ${name} 吗?`, '确认', { type: 'info' })
await api.post(`/vm/action/${name}`, { action }, { params: { host_id: hostId() } }) await api.post(`/vm/action/${name}`, { action }, { params: { host_id: host } })
ElMessage.success(`${labels[action]}操作已发送`) ElMessage.success(`${labels[action]}操作已发送`)
setTimeout(loadData, 2000) setTimeout(loadData, 2000)
} catch (e) { } catch (e) {
@@ -208,6 +264,10 @@ async function doAction(name, action) {
} }
} }
function goToDetail(row) {
router.push({ path: '/vm/' + row.name, query: { host_id: row.host_id } })
}
async function createVM() { async function createVM() {
if (!createForm.value.name) { if (!createForm.value.name) {
ElMessage.warning('请输入虚拟机名称') ElMessage.warning('请输入虚拟机名称')
@@ -215,7 +275,7 @@ async function createVM() {
} }
creating.value = true creating.value = true
try { try {
await api.post('/vm/create', createForm.value, { params: { host_id: hostId() } }) await api.post('/vm/create', createForm.value, { params: { host_id: createForm.value.host_id } })
ElMessage.success('虚拟机创建成功') ElMessage.success('虚拟机创建成功')
showCreateDialog.value = false showCreateDialog.value = false
loadData() loadData()
@@ -232,7 +292,7 @@ async function deleteVM(row) {
'危险操作', '危险操作',
{ type: 'error', confirmButtonText: '确定删除', confirmButtonClass: 'el-button--danger' } { type: 'error', confirmButtonText: '确定删除', confirmButtonClass: 'el-button--danger' }
) )
await api.delete(`/vm/delete/${row.name}`, { params: { force: row.state === 'running', host_id: hostId() } }) await api.delete(`/vm/delete/${row.name}`, { params: { force: row.state === 'running', host_id: row.host_id || 'local' } })
ElMessage.success('虚拟机已删除') ElMessage.success('虚拟机已删除')
loadData() loadData()
} catch (e) { } catch (e) {
@@ -240,7 +300,8 @@ async function deleteVM(row) {
} }
} }
onMounted(() => { onMounted(async () => {
await loadHosts()
loadData() loadData()
loadOptions() loadOptions()
}) })
+167
View File
@@ -0,0 +1,167 @@
#!/bin/bash
# KVM 虚拟机 QEMU Guest Agent 一键安装脚本
# 用法: ./install-guest-agent.sh [VM_NAME]
set -e
AGENT_XML="/tmp/ga-channel.xml"
CONTROLLER_XML="/tmp/ga-controller.xml"
# 检查 virtio-serial controller 是否存在
check_controller() {
local vm="$1"
if virsh dumpxml "$vm" | grep -q "type='virtio-serial'"; then
return 0 # 存在
fi
return 1 # 不存在
}
# 添加 virtio-serial controller
add_controller() {
local vm="$1"
cat > "$CONTROLLER_XML" << 'EOF'
<controller type='virtio-serial' index='0'/>
EOF
virsh attach-device "$vm" "$CONTROLLER_XML" --config 2>/dev/null || true
rm -f "$CONTROLLER_XML"
}
# 添加 guest agent channel
add_channel() {
local vm="$1"
cat > "$AGENT_XML" << 'EOF'
<channel type='unix'>
<source mode='bind'/>
<target type='virtio' name='org.qemu.guest_agent.0'/>
</channel>
EOF
virsh attach-device "$vm" "$AGENT_XML" --live --config 2>/dev/null
rm -f "$AGENT_XML"
}
# 安装 guest agent 到虚拟机内部
install_agent_in_vm() {
local vm="$1"
echo "安装 Guest Agent 到 $vm ..."
# 获取虚拟机 IP(用于 SSH
local ip=""
if command -v virsh &>/dev/null; then
ip=$(virsh domifaddr "$vm" 2>/dev/null | grep -oP '(\d+\.){3}\d+' | head -1)
fi
if [ -z "$ip" ]; then
echo " 无法获取 $vm 的 IP 地址,跳过 Agent 安装"
echo " 请手动在虚拟机内执行以下命令安装 Agent:"
echo " CentOS/RHEL: yum install qemu-guest-agent"
echo " Ubuntu/Debian: apt install qemu-guest-agent"
return 0
fi
# 尝试 SSH 连接并安装
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 "$ip" "command -v yum &>/dev/null && yum install -y qemu-guest-agent || (command -v apt &>/dev/null && apt update && apt install -y qemu-guest-agent) || echo 'Agent 安装失败,请手动安装'" 2>/dev/null || true
}
# 单个虚拟机配置
setup_one_vm() {
local vm="$1"
echo "=========================================="
echo "配置虚拟机: $vm"
# 检查虚拟机是否存在
if ! virsh domstate "$vm" &>/dev/null; then
echo " 错误: 虚拟机 $vm 不存在"
return 1
fi
local state=$(virsh domstate "$vm")
echo " 当前状态: $state"
# 检查是否已有 guest-agent channel
if virsh dumpxml "$vm" | grep -q "org.qemu.guest_agent"; then
echo " ✓ Guest Agent channel 已配置,跳过"
if [ "$state" = "running" ]; then
echo " 检测 Guest Agent 连接状态..."
virsh qemu-agent-command "$vm" '{"execute":"guest-info"}' 2>/dev/null && echo " ✓ Guest Agent 运行正常" || echo " ✗ Guest Agent 未响应"
fi
return 0
fi
# 检查 controller
if ! check_controller "$vm"; then
echo " 添加 virtio-serial 控制器..."
add_controller "$vm"
echo " ✓ 控制器添加成功(需要重启生效)"
else
echo " ✓ virtio-serial 控制器已存在"
fi
# 添加 channel
echo " 添加 Guest Agent channel..."
if add_channel "$vm"; then
echo " ✓ Channel 配置成功"
else
echo " ✗ Channel 配置失败(虚拟机可能需要关机)"
echo " 建议: virsh shutdown $vm && virsh edit $vm"
return 1
fi
# 如果运行中,尝试安装 Agent
if [ "$state" = "running" ]; then
install_agent_in_vm "$vm"
fi
echo "=========================================="
echo ""
}
# 批量配置所有运行中的虚拟机
setup_all_vms() {
echo "===== 批量配置所有虚拟机 ====="
echo ""
local vms=$(virsh list --all --name 2>/dev/null)
if [ -z "$vms" ]; then
echo "没有找到虚拟机"
return
fi
for vm in $vms; do
# 跳过模板和特殊虚拟机
[[ "$vm" =~ ^(Template|base|.*-template)$ ]] && continue
setup_one_vm "$vm"
done
echo "===== 配置完成 ====="
echo ""
echo "提示: 如果 Guest Agent 未响应,请重启虚拟机:"
echo " virsh reboot <VM_NAME>"
}
# 主程序
main() {
echo "=========================================="
echo " KVM Guest Agent 一键安装脚本"
echo "=========================================="
echo ""
if [ -z "$1" ]; then
echo "用法:"
echo " $0 <VM_NAME> # 配置单个虚拟机"
echo " $0 --all # 配置所有虚拟机"
echo ""
echo "示例:"
echo " $0 myvm"
echo " $0 --all"
echo ""
setup_all_vms
elif [ "$1" = "--all" ]; then
setup_all_vms
else
setup_one_vm "$1"
fi
}
main "$@"
+222
View File
@@ -0,0 +1,222 @@
#!/bin/bash
# KVM Manager 启动脚本
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKEND_DIR="$SCRIPT_DIR/backend"
FRONTEND_DIR="$SCRIPT_DIR/frontend"
LOG_DIR="/tmp/kvm-manager-logs"
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
usage() {
echo "用法: $0 [选项]"
echo ""
echo "选项:"
echo " -b, --backend 只启动后端服务"
echo " -f, --frontend 只启动前端服务"
echo " -a, --all 启动所有服务 (默认)"
echo " -s, --stop 停止所有服务"
echo " -r, --restart 重启所有服务"
echo " -h, --help 显示帮助"
echo ""
echo "示例:"
echo " $0 # 启动所有服务"
echo " $0 -b # 只启动后端"
echo " $0 -s # 停止所有服务"
}
# 创建日志目录
mkdir -p "$LOG_DIR"
# 检查端口是否被占用
check_port() {
local port=$1
if lsof -i:$port &>/dev/null; then
return 1 # 端口被占用
fi
return 0 # 端口空闲
}
# 停止服务
stop_services() {
echo -e "${YELLOW}停止 KVM Manager 服务...${NC}"
# 停止后端
if [ -f "$LOG_DIR/backend.pid" ]; then
local pid=$(cat "$LOG_DIR/backend.pid")
if ps -p "$pid" &>/dev/null; then
kill "$pid" 2>/dev/null || true
echo " 后端服务已停止 (PID: $pid)"
fi
rm -f "$LOG_DIR/backend.pid"
fi
# 停止前端
if [ -f "$LOG_DIR/frontend.pid" ]; then
local pid=$(cat "$LOG_DIR/frontend.pid")
if ps -p "$pid" &>/dev/null; then
kill "$pid" 2>/dev/null || true
echo " 前端服务已停止 (PID: $pid)"
fi
rm -f "$LOG_DIR/frontend.pid"
fi
# 强制停止残留进程
pkill -f "uvicorn.*app.main:app" 2>/dev/null || true
pkill -f "vite" 2>/dev/null || true
echo -e "${GREEN}所有服务已停止${NC}"
}
# 启动后端
start_backend() {
echo -e "${YELLOW}启动后端服务...${NC}"
if ! check_port 8004; then
echo -e "${RED}错误: 端口 8004 已被占用${NC}"
return 1
fi
cd "$BACKEND_DIR"
# 检查虚拟环境
if [ ! -d "venv" ]; then
echo -e "${RED}错误: 未找到虚拟环境,请先运行: cd backend && python -m venv venv${NC}"
return 1
fi
source "$BACKEND_DIR/venv/bin/activate"
nohup uvicorn app.main:app --host 0.0.0.0 --port 8004 > "$LOG_DIR/backend.log" 2>&1 &
local pid=$!
echo $pid > "$LOG_DIR/backend.pid"
sleep 2
if ps -p "$pid" &>/dev/null; then
echo -e "${GREEN}✓ 后端服务已启动 (PID: $pid, 端口: 8004)${NC}"
echo " 日志: $LOG_DIR/backend.log"
else
echo -e "${RED}✗ 后端服务启动失败${NC}"
tail -20 "$LOG_DIR/backend.log"
return 1
fi
}
# 启动前端
start_frontend() {
echo -e "${YELLOW}启动前端服务...${NC}"
if ! check_port 8005; then
echo -e "${RED}错误: 端口 8005 已被占用${NC}"
return 1
fi
cd "$FRONTEND_DIR"
# 检查 node_modules
if [ ! -d "node_modules" ]; then
echo -e "${YELLOW}首次运行,需要安装依赖...${NC}"
npm install
fi
nohup npm run dev -- --host 0.0.0.0 --port 8005 > "$LOG_DIR/frontend.log" 2>&1 &
local pid=$!
echo $pid > "$LOG_DIR/frontend.pid"
sleep 5
if ps -p "$pid" &>/dev/null; then
echo -e "${GREEN}✓ 前端服务已启动 (PID: $pid, 端口: 8005)${NC}"
echo " 日志: $LOG_DIR/frontend.log"
else
echo -e "${RED}✗ 前端服务启动失败${NC}"
tail -20 "$LOG_DIR/frontend.log"
return 1
fi
}
# 查看状态
show_status() {
echo ""
echo "========== KVM Manager 服务状态 =========="
echo ""
# 后端状态
if [ -f "$LOG_DIR/backend.pid" ]; then
local pid=$(cat "$LOG_DIR/backend.pid")
if ps -p "$pid" &>/dev/null; then
echo -e "${GREEN}✓ 后端服务${NC} - 运行中 (PID: $pid, 端口: 8004)"
else
echo -e "${RED}✗ 后端服务${NC} - 未运行 (PID 文件过期)"
fi
else
echo -e "${YELLOW}○ 后端服务${NC} - 未启动"
fi
# 前端状态
if [ -f "$LOG_DIR/frontend.pid" ]; then
local pid=$(cat "$LOG_DIR/frontend.pid")
if ps -p "$pid" &>/dev/null; then
echo -e "${GREEN}✓ 前端服务${NC} - 运行中 (PID: $pid, 端口: 8005)"
else
echo -e "${RED}✗ 前端服务${NC} - 未运行 (PID 文件过期)"
fi
else
echo -e "${YELLOW}○ 前端服务${NC} - 未启动"
fi
echo ""
echo "=========================================="
echo ""
echo "访问地址:"
echo " 前端: http://localhost:8005"
echo " API: http://localhost:8004"
echo " 文档: http://localhost:8004/docs"
echo ""
}
# 主程序
main() {
case "${1:-all}" in
-b|--backend)
start_backend
;;
-f|--frontend)
start_frontend
;;
-a|--all)
start_backend
start_frontend
show_status
;;
-s|--stop)
stop_services
;;
-r|--restart)
stop_services
sleep 1
start_backend
start_frontend
show_status
;;
-h|--help)
usage
;;
status)
show_status
;;
*)
usage
exit 1
;;
esac
}
main "$@"