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 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([]) const [loading, setLoading] = useState(false) const [batchCount, setBatchCount] = useState(1) const [form, setForm] = useState(defaultForm) const [hostInfo, setHostInfo] = useState(null) const [hostReport, setHostReport] = useState(null) const [storageInfo, setStorageInfo] = useState(null) const [storageLoading, setStorageLoading] = useState(true) const [ipv6Status, setIPv6Status] = useState(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 (

创建新容器

handleNameChange(event.target.value)} className={`${inputClass} ${nameError ? 'border-red-400 focus:ring-red-400 focus:border-red-400' : ''}`} placeholder="my-container" required /> {nameError &&

{nameError}

}
setBatchCount(Math.max(1, value || 1))} />
{batchCount > 1 &&

将创建 {batchCount} 个容器:{form.name}-{batchStartIndex} 至 {form.name}-{batchStartIndex + batchCount - 1}

}
{templates.length === 0 ? (
暂无可用的{form.virtualization === 'kvm' ? ' KVM' : ' LXC'}系统镜像,请先在「镜像管理」中下载镜像模板。
) : ( )}
{storageLoading ? (
正在检查存储配置...
) : storagePools.length > 0 ? ( ) : (
尚未开启{form.virtualization === 'kvm' ? ' KVM 磁盘' : ' LXC 容器'}存储,当前无法创建。
)}
{templates.length > 0 && (
默认勾选当前系统;取消后,子用户也不能重装该系统。
{templates.map((template) => { const checked = (form.allowed_image_ids || []).includes(template.id) const current = template.id === form.template_id return ( ) })}
)} {linuxTemplate && (
登录方式
{([ ['auto_password', '自动生成密码'], ['password', '自定义密码'], ['key', 'SSH Key'], ] as Array<[SSHAuthMode, string]>).map(([mode, label]) => ( ))}
{sshAuthMode === 'password' && (
setForm({ ...form, ssh_password: event.target.value })} className={inputClass} placeholder="RootPass123" />
)} {sshAuthMode === 'key' && (