Files
CLICD/frontend/src/components/CreateContainerModal.tsx
T
MengMengCode ebba97f1d6 · 修复了一些已知问题
· 增加了局域网DHCP IP分配适配
· 完善了多盘兼容支持 #17
2026-07-18 19:44:32 +08:00

1188 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, RefreshCw, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, StorageInfo, Template } from '../services/api'
import { useDialog } from './Dialog'
import { useLanguage, type Language } from '../contexts/LanguageContext'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
interface CreateContainerModalProps {
isOpen: boolean
onClose: () => void
onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void>
existingNames?: string[]
}
const defaultForm: CreateContainerRequest = {
name: '',
virtualization: 'lxc',
template_id: '',
storage_pool_id: '',
vcpu: 1,
cpu_percent: 100,
ram_mb: 512,
disk_gb: 10,
network_bw_mbps: 0,
network_down_mbps: 0,
network_up_mbps: 0,
monthly_traffic_gb: 0,
traffic_mode: 'total',
traffic_in_gb: 0,
traffic_out_gb: 0,
io_speed_mbps: 0,
io_read_mbps: 0,
io_write_mbps: 0,
extra_ports: [],
port_mapping_count: 2,
assign_nat: true,
lan_ipv4_mode: '',
lan_interface: '',
lan_ipv4_address: '',
lan_ipv4_prefix_len: 24,
lan_ipv4_gateway: '',
snapshot_limit: 1,
assign_ipv4: false,
ipv4_count: 1,
public_ipv4s: [],
assign_ipv6: false,
ipv6_count: 1,
ipv6_addresses: [],
ssh_auth_mode: 'auto_password',
ssh_password: '',
ssh_public_key: '',
allowed_image_ids: [],
image_limit_configured: false,
expires_at: '',
}
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const navigate = useNavigate()
const dialog = useDialog()
const { language } = useLanguage()
const networkText = createNetworkText[language]
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false)
const [batchCount, setBatchCount] = useState(1)
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [hostReport, setHostReport] = useState<HostProbeReport | null>(null)
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(true)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
const [nameError, setNameError] = useState('')
useEffect(() => {
if (!isOpen) return
getEnabledImages(form.virtualization)
.then((res) => {
const data = res.data.data || []
setTemplates(data)
setForm((prev) => {
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
const allowed = new Set(data.map((item) => item.id))
const selectedAllowedIDs = (prev.allowed_image_ids || []).filter((id) => allowed.has(id))
return applyTemplateDefaults({
...prev,
template_id: templateID,
allowed_image_ids: prev.image_limit_configured ? selectedAllowedIDs : (templateID ? [templateID] : []),
image_limit_configured: true,
})
})
})
.catch(console.error)
getIPv6Status()
.then((res) => {
const status = res.data.data || null
setIPv6Status(status)
if (!status?.available) {
setForm((prev) => ({ ...prev, assign_ipv6: false }))
}
})
.catch(() => {
setIPv6Status({ available: false, reachable: false, reason: 'IPv6 status check failed', prefixes: [] })
setForm((prev) => ({ ...prev, assign_ipv6: false }))
})
getHostInfo()
.then((res) => setHostInfo(res.data.data || null))
.catch(() => setHostInfo(null))
getHostReport()
.then((res) => setHostReport(res.data.data || null))
.catch(() => setHostReport(null))
}, [isOpen, form.virtualization])
useEffect(() => {
if (!isOpen) return
let active = true
setStorageLoading(true)
getStorageInfo()
.then((res) => {
if (active) setStorageInfo(res.data.data || null)
})
.catch(() => {
if (active) setStorageInfo(null)
})
.finally(() => {
if (active) setStorageLoading(false)
})
return () => { active = false }
}, [isOpen])
const ipv6Available = !!ipv6Status?.available
const ipv6Prefixes = ipv6Status?.prefixes || []
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
const publicIPv4s = hostInfo?.network.public_ipv4_addresses || []
const ipv4Available = publicIPv4s.length > 0
const manualIPv4s = form.public_ipv4s || []
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const kvmAvailable = !!hostInfo?.runtime?.kvm_available
const storagePools = useMemo(() => {
const content = form.virtualization === 'kvm' ? 'kvm' : 'lxc'
return (storageInfo?.pools || []).filter((pool) => pool.enabled && pool.available !== false && (pool.content_types || []).includes(content))
}, [storageInfo, form.virtualization])
const storageReady = storagePools.length > 0
useEffect(() => {
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))
}
}, [hostInfo, kvmAvailable, form.virtualization])
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const lanIPv4Enabled = form.lan_ipv4_mode === 'dhcp' || form.lan_ipv4_mode === 'static'
const lanStaticEnabled = form.lan_ipv4_mode === 'static'
const natEnabled = form.assign_nat !== false && !lanIPv4Enabled
const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport])
const defaultLANInterface = lanInterfaces[0]?.name || ''
const customNATPorts = form.extra_ports || []
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2, customNATPorts.length + 1) : 0
const linuxTemplate = !isWindowsTemplate(form.template_id)
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
const autoPorts = useMemo(() => {
if (!natEnabled) return []
const count = natPortCount
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
}, [natEnabled, natPortCount])
const natPreviewPorts = customNATPorts.length > 0 ? customNATPorts : autoPorts
// SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000
// Find next available batch index to avoid name conflicts
const batchStartIndex = useMemo(() => {
if (batchCount <= 1 || !form.name) return 1
const prefix = `${form.name}-`
let maxIdx = 0
for (const existing of existingNames) {
if (existing.startsWith(prefix)) {
const suffix = existing.slice(prefix.length)
const idx = parseInt(suffix, 10)
if (!isNaN(idx) && idx > maxIdx) {
maxIdx = idx
}
}
}
return maxIdx + 1
}, [form.name, batchCount, existingNames])
const handleNameChange = (value: string) => {
setForm({ ...form, name: value })
if (/\s/.test(value)) {
setNameError('容器名称不能包含空格')
} else if (value && existingNames.includes(value) && batchCount === 1) {
setNameError('该容器名称已存在')
} else {
setNameError('')
}
}
const handleSubmit = async () => {
if (!form.name || !form.template_id) {
dialog.alert('提示', '请填写容器名称并选择系统模板')
return
}
if (Object.keys(resourceErrors).length > 0) {
dialog.alert('资源配置有误', '请按红色提示修改 vCPU、内存或磁盘配置')
return
}
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false && form.lan_ipv4_mode !== 'dhcp' && form.lan_ipv4_mode !== 'static') {
dialog.alert('提示', '请勾选任意一个可用网络')
return
}
if (form.lan_ipv4_mode === 'static') {
if (!isIPv4Address(form.lan_ipv4_address || '') || !isIPv4Address(form.lan_ipv4_gateway || '') || !form.lan_ipv4_prefix_len) {
dialog.alert('局域网 IPv4 配置有误', '请填写有效的 IPv4 地址、子网掩码和网关')
return
}
}
if (!storageReady) {
dialog.alert('未配置存储', `请先在存储管理中为 ${form.virtualization === 'kvm' ? 'KVM 磁盘' : 'LXC 容器'}开启至少一块存储磁盘`)
return
}
const authError = validateSSHAuthInputs(form)
if (authError) {
dialog.alert('登录方式有误', authError)
return
}
const boundedForm = normalizeCreateForm(form)
const wantsNAT = boundedForm.assign_nat !== false
// Build batch of containers
const containers: CreateContainerRequest[] = []
const startIndex = batchStartIndex
for (let i = 0; i < batchCount; i++) {
const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name
containers.push({
...boundedForm,
name,
assign_nat: wantsNAT,
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2, (boundedForm.extra_ports || []).length + 1) : 0,
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
extra_ports: wantsNAT ? (boundedForm.extra_ports || []) : [],
})
}
setLoading(true)
try {
await batchCreate(containers)
await onSuccess(containers)
onClose()
setBatchCount(1)
setForm({ ...defaultForm, template_id: templates[0]?.id || '', allowed_image_ids: templates[0]?.id ? [templates[0].id] : [], image_limit_configured: true })
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
} finally {
setLoading(false)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-3xl max-h-[92vh] overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-black">创建新容器</h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded text-gray-500" title="关闭">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="容器名称">
<input
type="text"
value={form.name}
onChange={(event) => handleNameChange(event.target.value)}
className={`${inputClass} ${nameError ? 'border-red-400 focus:ring-red-400 focus:border-red-400' : ''}`}
placeholder="my-container"
required
/>
{nameError && <p className="text-xs text-red-500 mt-1">{nameError}</p>}
</Field>
<Field label="批量创建数量">
<NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} />
</Field>
</div>
{batchCount > 1 && <p className="text-xs text-gray-400">将创建 {batchCount} 个容器:{form.name}-{batchStartIndex} {form.name}-{batchStartIndex + batchCount - 1}</p>}
<Field label="虚拟化架构">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))}
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"
disabled={!kvmAvailable}
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
onClick={() => {
if (kvmAvailable) {
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))
}
}}
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
KVM 虚拟机
</button>
</div>
</Field>
<Field label="系统模板">
{templates.length === 0 ? (
<div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
暂无可用的{form.virtualization === 'kvm' ? ' KVM' : ' LXC'}系统镜像,请先在「镜像管理」中下载镜像模板。
</div>
) : (
<select
value={form.template_id}
onChange={(event) => {
const templateID = event.target.value
const allowed = new Set(form.allowed_image_ids || [])
if (templateID) allowed.add(templateID)
setForm(applyTemplateDefaults({ ...form, template_id: templateID, allowed_image_ids: Array.from(allowed), image_limit_configured: true }))
}}
className={inputClass}
>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
)}
</Field>
<Field label="存储磁盘">
{storageLoading ? (
<div className="flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600">
<RefreshCw className="h-4 w-4 animate-spin" />
正在检查存储配置...
</div>
) : storagePools.length > 0 ? (
<select
value={form.storage_pool_id || ''}
onChange={(event) => setForm({ ...form, storage_pool_id: event.target.value })}
className={inputClass}
>
<option value="">自动选择(默认盘优先,空间不足自动切换)</option>
{storagePools.map((pool) => (
<option key={pool.id} value={pool.id}>
{pool.name} · {pool.mount_point || pool.path}
</option>
))}
</select>
) : (
<div className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
<span>尚未开启{form.virtualization === 'kvm' ? ' KVM 磁盘' : ' LXC 容器'}存储,当前无法创建。</span>
<button
type="button"
onClick={() => { onClose(); navigate('/storage') }}
className="shrink-0 rounded-md border border-amber-300 bg-white px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100"
>
去开启
</button>
</div>
)}
</Field>
{templates.length > 0 && (
<Field label="子用户可用镜像">
<div className="rounded-md border border-gray-200 bg-gray-50 p-3">
<div className="mb-2 text-xs text-gray-500">默认勾选当前系统;取消后,子用户也不能重装该系统。</div>
<div className="grid gap-2 sm:grid-cols-2">
{templates.map((template) => {
const checked = (form.allowed_image_ids || []).includes(template.id)
const current = template.id === form.template_id
return (
<label key={template.id} className={`flex cursor-pointer items-start gap-2 rounded border px-2.5 py-2 text-xs ${checked ? 'border-black bg-white' : 'border-gray-200 bg-white hover:bg-gray-50'}`}>
<input
type="checkbox"
checked={checked}
onChange={() => {
const currentIDs = form.allowed_image_ids || []
const next = checked ? currentIDs.filter((id) => id !== template.id) : [...currentIDs, template.id]
setForm({ ...form, allowed_image_ids: next, image_limit_configured: true })
}}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
/>
<span className="min-w-0">
<span className="block truncate font-medium text-gray-800">{template.name}{current ? '(当前系统)' : ''}</span>
<span className="block text-gray-500">{template.arch} · {template.distro} {template.release}</span>
</span>
</label>
)
})}
</div>
</div>
</Field>
)}
{linuxTemplate && (
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
<div className="mb-2 font-medium text-gray-800">登录方式</div>
<div className="grid grid-cols-3 gap-2">
{([
['auto_password', '自动生成密码'],
['password', '自定义密码'],
['key', 'SSH Key'],
] as Array<[SSHAuthMode, string]>).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setForm({ ...form, ssh_auth_mode: mode })}
className={`rounded-md border px-3 py-2 text-xs font-medium transition-colors ${sshAuthMode === mode ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
{label}
</button>
))}
</div>
{sshAuthMode === 'password' && (
<div className="mt-3 flex gap-2">
<input
type="text"
value={form.ssh_password || ''}
onChange={(event) => setForm({ ...form, ssh_password: event.target.value })}
className={inputClass}
placeholder="RootPass123"
/>
<button
type="button"
onClick={() => setForm({ ...form, ssh_password: generateSSHPassword() })}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50"
title="生成密码"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)}
{sshAuthMode === 'key' && (
<textarea
value={form.ssh_public_key || ''}
onChange={(event) => setForm({ ...form, ssh_public_key: event.target.value })}
className={`${inputClass} mt-3 min-h-20 resize-y font-mono text-xs`}
placeholder="ssh-ed25519 AAAA..."
/>
)}
</div>
)}
<div className="grid gap-3 lg:grid-cols-2">
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={!!form.assign_ipv4}
disabled={!ipv4Available}
onChange={(event) => setForm({
...form,
assign_ipv4: event.target.checked,
public_ipv4s: event.target.checked ? form.public_ipv4s : [],
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [], lan_ipv4_mode: '', lan_interface: '' } : {}),
})}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicIPv4}</span>
<span className="block text-xs text-gray-500">
{ipv4Available ? formatAllocatableIPv4Count(publicIPv4s.length, language) : networkText.noAllocatableIPv4}
</span>
</span>
</label>
{form.assign_ipv4 && (
<div className="mt-3 space-y-3 pl-6">
<div className="grid grid-cols-2 gap-3">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={manualIPv4s.length === 0}
onChange={() => setForm({ ...form, public_ipv4s: [] })}
/>
Auto assign
</label>
<Field label="IPv4 count">
<NumberInput
value={form.ipv4_count || 1}
min={1}
max={Math.max(1, publicIPv4s.length)}
onChange={(value) => setForm({ ...form, ipv4_count: Math.max(1, Math.round(value || 1)) })}
/>
</Field>
</div>
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={manualIPv4s.length > 0}
onChange={() => setForm({ ...form, public_ipv4s: publicIPv4s[0]?.address ? [publicIPv4s[0].address] : [], ipv4_count: 1 })}
/>
Manual select
</label>
{manualIPv4s.length > 0 && (
<div className="grid gap-1.5 sm:grid-cols-2">
{publicIPv4s.map((ip) => (
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded border border-gray-200 px-2 py-1.5 text-xs text-gray-700">
<input
type="checkbox"
checked={manualIPv4s.includes(ip.address)}
onChange={(event) => {
const next = event.target.checked
? [...manualIPv4s, ip.address]
: manualIPv4s.filter((value) => value !== ip.address)
setForm({ ...form, public_ipv4s: next, ipv4_count: Math.max(1, next.length || 1) })
}}
/>
<span className="truncate font-mono">{ip.address}</span>
<span className="shrink-0 text-gray-400">{ip.interface}</span>
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
</label>
))}
</div>
)}
</div>
</div>
)}
</div>
<div className={`rounded-md border px-3 py-2 text-sm ${form.virtualization === 'lxc' && lanInterfaces.length > 0 ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<div className="flex items-start justify-between gap-3">
<label className="flex min-w-0 flex-1 items-start gap-3">
<input
type="checkbox"
checked={lanIPv4Enabled}
disabled={form.virtualization !== 'lxc' || lanInterfaces.length === 0}
onChange={(event) => {
const checked = event.target.checked
setForm({
...form,
lan_ipv4_mode: checked ? 'dhcp' : '',
lan_interface: checked ? (form.lan_interface || defaultLANInterface) : '',
assign_nat: checked ? false : form.assign_nat,
port_mapping_count: checked ? 0 : form.port_mapping_count,
extra_ports: checked ? [] : form.extra_ports,
assign_ipv4: checked ? false : form.assign_ipv4,
public_ipv4s: checked ? [] : form.public_ipv4s,
ipv4_count: checked ? 0 : form.ipv4_count,
})
}}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">局域网 DHCP</span>
<span className="block text-xs text-gray-500">
{lanInterfaces.length > 0 ? 'macvlan 独立局域网 IP' : '未检测到可用上联网卡'}
</span>
</span>
</label>
{lanIPv4Enabled && (
<select
value={form.lan_interface || defaultLANInterface}
onChange={(event) => setForm({ ...form, lan_interface: event.target.value })}
className="h-9 w-32 shrink-0 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-black"
>
{lanInterfaces.map((item) => (
<option key={item.name} value={item.name}>{item.name}</option>
))}
</select>
)}
</div>
{lanIPv4Enabled && (
<div className="mt-3 space-y-3 pl-6">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setForm({ ...form, lan_ipv4_mode: 'dhcp' })}
className={`rounded-md border px-3 py-2 text-xs font-medium ${form.lan_ipv4_mode === 'dhcp' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
DHCP 自动获取
</button>
<button
type="button"
onClick={() => setForm({ ...form, lan_ipv4_mode: 'static' })}
className={`rounded-md border px-3 py-2 text-xs font-medium ${lanStaticEnabled ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
手动配置
</button>
</div>
{lanStaticEnabled && (
<div className="grid gap-3 sm:grid-cols-3">
<Field label="IPv4 地址">
<input
value={form.lan_ipv4_address || ''}
onChange={(event) => setForm({ ...form, lan_ipv4_address: event.target.value })}
className={inputClass}
placeholder="192.168.2.250"
/>
</Field>
<Field label="子网掩码">
<input
value={subnetMaskFromPrefixLen(form.lan_ipv4_prefix_len || 24)}
onChange={(event) => setForm({ ...form, lan_ipv4_prefix_len: prefixLenFromSubnetMask(event.target.value) || 24 })}
className={inputClass}
placeholder="255.255.255.0"
/>
</Field>
<Field label="网关">
<input
value={form.lan_ipv4_gateway || ''}
onChange={(event) => setForm({ ...form, lan_ipv4_gateway: event.target.value })}
className={inputClass}
placeholder="192.168.2.202"
/>
</Field>
</div>
)}
</div>
)}
</div>
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<div className="flex items-start justify-between gap-3">
<label className="flex min-w-0 flex-1 items-start gap-3">
<input
type="checkbox"
checked={!!form.assign_ipv6}
disabled={!ipv6Available}
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
<span className="block text-xs text-gray-500 truncate">
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : formatIPv6StatusReason(ipv6Status?.reason, language, networkText.checkingIPv6Prefix)}
</span>
</span>
</label>
{form.assign_ipv6 && (
<span className="block w-24 shrink-0">
<NumberInput
value={form.ipv6_count || 1}
min={1}
max={64}
onChange={(value) => setForm({ ...form, ipv6_count: Math.max(1, Math.round(value || 1)) })}
/>
</span>
)}
</div>
{form.assign_ipv6 && (
<div className="mt-3 space-y-3 pl-6">
<div className="grid grid-cols-2 gap-3">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={(form.ipv6_addresses || []).length === 0}
onChange={() => setForm({ ...form, ipv6_addresses: [] })}
/>
Random assign
</label>
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={(form.ipv6_addresses || []).length > 0}
onChange={() => setForm({ ...form, ipv6_addresses: [''], ipv6_count: 1 })}
/>
Custom assign
</label>
</div>
{(form.ipv6_addresses || []).length > 0 && (
<textarea
value={(form.ipv6_addresses || []).join('\n')}
onChange={(event) => {
const next = splitAddressLines(event.target.value)
setForm({ ...form, ipv6_addresses: next.length ? next : [''], ipv6_count: Math.max(1, next.length || 1) })
}}
className={`${inputClass} min-h-20 font-mono text-xs`}
placeholder="2001:db8:100::100"
/>
)}
</div>
)}
</div>
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-3">
<label className="flex min-w-0 flex-1 items-start gap-3">
<input
type="checkbox"
checked={natEnabled}
onChange={(event) => {
const checked = event.target.checked
setForm({
...form,
assign_nat: checked,
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
extra_ports: [],
...(checked ? { assign_ipv4: false, public_ipv4s: [], ipv4_count: 0, lan_ipv4_mode: '', lan_interface: '' } : {}),
})
}}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicNAT}</span>
<span className="block text-xs text-gray-500">
{natEnabled ? formatNATPortCount(natPortCount, language) : networkText.noNATPorts}
</span>
</span>
</label>
{natEnabled && customNATPorts.length === 0 && (
<span className="block w-24 shrink-0">
<NumberInput
value={natPortCount}
min={2}
max={64}
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true, extra_ports: [] })}
/>
</span>
)}
</div>
{natEnabled && (
<div className="mt-2 space-y-2 pl-6">
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={customNATPorts.length === 0}
onChange={() => setForm({ ...form, extra_ports: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })}
/>
Auto ports
</label>
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={customNATPorts.length > 0}
onChange={() => {
const next = customNATPorts.length > 0 ? customNATPorts : [22002]
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
}}
/>
Custom ports
</label>
</div>
{customNATPorts.length > 0 && (
<textarea
value={customNATPorts.join('\n')}
onChange={(event) => {
const next = parsePortList(event.target.value)
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
}}
className={`${inputClass} min-h-16 font-mono text-xs`}
placeholder={'22002\n8080\n8443'}
/>
)}
<div className="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">
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
</span>
{natPreviewPorts.map((port, index) => (
<span key={`${port}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{port} -&gt; {port}
</span>
))}
</div>
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Field label="vCPU">
<NumberInput
value={form.vcpu}
min={form.virtualization === 'kvm' ? 1 : 0.25}
max={maxVCPU}
step={form.virtualization === 'kvm' ? 1 : 0.25}
invalid={!!resourceErrors.vcpu}
onChange={(value) => setForm({ ...form, vcpu: value })}
/>
{resourceErrors.vcpu && <p className="mt-1 text-xs text-red-500">{resourceErrors.vcpu}</p>}
</Field>
<Field label="内存 (MB)">
<NumberInput
value={form.ram_mb}
min={128}
max={maxRAMMB}
step={128}
invalid={!!resourceErrors.ram_mb}
onChange={(value) => setForm({ ...form, ram_mb: value })}
/>
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
</Field>
<Field label="磁盘 (GB)">
<NumberInput
value={form.disk_gb}
min={1}
max={maxDiskGB}
invalid={!!resourceErrors.disk_gb}
onChange={(value) => setForm({ ...form, disk_gb: value })}
/>
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
</Field>
<Field label="下行带宽 (Mbps)">
<NumberInput value={form.network_down_mbps} min={0} onChange={(value) => setForm({ ...form, network_down_mbps: value, network_bw_mbps: symmetricLimit(value, form.network_up_mbps) })} />
</Field>
<Field label="上行带宽 (Mbps)">
<NumberInput value={form.network_up_mbps} min={0} onChange={(value) => setForm({ ...form, network_up_mbps: value, network_bw_mbps: symmetricLimit(form.network_down_mbps, value) })} />
</Field>
<Field label="读取 IO (MB/s)">
<NumberInput value={form.io_read_mbps} min={0} onChange={(value) => setForm({ ...form, io_read_mbps: value, io_speed_mbps: symmetricLimit(value, form.io_write_mbps) })} />
</Field>
<Field label="写入 IO (MB/s)">
<NumberInput value={form.io_write_mbps} min={0} onChange={(value) => setForm({ ...form, io_write_mbps: value, io_speed_mbps: symmetricLimit(form.io_read_mbps, value) })} />
</Field>
<div>
<div className="mb-1.5 flex items-center justify-between gap-2">
<label className="text-sm font-medium text-gray-700">月流量</label>
<select
value={form.traffic_mode}
onChange={(e) => setForm({ ...form, traffic_mode: e.target.value })}
className="h-8 px-2 border border-gray-300 rounded text-xs text-gray-600 bg-white"
>
<option value="total">双向统计</option>
<option value="in_out">/出分离</option>
</select>
</div>
{form.traffic_mode === 'total' ? (
<div className="flex items-center gap-2">
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
<span className="shrink-0 text-xs text-gray-400">GB</span>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<Field label="入站 (GB)">
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
</Field>
<Field label="出站 (GB)">
<NumberInput value={form.traffic_out_gb} min={0} onChange={(value) => setForm({ ...form, traffic_out_gb: value || 0 })} />
</Field>
</div>
)}
</div>
<Field label="子用户快照上限">
<NumberInput
value={form.snapshot_limit}
min={1}
max={999}
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
/>
</Field>
<Field label="到期时间">
<div className="relative">
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="date"
value={form.expires_at}
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
min={new Date().toISOString().slice(0, 10)}
className={`${inputClass} pl-10`}
/>
</div>
<p className="mt-1 text-[11px] leading-4 text-gray-400">不选则长期有效</p>
</Field>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md transition-colors">
取消
</button>
<button
onClick={handleSubmit}
disabled={loading || storageLoading || !storageReady}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '创建中...' : '创建容器'}
</button>
</div>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{label}</label>
{children}
</div>
)
}
function NumberInput({
value,
min,
max,
step,
invalid,
onChange,
}: {
value: number
min?: number
max?: number
step?: number
invalid?: boolean
onChange: (value: number) => void
}) {
const [draft, setDraft] = useState(Number.isFinite(value) ? String(value) : '')
const [focused, setFocused] = useState(false)
useEffect(() => {
if (!focused) {
setDraft(Number.isFinite(value) ? String(value) : '')
}
}, [focused, value])
return (
<input
type="text"
inputMode={step && !Number.isInteger(step) ? 'decimal' : 'numeric'}
value={draft}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false)
setDraft(Number.isFinite(value) ? String(value) : '')
}}
onChange={(event) => {
const raw = event.target.value
setDraft(raw)
const next = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
onChange(next)
}}
aria-invalid={invalid || undefined}
data-min={min}
data-max={max}
data-step={step}
className={`${inputClass} ${invalid ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : ''}`}
/>
)
}
function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) {
const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {}
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'
} else if (form.vcpu < minVCPU) {
errors.vcpu = `不能小于 ${minVCPU} 核`
} else if (form.vcpu > maxVCPU) {
errors.vcpu = `不能大于 ${maxVCPU} 核`
} else if (form.virtualization === 'kvm' && form.vcpu !== Math.round(form.vcpu)) {
errors.vcpu = 'KVM vCPU 必须是整数'
}
if (!Number.isFinite(form.ram_mb)) {
errors.ram_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 < minDiskGB) {
errors.disk_gb = `不能小于 ${minDiskGB} GB`
} else if (maxDiskGB && form.disk_gb > maxDiskGB) {
errors.disk_gb = `不能大于 ${maxDiskGB} GB`
}
return errors
}
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
const normalized = applyTemplateDefaults(form)
const wantsLANDHCP = normalized.virtualization === 'lxc' && normalized.lan_ipv4_mode === 'dhcp'
const wantsLANStatic = normalized.virtualization === 'lxc' && normalized.lan_ipv4_mode === 'static'
const wantsLANIPv4 = wantsLANDHCP || wantsLANStatic
const wantsIPv4 = !!normalized.assign_ipv4
const wantsIPv6 = !!normalized.assign_ipv6
// IPv4 and NAT are mutually exclusive
const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
const extraPorts = wantsNAT ? normalizePortList(normalized.extra_ports || []) : []
const portMappingCount = wantsNAT ? clampInt(Math.max(normalized.port_mapping_count || 2, extraPorts.length + 1), 2, 64, 2) : 0
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
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),
assign_nat: wantsNAT,
port_mapping_count: portMappingCount,
extra_ports: extraPorts,
lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''),
lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '',
lan_ipv4_address: wantsLANStatic ? (normalized.lan_ipv4_address || '').trim() : '',
lan_ipv4_prefix_len: wantsLANStatic ? clampInt(normalized.lan_ipv4_prefix_len || 24, 1, 32, 24) : 0,
lan_ipv4_gateway: wantsLANStatic ? (normalized.lan_ipv4_gateway || '').trim() : '',
assign_ipv4: wantsIPv4,
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
assign_ipv6: wantsIPv6,
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []).map((item) => item.trim()).filter(Boolean) : [],
ssh_auth_mode: sshAuthMode,
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
}
}
function validateSSHAuthInputs(form: CreateContainerRequest) {
if (isWindowsTemplate(form.template_id)) return ''
const mode = form.ssh_auth_mode || 'auto_password'
if (mode === 'password') return sshPasswordError((form.ssh_password || '').trim())
if (mode === 'key') return sshPublicKeyError(form.ssh_public_key || '')
if (mode !== 'auto_password') return '请选择登录方式'
return ''
}
function getLANDHCPInterfaces(report: HostProbeReport | null) {
const interfaces = report?.network_interfaces || []
return interfaces.filter((item) => {
const name = item.name || ''
if (!name || name === 'lo') return false
if (name.startsWith('lxc') || name.startsWith('docker') || name.startsWith('br-') || name.startsWith('veth') || name.startsWith('virbr') || name.startsWith('clmv-')) return false
return (item.state || '').toLowerCase() === 'up'
})
}
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
if (!isWindowsTemplate(form.template_id)) return form
return {
...form,
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))
}
function clampInt(value: number, min: number, max?: number, fallback = min) {
const next = Math.round(Number.isFinite(value) ? value : fallback)
return Math.min(Math.max(next, min), max ?? next)
}
function parsePortList(value: string) {
return normalizePortList(
value
.split(/[\s,;]+/)
.map((item) => Number(item.trim()))
)
}
function normalizePortList(ports: number[]) {
const seen = new Set<number>()
const result: number[] = []
for (const port of ports) {
if (!Number.isFinite(port)) continue
const next = Math.round(port)
if (next < 1 || next > 65535 || seen.has(next)) continue
seen.add(next)
result.push(next)
if (result.length >= 63) break
}
return result
}
function isIPv4Address(value: string) {
const parts = value.trim().split('.')
return parts.length === 4 && parts.every((part) => {
if (!/^\d+$/.test(part)) return false
const n = Number(part)
return n >= 0 && n <= 255
})
}
function splitAddressLines(value: string) {
return value
.split(/[\n,\s]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function subnetMaskFromPrefixLen(prefixLen: number) {
if (!Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return '255.255.255.0'
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
return [24, 16, 8, 0].map((shift) => (mask >>> shift) & 255).join('.')
}
function prefixLenFromSubnetMask(mask: string) {
if (!isIPv4Address(mask)) return 0
const bits = mask.split('.').map((part) => Number(part).toString(2).padStart(8, '0')).join('')
if (!/^1*0*$/.test(bits)) return 0
return bits.indexOf('0') === -1 ? 32 : bits.indexOf('0')
}
const createNetworkText = {
zh: {
publicIPv4: '公网 IPv4',
noAllocatableIPv4: '未检测到可分配公网 IPv4',
publicIPv6: '可分配 IPv6 前缀',
use: '使用',
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
publicNAT: '公网 NAT',
noNATPorts: '不分配 NAT 端口',
},
en: {
publicIPv4: 'Public IPv4',
noAllocatableIPv4: 'No allocatable public IPv4 detected',
publicIPv6: 'Allocatable IPv6 Prefix',
use: 'Use',
checkingIPv6Prefix: 'Checking IPv6 prefix...',
publicNAT: 'Public NAT',
noNATPorts: 'No NAT ports will be assigned',
},
} as const
function formatIPv6StatusReason(reason: string | undefined, language: Language, fallback: string) {
if (!reason) return fallback
if (reason.includes('/128 single-address IPv6 is not assignable')) {
return language === 'en'
? 'No allocatable IPv6 prefix. The host only has a /128 single IPv6 address.'
: '未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。'
}
if (reason.includes('outbound IPv6 connectivity test failed')) {
return language === 'en'
? reason
: '宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。'
}
return reason
}
function formatAllocatableIPv4Count(count: number, language: Language) {
return language === 'en'
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
: `检测到 ${count} 个可分配地址`
}
function formatNATPortCount(count: number, language: Language) {
return language === 'en'
? `${count} NAT ports will be assigned`
: `将分配 ${count} 个 NAT 端口`
}
function symmetricLimit(a: number, b: number) {
const left = Math.max(0, Number(a) || 0)
const right = Math.max(0, Number(b) || 0)
if (left === right) return left
if (left === 0) return right
if (right === 0) return left
return Math.min(left, right)
}
const inputClass =
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'