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

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

Bug修复:
- libvirt getInfo()内存单位修正(MiB非KiB)
- 远程主机VNC 0.0.0.0监听地址连接策略修复
- Dashboard定时器内存泄漏修复
- bcrypt版本兼容性修复
This commit is contained in:
admin
2026-05-07 12:41:10 +08:00
parent fac8ab7470
commit 8ccccf8f52
30 changed files with 1972 additions and 170 deletions
+4 -2
View File
@@ -25,10 +25,12 @@ server {
}
# WebSocket (VNC)
location /websockify {
proxy_pass http://backend:8004/websockify;
location /ws/ {
proxy_pass http://backend:8004/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+7
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@novnc/novnc": "^1.7.0",
"axios": "^1.15.2",
"echarts": "^6.0.0",
"element-plus": "^2.13.7",
@@ -193,6 +194,12 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@novnc/novnc": "^1.7.0",
"axios": "^1.15.2",
"echarts": "^6.0.0",
"element-plus": "^2.13.7",
+91
View File
@@ -0,0 +1,91 @@
"""Simple static file server for the KVM frontend with API proxy"""
import http.server
import urllib.request
import urllib.error
import os
import json
PORT = 8006
DIST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dist")
API_BASE = "http://127.0.0.1:8004"
class KVMHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=DIST_DIR, **kwargs)
def do_GET(self):
if self.path.startswith("/api/"):
self._proxy("GET")
elif self.path == "/" or self.path == "":
self.path = "/index.html"
super().do_GET()
else:
# SPA fallback: if file doesn't exist, serve index.html
file_path = os.path.join(DIST_DIR, self.path.lstrip("/"))
if os.path.isfile(file_path):
super().do_GET()
else:
self.path = "/index.html"
super().do_GET()
def do_POST(self):
if self.path.startswith("/api/"):
self._proxy("POST")
else:
self.send_error(404)
def do_PUT(self):
if self.path.startswith("/api/"):
self._proxy("PUT")
else:
self.send_error(404)
def do_DELETE(self):
if self.path.startswith("/api/"):
self._proxy("DELETE")
else:
self.send_error(404)
def _proxy(self, method):
"""Proxy API requests to backend"""
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else None
url = f"{API_BASE}{self.path}"
req = urllib.request.Request(url, data=body, method=method)
# Forward headers
for key in ["Content-Type", "Authorization"]:
if key in self.headers:
req.add_header(key, self.headers[key])
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_body = resp.read()
self.send_response(resp.status)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(resp_body)
except urllib.error.HTTPError as e:
resp_body = e.read()
self.send_response(e.code)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(resp_body)
except Exception as e:
self.send_response(502)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"error": str(e)}).encode())
def log_message(self, format, *args):
pass # Suppress logs
if __name__ == "__main__":
server = http.server.HTTPServer(("0.0.0.0", PORT), KVMHandler)
print(f"KVM Frontend serving on http://0.0.0.0:{PORT}")
server.serve_forever()
+143
View File
@@ -32,6 +32,9 @@ body {
background: transparent;
}
/* ========== Element Plus 深色主题全局覆盖 ========== */
/* 表格 */
.el-table {
--el-table-bg-color: #1a2633;
--el-table-tr-bg-color: #1a2633;
@@ -40,27 +43,167 @@ body {
--el-table-border-color: #2a3a4e;
--el-table-text-color: #c0ccda;
--el-table-header-text-color: #8aa4be;
--el-table-current-row-bg-color: #243447;
--el-table-expanded-cell-bg-color: #1a2633;
}
/* 表格斑马纹 */
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background: #162230 !important;
}
/* 表格展开行 */
.el-table__expanded-cell {
background: #1a2633 !important;
}
/* 卡片 */
.el-card {
--el-card-bg-color: #1a2633;
--el-card-border-color: #2a3a4e;
}
/* 对话框 */
.el-dialog {
--el-dialog-bg-color: #1a2633;
--el-dialog-title-font-size: 16px;
}
.el-dialog__title {
color: #e0e6ed !important;
}
.el-dialog__headerbtn .el-dialog__close {
color: #7a8fa3 !important;
}
/* 确认弹窗 (MessageBox) */
.el-message-box {
--el-messagebox-title-color: #e0e6ed;
--el-messagebox-content-color: #c0ccda;
background-color: #1a2633 !important;
border: 1px solid #2a3a4e !important;
}
.el-message-box__message p {
color: #c0ccda !important;
}
/* 遮罩层 */
.el-overlay {
background-color: rgba(0, 0, 0, 0.6) !important;
}
/* 表单 */
.el-form-item__label {
color: #8aa4be !important;
}
/* 输入框 */
.el-input__wrapper {
background-color: #0f1923 !important;
box-shadow: 0 0 0 1px #2a3a4e inset !important;
}
.el-input__inner {
color: #c0ccda !important;
}
.el-input__inner::placeholder {
color: #4a5a6a !important;
}
/* 下拉选择器 */
.el-select .el-input__wrapper {
background-color: #0f1923 !important;
}
/* 下拉弹出框 */
.el-select__popper,
.el-popper {
background: #1a2633 !important;
border: 1px solid #2a3a4e !important;
}
.el-select-dropdown__item {
color: #c0ccda !important;
}
.el-select-dropdown__item.hover,
.el-select-dropdown__item:hover {
background-color: #243447 !important;
}
.el-select-dropdown__item.selected {
color: #409eff !important;
}
/* 空状态 */
.el-empty {
--el-empty-description-color: #7a8fa3;
}
.el-empty__image svg {
fill: #2a3a4e;
}
/* 数字输入框 */
.el-input-number__decrease,
.el-input-number__increase {
background: #1e2d3d !important;
color: #c0ccda !important;
border-color: #2a3a4e !important;
}
/* 文本域 */
.el-textarea__inner {
background-color: #0f1923 !important;
color: #c0ccda !important;
border-color: #2a3a4e !important;
}
/* 开关 */
.el-switch__label {
color: #7a8fa3 !important;
}
.el-switch__label.is-active {
color: #409eff !important;
}
/* 提示信息 (Alert) */
.el-alert {
border: none;
}
/* Loading 遮罩 */
.el-loading-mask {
background-color: rgba(15, 25, 35, 0.8) !important;
}
/* 分页 */
.el-pagination {
--el-pagination-bg-color: #1a2633;
--el-pagination-text-color: #c0ccda;
--el-pagination-button-disabled-color: #4a5a6a;
}
/* 标签页 */
.el-tabs__item {
color: #7a8fa3 !important;
}
.el-tabs__item.is-active {
color: #409eff !important;
}
/* 气泡确认框 */
.el-popconfirm {
background: #1a2633 !important;
}
/* 面包屑 */
.el-breadcrumb__inner {
color: #7a8fa3 !important;
}
/* 按钮组 */
.el-button-group .el-button--default {
background-color: #1e2d3d !important;
border-color: #2a3a4e !important;
color: #c0ccda !important;
}
.el-button-group .el-button--default:hover {
background-color: #243447 !important;
color: #e0e6ed !important;
}
</style>
+49
View File
@@ -5,10 +5,59 @@ const api = axios.create({
timeout: 30000,
})
// Token 管理
const TOKEN_KEY = 'kvm_token'
const USER_KEY = 'kvm_user'
export function getToken() {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token)
}
export function removeToken() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
export function getUser() {
try {
return JSON.parse(localStorage.getItem(USER_KEY) || 'null')
} catch {
return null
}
}
export function setUser(user) {
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
export function isAuthenticated() {
return !!getToken()
}
// 请求拦截 - 添加 Token
api.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截
api.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
removeToken()
window.location.href = '/login'
}
console.error('API Error:', err.response?.data?.detail || err.message)
return Promise.reject(err)
}
+21 -4
View File
@@ -19,6 +19,10 @@
<el-icon><Monitor /></el-icon>
<template #title>仪表盘</template>
</el-menu-item>
<el-menu-item index="/hosts">
<el-icon><OfficeBuilding /></el-icon>
<template #title>主机管理</template>
</el-menu-item>
<el-menu-item index="/vms">
<el-icon><Coin /></el-icon>
<template #title>虚拟机</template>
@@ -45,8 +49,9 @@
<span class="page-title">{{ currentTitle }}</span>
</div>
<div class="topbar-right">
<span class="host-name">{{ hostInfo.hostname || '...' }}</span>
<span class="user-info" v-if="user">{{ user.username }}</span>
<el-tag type="success" size="small" effect="dark">在线</el-tag>
<el-button text type="danger" @click="handleLogout" size="small">退出</el-button>
</div>
</div>
@@ -60,17 +65,24 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Monitor, Coin, Files, Connection, Fold, Expand } from '@element-plus/icons-vue'
import api from '../api'
import { useRouter, useRoute } from 'vue-router'
import { Monitor, Coin, Files, Connection, Fold, Expand, OfficeBuilding } from '@element-plus/icons-vue'
import api, { getUser, removeToken } from '../api'
const route = useRoute()
const router = useRouter()
const sidebarCollapsed = ref(false)
const hostInfo = ref({})
const user = getUser()
const currentRoute = computed(() => route.path)
const currentTitle = computed(() => route.meta.title || '')
function handleLogout() {
removeToken()
router.push('/login')
}
onMounted(async () => {
try {
hostInfo.value = await api.get('/host')
@@ -165,6 +177,11 @@ onMounted(async () => {
gap: 12px;
}
.user-info {
color: #c0ccda;
font-size: 13px;
}
.host-name {
color: #7a8fa3;
font-size: 13px;
+27
View File
@@ -1,6 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import { isAuthenticated, removeToken } from '../api'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { title: '登录', public: true },
},
{
path: '/',
component: () => import('../layout/MainLayout.vue'),
@@ -12,6 +19,12 @@ const routes = [
component: () => import('../views/Dashboard.vue'),
meta: { title: '仪表盘' },
},
{
path: 'hosts',
name: 'Hosts',
component: () => import('../views/Hosts.vue'),
meta: { title: '主机管理' },
},
{
path: 'vms',
name: 'VMList',
@@ -24,6 +37,12 @@ const routes = [
component: () => import('../views/VMDetail.vue'),
meta: { title: '虚拟机详情' },
},
{
path: 'console/:name',
name: 'Console',
component: () => import('../views/Console.vue'),
meta: { title: '控制台' },
},
{
path: 'storage',
name: 'Storage',
@@ -47,6 +66,14 @@ const router = createRouter({
router.beforeEach((to) => {
document.title = `${to.meta.title || 'KVM'} - KVM管理平台`
// 不需要认证的页面直接放行
if (to.meta.public) return true
// 未登录跳转到登录页
if (!isAuthenticated()) {
return { name: 'Login' }
}
})
export default router
+185
View File
@@ -0,0 +1,185 @@
<template>
<div class="vnc-page">
<div class="vnc-toolbar">
<el-button text @click="$router.back()">
<el-icon><ArrowLeft /></el-icon> 返回
</el-button>
<span class="vnc-title">控制台 - {{ vmName }}</span>
<div class="vnc-toolbar-right">
<el-tag v-if="connected" type="success" size="small">已连接</el-tag>
<el-tag v-else type="danger" size="small">未连接</el-tag>
<el-button size="small" @click="sendCtrlAltDel">Ctrl+Alt+Del</el-button>
<el-button size="small" @click="toggleFullscreen">全屏</el-button>
</div>
</div>
<div class="vnc-container" ref="containerRef" id="vnc-container">
<div ref="screenRef" class="vnc-screen"></div>
<div v-if="!connected && !error" class="vnc-connecting">
<p>正在连接到虚拟机控制台...</p>
</div>
<div v-if="error" class="vnc-error">
<p>{{ error }}</p>
<el-button type="primary" @click="connect">重新连接</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import RFB from '@novnc/novnc'
const route = useRoute()
const vmName = route.params.name
const hostId = route.query.host_id || 'local'
const containerRef = ref(null)
const screenRef = ref(null)
const connected = ref(false)
const error = ref('')
let rfb = null
function connect() {
error.value = ''
connected.value = false
if (rfb) {
rfb.disconnect()
rfb = null
}
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const url = `${protocol}//${host}/ws/vnc/${vmName}?host_id=${hostId}`
rfb = new RFB(screenRef.value, url)
rfb.scaleViewport = true
rfb.resizeSession = true
rfb.background = '#000'
rfb.addEventListener('connect', () => {
connected.value = true
error.value = ''
})
rfb.addEventListener('disconnect', (e) => {
connected.value = false
if (!e.detail.clean) {
error.value = '连接已断开,虚拟机可能未运行或 VNC 未配置'
}
})
rfb.addEventListener('credentialsrequired', () => {
rfb.sendCredentials({ password: '' })
})
} catch (e) {
error.value = `连接失败: ${e.message}`
}
}
function sendCtrlAltDel() {
if (rfb) {
rfb.sendCtrlAltDel()
}
}
function toggleFullscreen() {
const el = containerRef.value
if (!el) return
if (!document.fullscreenElement) {
el.requestFullscreen()
} else {
document.exitFullscreen()
}
}
onMounted(() => {
connect()
})
onBeforeUnmount(() => {
if (rfb) {
rfb.disconnect()
rfb = null
}
})
</script>
<style scoped>
.vnc-page {
display: flex;
flex-direction: column;
height: calc(100vh - 100px);
}
.vnc-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: #1a2633;
border: 1px solid #2a3a4e;
border-radius: 8px 8px 0 0;
flex-shrink: 0;
}
.vnc-title {
color: #e0e6ed;
font-size: 15px;
font-weight: 600;
}
.vnc-toolbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.vnc-container {
flex: 1;
background: #000;
border: 1px solid #2a3a4e;
border-top: none;
border-radius: 0 0 8px 8px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.vnc-container:fullscreen {
height: 100vh;
border-radius: 0;
}
.vnc-screen {
width: 100%;
height: 100%;
}
.vnc-screen :deep(canvas) {
cursor: default;
}
.vnc-connecting,
.vnc-error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #e0e6ed;
font-size: 14px;
z-index: 10;
}
.vnc-error p {
margin-bottom: 12px;
color: #f56c6c;
}
</style>
+21 -9
View File
@@ -53,7 +53,7 @@
<el-table :data="vms" stripe style="width: 100%">
<el-table-column prop="name" label="名称" min-width="140">
<template #default="{ row }">
<el-link type="primary" @click="$router.push(`/vm/${row.name}`)">{{ row.name }}</el-link>
<el-link type="primary" @click="$router.push(`/vm/${row.name}?host_id=${hostId()}`)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
@@ -133,10 +133,14 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../api'
const route = useRoute()
const hostId = () => route.query.host_id || 'local'
const overview = ref({})
const vms = ref([])
const pools = ref([])
@@ -174,13 +178,15 @@ function poolColor(pool) {
return '#409eff'
}
let refreshTimer = null
async function loadData() {
try {
const [o, v, p, n] = await Promise.all([
api.get('/monitor/overview'),
api.get('/vm/list'),
api.get('/storage/pools'),
api.get('/network/list'),
api.get('/monitor/overview', { params: { host_id: hostId() } }),
api.get('/vm/list', { params: { host_id: hostId() } }),
api.get('/storage/pools', { params: { host_id: hostId() } }),
api.get('/network/list', { params: { host_id: hostId() } }),
])
overview.value = o
vms.value = v.vms || []
@@ -197,7 +203,7 @@ async function vmAction(name, action) {
await ElMessageBox.confirm(`确定要${labels[action]}虚拟机 ${name} 吗?`, '确认操作', {
type: action === 'force_stop' ? 'warning' : 'info',
})
await api.post(`/vm/action/${name}`, { action })
await api.post(`/vm/action/${name}`, { action }, { params: { host_id: hostId() } })
ElMessage.success(`${labels[action]}操作已发送`)
setTimeout(loadData, 2000)
} catch (e) {
@@ -207,8 +213,14 @@ async function vmAction(name, action) {
onMounted(() => {
loadData()
const timer = setInterval(loadData, 10000)
// cleanup on unmount handled by vue
refreshTimer = setInterval(loadData, 10000)
})
onBeforeUnmount(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
</script>
+306
View File
@@ -0,0 +1,306 @@
<template>
<div class="hosts-page">
<div class="toolbar">
<el-button type="primary" @click="showDialog = true">
<el-icon><Plus /></el-icon> 添加主机
</el-button>
<el-button @click="loadData"><el-icon><Refresh /></el-icon> 刷新</el-button>
</div>
<!-- 主机列表 -->
<el-row :gutter="16">
<el-col :span="8" v-for="host in hosts" :key="host.id" style="margin-bottom: 16px;">
<div class="host-card" :class="{ offline: host.status === 'offline' }">
<div class="host-header">
<div>
<h3>
{{ host.name }}
<el-tag v-if="host.type === 'local'" type="success" size="small" effect="plain">本机</el-tag>
</h3>
<span class="host-uri">{{ host.uri }}</span>
</div>
<div class="host-status">
<el-tag :type="host.status === 'online' ? 'success' : 'danger'" size="small" effect="dark">
{{ host.status === 'online' ? '在线' : '离线' }}
</el-tag>
</div>
</div>
<div v-if="host.status === 'online'" class="host-info">
<el-row :gutter="8">
<el-col :span="8">
<div class="stat">
<div class="stat-val">{{ host.cpu_cores || '-' }}</div>
<div class="stat-lbl">CPU核心</div>
</div>
</el-col>
<el-col :span="8">
<div class="stat">
<div class="stat-val">{{ host.memory_mb ? (host.memory_mb / 1024).toFixed(1) + 'G' : '-' }}</div>
<div class="stat-lbl">内存</div>
</div>
</el-col>
<el-col :span="8">
<div class="stat">
<div class="stat-val">{{ host.vm_total ?? '-' }}</div>
<div class="stat-lbl">虚拟机</div>
</div>
</el-col>
</el-row>
<div class="vm-stat" v-if="host.vm_total">
运行 {{ host.vm_running || 0 }} / 总计 {{ host.vm_total }}
</div>
</div>
<div v-else class="host-offline">
<span>无法连接到该主机</span>
</div>
<div class="host-actions">
<el-button type="primary" size="small" @click="enterHost(host)"
:disabled="host.status !== 'online'">
管理虚拟机
</el-button>
<el-button size="small" @click="refreshHost(host.id)">刷新状态</el-button>
<el-button size="small" type="danger" @click="deleteHost(host)"
:disabled="host.type === 'local'">删除</el-button>
</div>
</div>
</el-col>
</el-row>
<el-empty v-if="hosts.length === 0" description="暂无主机" />
<!-- 添加主机对话框 -->
<el-dialog v-model="showDialog" title="添加主机" width="550px" :close-on-click-modal="false">
<el-form :model="form" label-width="110px">
<el-form-item label="主机名称">
<el-input v-model="form.name" placeholder="如:生产服务器-1" />
</el-form-item>
<el-form-item label="连接URI">
<el-select v-model="form.mode" style="margin-bottom: 8px; width: 100%;" @change="onModeChange">
<el-option label="TCP 直连" value="tcp" />
<el-option label="SSH 隧道" value="ssh" />
</el-select>
<el-input v-model="form.uri" placeholder="qemu+tcp://192.168.1.2/system" />
</el-form-item>
<el-form-item label="SSH密钥路径" v-if="form.mode === 'ssh'">
<el-input v-model="form.ssh_key_path" placeholder="/root/.ssh/id_rsa(可选)" />
</el-form-item>
<el-form-item>
<el-button @click="testConn" :loading="testing">
测试连接
</el-button>
<span v-if="testResult" style="margin-left: 12px;">
<el-tag v-if="testResult.success" type="success" size="small">
{{ testResult.hostname }} - {{ testResult.cpu_cores }} / {{ testResult.memory_mb }}MB
</el-tag>
<el-tag v-else type="danger" size="small">{{ testResult.error }}</el-tag>
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="addHost" :loading="adding">添加</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const router = useRouter()
const hosts = ref([])
const showDialog = ref(false)
const testing = ref(false)
const adding = ref(false)
const testResult = ref(null)
const form = ref({
name: '',
mode: 'tcp',
uri: '',
ssh_key_path: '',
})
async function loadData() {
try {
const data = await api.get('/hosts/list')
hosts.value = data.hosts || []
} catch (e) {
console.error(e)
}
}
function onModeChange(mode) {
if (mode === 'tcp') {
form.value.uri = 'qemu+tcp://192.168.1.2/system'
} else {
form.value.uri = 'qemu+ssh://root@192.168.1.2/system'
}
}
async function testConn() {
if (!form.value.uri) {
ElMessage.warning('请输入连接 URI')
return
}
testing.value = true
testResult.value = null
try {
testResult.value = await api.post('/hosts/test', {
name: form.value.name,
uri: form.value.uri,
ssh_key_path: form.value.ssh_key_path || null,
})
} catch (e) {
testResult.value = { success: false, error: e.response?.data?.detail || '测试失败' }
}
testing.value = false
}
async function addHost() {
if (!form.value.name || !form.value.uri) {
ElMessage.warning('请填写名称和 URI')
return
}
adding.value = true
try {
await api.post('/hosts/add', {
name: form.value.name,
uri: form.value.uri,
ssh_key_path: form.value.ssh_key_path || null,
})
ElMessage.success('主机添加成功')
showDialog.value = false
form.value = { name: '', mode: 'tcp', uri: '', ssh_key_path: '' }
testResult.value = null
loadData()
} catch (e) {
ElMessage.error(e.response?.data?.detail || '添加失败')
}
adding.value = false
}
async function refreshHost(hostId) {
try {
await api.post(`/hosts/refresh/${hostId}`)
ElMessage.success('已刷新')
loadData()
} catch (e) {
ElMessage.error('刷新失败')
}
}
async function deleteHost(host) {
if (host.type === 'local') return
try {
await ElMessageBox.confirm(`确定删除主机 ${host.name} 吗?`, '确认', { type: 'error' })
await api.delete(`/hosts/delete/${host.id}`)
ElMessage.success('已删除')
loadData()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.detail || '删除失败')
}
}
function enterHost(host) {
router.push({ path: '/vms', query: { host_id: host.id } })
}
onMounted(loadData)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 8px;
}
.host-card {
background: #1a2633;
border: 1px solid #2a3a4e;
border-radius: 8px;
padding: 20px;
transition: border-color 0.3s;
}
.host-card:hover {
border-color: #409eff;
}
.host-card.offline {
opacity: 0.7;
}
.host-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.host-header h3 {
color: #e0e6ed;
font-size: 16px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.host-uri {
color: #5a6a7a;
font-size: 12px;
word-break: break-all;
}
.host-info {
margin-bottom: 16px;
}
.stat {
text-align: center;
padding: 8px;
background: #0f1923;
border-radius: 6px;
}
.stat-val {
color: #e0e6ed;
font-size: 18px;
font-weight: 700;
}
.stat-lbl {
color: #5a6a7a;
font-size: 11px;
margin-top: 4px;
}
.vm-stat {
color: #7a8fa3;
font-size: 12px;
margin-top: 10px;
text-align: center;
}
.host-offline {
color: #5a6a7a;
text-align: center;
padding: 20px 0;
font-size: 13px;
}
.host-actions {
display: flex;
gap: 8px;
border-top: 1px solid #2a3a4e;
padding-top: 12px;
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<div class="logo-icon">🖥</div>
<h1>KVM Manager</h1>
<p>虚拟化管理平台</p>
</div>
<el-form :model="form" @keyup.enter="handleLogin" class="login-form">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" size="large" prefix-icon="User" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" size="large"
prefix-icon="Lock" show-password />
</el-form-item>
<el-button type="primary" size="large" :loading="loading" @click="handleLogin"
style="width: 100%;"> </el-button>
</el-form>
<div class="login-footer">
<span>默认账号: admin / admin123</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import api, { setToken, setUser } from '../api'
const router = useRouter()
const loading = ref(false)
const form = ref({ username: '', password: '' })
async function handleLogin() {
if (!form.value.username || !form.value.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const data = await api.post('/auth/login', new URLSearchParams({
username: form.value.username,
password: form.value.password,
}))
setToken(data.access_token)
setUser({ username: data.username, role: data.role })
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (e) {
ElMessage.error(e.response?.data?.detail || '登录失败')
}
loading.value = false
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f1923;
}
.login-card {
width: 400px;
background: #1a2633;
border: 1px solid #2a3a4e;
border-radius: 12px;
padding: 40px 36px;
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.logo-icon {
font-size: 40px;
margin-bottom: 12px;
}
.login-header h1 {
color: #e0e6ed;
font-size: 24px;
margin-bottom: 6px;
}
.login-header p {
color: #7a8fa3;
font-size: 14px;
}
.login-form {
margin-bottom: 16px;
}
.login-footer {
text-align: center;
color: #5a6a7a;
font-size: 12px;
}
</style>
+8 -4
View File
@@ -92,10 +92,14 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const route = useRoute()
const hostId = () => route.query.host_id || 'local'
const networks = ref([])
const showDialog = ref(false)
const form = ref({
@@ -109,7 +113,7 @@ const form = ref({
async function loadData() {
try {
const data = await api.get('/network/list')
const data = await api.get('/network/list', { params: { host_id: hostId() } })
networks.value = data.networks || []
} catch (e) {}
}
@@ -122,7 +126,7 @@ async function createNet() {
return
}
try {
await api.post('/network/create', form.value)
await api.post('/network/create', form.value, { params: { host_id: hostId() } })
ElMessage.success('网络创建成功')
showDialog.value = false
form.value = { name: '', mode: 'nat', subnet: '192.168.100.0/24', bridge: '', dhcp_start: '', dhcp_end: '' }
@@ -134,7 +138,7 @@ async function createNet() {
async function toggleNet(name, action) {
try {
await api.post(`/network/action/${name}?action=${action}`)
await api.post(`/network/action/${name}?action=${action}&host_id=${hostId()}`)
ElMessage.success('操作成功')
setTimeout(loadData, 1000)
} catch (e) {
@@ -145,7 +149,7 @@ async function toggleNet(name, action) {
async function deleteNet(name) {
try {
await ElMessageBox.confirm(`确定删除网络 ${name} 吗?`, '确认', { type: 'error' })
await api.delete(`/network/delete/${name}`)
await api.delete(`/network/delete/${name}`, { params: { host_id: hostId() } })
ElMessage.success('网络已删除')
loadData()
} catch (e) {
+11 -7
View File
@@ -116,10 +116,14 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const route = useRoute()
const hostId = () => route.query.host_id || 'local'
const pools = ref([])
const isos = ref([])
const volumes = ref([])
@@ -134,8 +138,8 @@ const volForm = ref({ name: '', capacity_gb: 20, format: 'qcow2' })
async function loadData() {
try {
const [p, i] = await Promise.all([
api.get('/storage/pools'),
api.get('/storage/isos'),
api.get('/storage/pools', { params: { host_id: hostId() } }),
api.get('/storage/isos', { params: { host_id: hostId() } }),
])
pools.value = p.pools || []
isos.value = i.isos || []
@@ -145,7 +149,7 @@ async function loadData() {
async function viewVolumes(poolName) {
currentPool.value = poolName
try {
const data = await api.get(`/storage/pool/${poolName}`)
const data = await api.get(`/storage/pool/${poolName}`, { params: { host_id: hostId() } })
volumes.value = data.volumes || []
showVolumesDialog.value = true
} catch (e) {}
@@ -153,7 +157,7 @@ async function viewVolumes(poolName) {
async function createPool() {
try {
await api.post('/storage/pool/create', poolForm.value)
await api.post('/storage/pool/create', poolForm.value, { params: { host_id: hostId() } })
ElMessage.success('存储池创建成功')
showPoolDialog.value = false
poolForm.value = { name: '', path: '', type: 'dir' }
@@ -166,7 +170,7 @@ async function createPool() {
async function deletePool(name) {
try {
await ElMessageBox.confirm(`确定删除存储池 ${name} 吗?`, '确认', { type: 'error' })
await api.delete(`/storage/pool/${name}`)
await api.delete(`/storage/pool/${name}`, { params: { host_id: hostId() } })
ElMessage.success('存储池已删除')
loadData()
} catch (e) {
@@ -176,7 +180,7 @@ async function deletePool(name) {
async function createVol() {
try {
await api.post(`/storage/pool/${currentPool.value}/volume`, volForm.value)
await api.post(`/storage/pool/${currentPool.value}/volume`, volForm.value, { params: { host_id: hostId() } })
ElMessage.success('卷创建成功')
showVolDialog.value = false
volForm.value = { name: '', capacity_gb: 20, format: 'qcow2' }
@@ -189,7 +193,7 @@ async function createVol() {
async function deleteVol(volName) {
try {
await ElMessageBox.confirm(`确定删除卷 ${volName} 吗?`, '确认', { type: 'error' })
await api.delete(`/storage/pool/${currentPool.value}/volume/${volName}`)
await api.delete(`/storage/pool/${currentPool.value}/volume/${volName}`, { params: { host_id: hostId() } })
ElMessage.success('卷已删除')
viewVolumes(currentPool.value)
} catch (e) {
+181 -9
View File
@@ -18,6 +18,10 @@
<el-button v-if="vm.state === 'paused'" type="success" @click="doAction('resume')">恢复</el-button>
<el-button v-if="vm.state === 'running'" type="danger" @click="doAction('force_stop')">强制关机</el-button>
<el-button v-if="vm.state === 'running'" @click="doAction('restart')">重启</el-button>
<el-button v-if="vm.vnc_port > 0" type="primary" @click="openConsole">控制台</el-button>
<el-button @click="showCloneDialog = true">克隆</el-button>
<el-button @click="showMigrateDialog = true">迁移</el-button>
<el-button @click="loadXml">XML配置</el-button>
</div>
</div>
@@ -130,6 +134,61 @@
</el-table>
</div>
<!-- VNC 控制台 -->
<div v-if="showConsole" class="info-card" style="margin-top: 16px;">
<div class="section-header">
<h3>VNC 控制台</h3>
<div style="display: flex; gap: 8px;">
<el-button size="small" @click="toggleFullscreen">全屏</el-button>
<el-button size="small" type="danger" @click="showConsole = false">关闭</el-button>
</div>
</div>
<div class="vnc-container" id="vnc-container">
<div id="vnc-screen"></div>
</div>
</div>
<!-- 克隆对话框 -->
<el-dialog v-model="showCloneDialog" title="克隆虚拟机" width="450px">
<el-form :model="cloneForm" label-width="100px">
<el-form-item label="新虚拟机名">
<el-input v-model="cloneForm.new_name" placeholder="输入新虚拟机名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCloneDialog = false">取消</el-button>
<el-button type="primary" @click="cloneVM" :loading="cloning">克隆</el-button>
</template>
</el-dialog>
<!-- 迁移对话框 -->
<el-dialog v-model="showMigrateDialog" title="迁移虚拟机" width="500px">
<el-alert type="warning" :closable="false" style="margin-bottom: 16px;">
虚拟机将被迁移到目标宿主机请确保目标主机已配置 libvirt 并允许远程连接
</el-alert>
<el-form :model="migrateForm" label-width="120px">
<el-form-item label="目标URI">
<el-input v-model="migrateForm.dest_uri" placeholder="qemu+tcp://192.168.1.2/system" />
</el-form-item>
<el-form-item label="迁移模式">
<el-switch v-model="migrateForm.live" active-text="在线迁移" inactive-text="离线迁移" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showMigrateDialog = false">取消</el-button>
<el-button type="primary" @click="migrateVM" :loading="migrating">迁移</el-button>
</template>
</el-dialog>
<!-- XML 编辑对话框 -->
<el-dialog v-model="showXmlDialog" title="XML 配置" width="700px">
<el-input v-model="xmlContent" type="textarea" :rows="20" style="font-family: monospace;" />
<template #footer>
<el-button @click="showXmlDialog = false">取消</el-button>
<el-button type="primary" @click="saveXml" :loading="savingXml">保存</el-button>
</template>
</el-dialog>
<!-- 创建快照对话框 -->
<el-dialog v-model="showSnapDialog" title="创建快照" width="400px">
<el-form :model="snapForm" label-width="80px">
@@ -149,20 +208,33 @@
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import api from '../api'
const route = useRoute()
const router = useRouter()
const vmName = route.params.name
const hostId = () => route.query.host_id || 'local'
const loading = ref(true)
const vm = ref({})
const monitor = ref({})
const snapshots = ref([])
const snapLoading = ref(false)
const showSnapDialog = ref(false)
const showConsole = ref(false)
const showCloneDialog = ref(false)
const showXmlDialog = ref(false)
const showMigrateDialog = ref(false)
const cloning = ref(false)
const savingXml = ref(false)
const migrating = ref(false)
const xmlContent = ref('')
const cloneForm = ref({ new_name: '' })
const migrateForm = ref({ dest_uri: '', live: true })
let rfb = null
const snapForm = ref({ name: '', description: '' })
let timer = null
@@ -197,21 +269,21 @@ function formatTime(ts) {
async function loadVM() {
try {
vm.value = await api.get(`/vm/detail/${vmName}`)
vm.value = await api.get(`/vm/detail/${vmName}`, { params: { host_id: hostId() } })
} catch (e) {}
loading.value = false
}
async function loadMonitor() {
try {
monitor.value = await api.get(`/monitor/vm/${vmName}`)
monitor.value = await api.get(`/monitor/vm/${vmName}`, { params: { host_id: hostId() } })
} catch (e) {}
}
async function loadSnapshots() {
snapLoading.value = true
try {
const data = await api.get(`/snapshot/list/${vmName}`)
const data = await api.get(`/snapshot/list/${vmName}`, { params: { host_id: hostId() } })
snapshots.value = data.snapshots || []
} catch (e) {}
snapLoading.value = false
@@ -221,7 +293,7 @@ async function doAction(action) {
const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复', restart: '重启' }
try {
await ElMessageBox.confirm(`确定要${labels[action]}吗?`, '确认', { type: 'info' })
await api.post(`/vm/action/${vmName}`, { action })
await api.post(`/vm/action/${vmName}`, { action }, { params: { host_id: hostId() } })
ElMessage.success(`${labels[action]}操作已发送`)
setTimeout(() => { loadVM(); loadMonitor() }, 2000)
} catch (e) {
@@ -235,7 +307,7 @@ async function createSnap() {
return
}
try {
await api.post(`/snapshot/create/${vmName}`, snapForm.value)
await api.post(`/snapshot/create/${vmName}`, snapForm.value, { params: { host_id: hostId() } })
ElMessage.success('快照创建成功')
showSnapDialog.value = false
snapForm.value = { name: '', description: '' }
@@ -248,7 +320,7 @@ async function createSnap() {
async function revertSnap(name) {
try {
await ElMessageBox.confirm(`确定恢复到快照 ${name} 吗?虚拟机将重启。`, '确认', { type: 'warning' })
await api.post(`/snapshot/revert/${vmName}/${name}`)
await api.post(`/snapshot/revert/${vmName}/${name}`, null, { params: { host_id: hostId() } })
ElMessage.success('快照已恢复')
loadVM()
loadSnapshots()
@@ -260,7 +332,7 @@ async function revertSnap(name) {
async function deleteSnap(name) {
try {
await ElMessageBox.confirm(`确定删除快照 ${name} 吗?`, '确认', { type: 'error' })
await api.delete(`/snapshot/delete/${vmName}/${name}`)
await api.delete(`/snapshot/delete/${vmName}/${name}`, { params: { host_id: hostId() } })
ElMessage.success('快照已删除')
loadSnapshots()
} catch (e) {
@@ -268,6 +340,85 @@ async function deleteSnap(name) {
}
}
async function cloneVM() {
if (!cloneForm.value.new_name) {
ElMessage.warning('请输入新虚拟机名称')
return
}
cloning.value = true
try {
await api.post(`/vm/clone/${vmName}`, cloneForm.value, { params: { host_id: hostId() } })
ElMessage.success('克隆成功')
showCloneDialog.value = false
cloneForm.value = { new_name: '' }
} catch (e) {
ElMessage.error(e.response?.data?.detail || '克隆失败')
}
cloning.value = false
}
async function migrateVM() {
if (!migrateForm.value.dest_uri) {
ElMessage.warning('请输入目标 URI')
return
}
migrating.value = true
try {
await ElMessageBox.confirm(
`确定将虚拟机迁移到 ${migrateForm.value.dest_uri} 吗?`,
'确认迁移',
{ type: 'warning' }
)
await api.post(`/vm/migrate/${vmName}`, null, {
params: { dest_uri: migrateForm.value.dest_uri, live: migrateForm.value.live, host_id: hostId() }
})
ElMessage.success('迁移成功')
showMigrateDialog.value = false
migrateForm.value = { dest_uri: '', live: true }
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.detail || '迁移失败')
}
migrating.value = false
}
async function loadXml() {
try {
const data = await api.get(`/vm/xml/${vmName}`, { params: { host_id: hostId() } })
xmlContent.value = data.xml || ''
showXmlDialog.value = true
} catch (e) {
ElMessage.error('获取 XML 失败')
}
}
async function saveXml() {
savingXml.value = true
try {
await api.put(`/vm/xml/${vmName}`, { xml: xmlContent.value }, { params: { host_id: hostId() } })
ElMessage.success('XML 配置已更新')
showXmlDialog.value = false
loadVM()
} catch (e) {
ElMessage.error(e.response?.data?.detail || '保存失败')
}
savingXml.value = false
}
function openConsole() {
router.push({ path: `/console/${vmName}`, query: { host_id: hostId() } })
}
function toggleFullscreen() {
const container = document.getElementById('vnc-container')
if (container) {
if (!document.fullscreenElement) {
container.requestFullscreen()
} else {
document.exitFullscreen()
}
}
}
onMounted(() => {
loadVM()
loadMonitor()
@@ -277,6 +428,7 @@ onMounted(() => {
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
if (rfb) { rfb.disconnect(); rfb = null }
})
</script>
@@ -388,4 +540,24 @@ onBeforeUnmount(() => {
margin-bottom: 0;
padding-bottom: 0;
}
.vnc-container {
width: 100%;
height: 500px;
background: #000;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.vnc-container:fullscreen {
height: 100vh;
}
#vnc-screen {
width: 100%;
height: 100%;
}
</style>
+13 -9
View File
@@ -36,7 +36,7 @@
</el-table-column>
<el-table-column prop="name" label="名称" min-width="150">
<template #default="{ row }">
<el-link type="primary" @click="$router.push(`/vm/${row.name}`)">{{ row.name }}</el-link>
<el-link type="primary" @click="$router.push(`/vm/${row.name}?host_id=${hostId()}`)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
@@ -77,7 +77,7 @@
<el-button v-if="row.state === 'running'" type="danger" size="small"
@click="doAction(row.name, 'force_stop')">强制关</el-button>
<el-button type="primary" size="small"
@click="$router.push(`/vm/${row.name}`)">详情</el-button>
@click="$router.push(`/vm/${row.name}?host_id=${hostId()}`)">详情</el-button>
<el-button type="danger" size="small"
@click="deleteVM(row)">删除</el-button>
</el-button-group>
@@ -133,10 +133,14 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import api from '../api'
const route = useRoute()
const hostId = () => route.query.host_id || 'local'
const loading = ref(false)
const creating = ref(false)
const vms = ref([])
@@ -173,7 +177,7 @@ function formatMem(mb) {
async function loadData() {
loading.value = true
try {
const data = await api.get('/vm/list')
const data = await api.get('/vm/list', { params: { host_id: hostId() } })
vms.value = data.vms || []
} catch (e) {}
loading.value = false
@@ -182,9 +186,9 @@ async function loadData() {
async function loadOptions() {
try {
const [pools, nets, isos] = await Promise.all([
api.get('/storage/pools'),
api.get('/network/list'),
api.get('/storage/isos'),
api.get('/storage/pools', { params: { host_id: hostId() } }),
api.get('/network/list', { params: { host_id: hostId() } }),
api.get('/storage/isos', { params: { host_id: hostId() } }),
])
poolOptions.value = (pools.pools || []).map(p => p.name)
networkOptions.value = (nets.networks || []).map(n => n.name)
@@ -196,7 +200,7 @@ async function doAction(name, action) {
const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复' }
try {
await ElMessageBox.confirm(`确定要${labels[action]}虚拟机 ${name} 吗?`, '确认', { type: 'info' })
await api.post(`/vm/action/${name}`, { action })
await api.post(`/vm/action/${name}`, { action }, { params: { host_id: hostId() } })
ElMessage.success(`${labels[action]}操作已发送`)
setTimeout(loadData, 2000)
} catch (e) {
@@ -211,7 +215,7 @@ async function createVM() {
}
creating.value = true
try {
await api.post('/vm/create', createForm.value)
await api.post('/vm/create', createForm.value, { params: { host_id: hostId() } })
ElMessage.success('虚拟机创建成功')
showCreateDialog.value = false
loadData()
@@ -228,7 +232,7 @@ async function deleteVM(row) {
'危险操作',
{ type: 'error', confirmButtonText: '确定删除', confirmButtonClass: 'el-button--danger' }
)
await api.delete(`/vm/delete/${row.name}`, { params: { force: row.state === 'running' } })
await api.delete(`/vm/delete/${row.name}`, { params: { force: row.state === 'running', host_id: hostId() } })
ElMessage.success('虚拟机已删除')
loadData()
} catch (e) {
+9
View File
@@ -11,6 +11,15 @@ export default defineConfig({
target: 'http://localhost:8004',
changeOrigin: true,
},
'/ws': {
target: 'http://localhost:8004',
ws: true,
changeOrigin: true,
},
'/health': {
target: 'http://localhost:8004',
changeOrigin: true,
},
},
},
})