mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 06:14:42 +08:00
兼容KVM大多数功能
This commit is contained in:
@@ -48,7 +48,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
.then((res) => {
|
||||
const data = res.data.data || []
|
||||
setTemplates(data)
|
||||
setForm((prev) => ({ ...prev, template_id: data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '') }))
|
||||
setForm((prev) => {
|
||||
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
|
||||
return applyTemplateDefaults({ ...prev, template_id: templateID })
|
||||
})
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
@@ -190,14 +193,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
LXC 容器
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => ({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
KVM 虚拟机
|
||||
@@ -213,7 +216,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
) : (
|
||||
<select
|
||||
value={form.template_id}
|
||||
onChange={(event) => setForm({ ...form, template_id: event.target.value })}
|
||||
onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
|
||||
className={inputClass}
|
||||
>
|
||||
{templates.map((template) => (
|
||||
@@ -223,6 +226,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
</Field>
|
||||
|
||||
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
@@ -324,7 +328,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
|
||||
SSH: {sshPortPreview} -> 22
|
||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
@@ -435,7 +439,10 @@ function NumberInput({
|
||||
|
||||
function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) {
|
||||
const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {}
|
||||
const minVCPU = form.virtualization === 'kvm' ? 1 : 0.25
|
||||
const windows = isWindowsTemplate(form.template_id)
|
||||
const minVCPU = windows ? 2 : (form.virtualization === 'kvm' ? 1 : 0.25)
|
||||
const minRAMMB = windows ? 2048 : 128
|
||||
const minDiskGB = windows ? 30 : 1
|
||||
|
||||
if (!Number.isFinite(form.vcpu)) {
|
||||
errors.vcpu = '请输入 vCPU'
|
||||
@@ -449,16 +456,16 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
|
||||
|
||||
if (!Number.isFinite(form.ram_mb)) {
|
||||
errors.ram_mb = '请输入内存'
|
||||
} else if (form.ram_mb < 128) {
|
||||
errors.ram_mb = '不能小于 128 MB'
|
||||
} else if (form.ram_mb < minRAMMB) {
|
||||
errors.ram_mb = `不能小于 ${minRAMMB} MB`
|
||||
} else if (maxRAMMB && form.ram_mb > maxRAMMB) {
|
||||
errors.ram_mb = `不能大于 ${maxRAMMB} MB`
|
||||
}
|
||||
|
||||
if (!Number.isFinite(form.disk_gb)) {
|
||||
errors.disk_gb = '请输入磁盘'
|
||||
} else if (form.disk_gb < 1) {
|
||||
errors.disk_gb = '不能小于 1 GB'
|
||||
} else if (form.disk_gb < minDiskGB) {
|
||||
errors.disk_gb = `不能小于 ${minDiskGB} GB`
|
||||
} else if (maxDiskGB && form.disk_gb > maxDiskGB) {
|
||||
errors.disk_gb = `不能大于 ${maxDiskGB} GB`
|
||||
}
|
||||
@@ -467,15 +474,31 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
|
||||
}
|
||||
|
||||
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||
const normalized = applyTemplateDefaults(form)
|
||||
return {
|
||||
...normalized,
|
||||
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
||||
ram_mb: Math.round(normalized.ram_mb),
|
||||
disk_gb: Math.round(normalized.disk_gb),
|
||||
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
|
||||
}
|
||||
}
|
||||
|
||||
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
|
||||
if (!isWindowsTemplate(form.template_id)) return form
|
||||
return {
|
||||
...form,
|
||||
vcpu: form.virtualization === 'kvm' ? Math.round(form.vcpu) : normalizeLXCvCPU(form.vcpu),
|
||||
ram_mb: Math.round(form.ram_mb),
|
||||
disk_gb: Math.round(form.disk_gb),
|
||||
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3),
|
||||
virtualization: 'kvm',
|
||||
vcpu: Math.max(2, Math.round(Number.isFinite(form.vcpu) ? form.vcpu : 2)),
|
||||
ram_mb: Math.max(2048, Math.round(Number.isFinite(form.ram_mb) ? form.ram_mb : 2048)),
|
||||
disk_gb: Math.max(30, Math.round(Number.isFinite(form.disk_gb) ? form.disk_gb : 30)),
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowsTemplate(templateID: string) {
|
||||
return templateID.toLowerCase().includes('windows')
|
||||
}
|
||||
|
||||
function normalizeLXCvCPU(value: number) {
|
||||
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
|
||||
return Number(rounded.toFixed(2))
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
|
||||
import RFB from '@novnc/novnc'
|
||||
import { createVNCTicket, getWebVNCUrl } from '../services/api'
|
||||
|
||||
interface WebVNCViewerProps {
|
||||
containerName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
|
||||
const screenRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<RFB | null>(null)
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
const cleanup = () => {
|
||||
if (rfbRef.current) {
|
||||
rfbRef.current.disconnect()
|
||||
rfbRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
const target = screenRef.current
|
||||
if (!target) return
|
||||
|
||||
cleanup()
|
||||
target.innerHTML = ''
|
||||
setStatus('connecting')
|
||||
setErrorMsg('')
|
||||
|
||||
let ticket = ''
|
||||
try {
|
||||
const response = await createVNCTicket(containerName)
|
||||
ticket = response.data.data?.ticket || ''
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
setStatus('error')
|
||||
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
|
||||
return
|
||||
}
|
||||
if (!ticket) {
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket))
|
||||
rfb.scaleViewport = true
|
||||
rfb.resizeSession = false
|
||||
rfb.focusOnClick = true
|
||||
rfb.qualityLevel = 6
|
||||
rfb.compressionLevel = 2
|
||||
rfb.background = '#050505'
|
||||
rfb.addEventListener('connect', () => {
|
||||
setStatus('connected')
|
||||
})
|
||||
rfb.addEventListener('disconnect', (event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
|
||||
setStatus((current) => current === 'error' ? current : 'disconnected')
|
||||
if (detail && detail.clean === false) {
|
||||
setErrorMsg('WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用')
|
||||
}
|
||||
})
|
||||
rfb.addEventListener('securityfailure', () => {
|
||||
setStatus('error')
|
||||
setErrorMsg('VNC 安全协商失败')
|
||||
})
|
||||
rfb.addEventListener('credentialsrequired', () => {
|
||||
setStatus('error')
|
||||
setErrorMsg('当前 VNC 控制台要求密码,暂不支持自动输入')
|
||||
})
|
||||
rfbRef.current = rfb
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC 初始化失败')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(connect, 100)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
cleanup()
|
||||
}
|
||||
}, [containerName])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
|
||||
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">连接失败</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => rfbRef.current?.sendCtrlAltDel()}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
|
||||
title="发送 Ctrl+Alt+Del"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
Ctrl+Alt+Del
|
||||
</button>
|
||||
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
|
||||
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
|
||||
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
|
||||
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
|
||||
{status === 'connecting' ? '正在连接 KVM VNC 控制台...' : (errorMsg || 'WebVNC 已断开')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user