import { useEffect, useMemo, useState, type ReactNode } from 'react' import { ArrowLeft, ArrowRight, CalendarClock, Check, Plus, RefreshCw, Trash2, X } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getRoutingInfo, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, PortMapping, RoutingInfo, 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: [], nat_port_mappings: [], management_port: 0, 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, t } = useLanguage() const networkText = createNetworkText[language] const wizardSteps = [t('基础信息'), t('镜像选择'), t('网络配置'), t('预览清单')] const [currentStep, setCurrentStep] = useState(0) 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 [routingInfo, setRoutingInfo] = useState(null) const [ipv6Status, setIPv6Status] = useState(null) const [nameError, setNameError] = useState('') useEffect(() => { if (isOpen) setCurrentStep(0) }, [isOpen]) 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]) useEffect(() => { if (!isOpen) return let active = true getRoutingInfo() .then((res) => { if (active) setRoutingInfo(res.data.data || null) }) .catch(() => { if (active) setRoutingInfo(null) }) 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 customNATMappings = form.nat_port_mappings || [] const natPortCount = natEnabled ? (customNATMappings.length > 0 ? customNATMappings.length + 1 : Math.max(2, form.port_mapping_count || 2)) : 0 const linuxTemplate = !isWindowsTemplate(form.template_id) const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode const managementPort = Math.round(Number(form.management_port) || 0) const natAllocationPreview = useMemo( () => previewNATAllocation( routingInfo, customNATMappings, managementPort, natEnabled ? natPortCount - 1 : 0, isWindowsTemplate(form.template_id) ? 3389 : 22 ), [routingInfo, customNATMappings, managementPort, natEnabled, natPortCount, form.template_id] ) const autoPortMappings = natAllocationPreview.autoMappings const natPreviewMappings = customNATMappings.length > 0 ? customNATMappings : autoPortMappings const sshPortPreview = managementPort || natAllocationPreview.managementPort const selectedTemplate = templates.find((template) => template.id === form.template_id) const selectedStoragePool = storagePools.find((pool) => pool.id === form.storage_pool_id) const selectedAllowedImages = templates.filter((template) => (form.allowed_image_ids || []).includes(template.id)) const networkSummary = form.assign_ipv4 ? (manualIPv4s.length > 0 ? `${networkText.publicIPv4}: ${manualIPv4s.join(', ')}` : `${networkText.publicIPv4}: ${t('自动分配')} × ${form.ipv4_count || 1}`) : lanIPv4Enabled ? `${t('局域网')}: ${lanStaticEnabled ? `${form.lan_ipv4_address}/${form.lan_ipv4_prefix_len}` : 'DHCP'}` : natEnabled ? `${networkText.publicNAT}: ${natPortCount} ${t('个端口')}` : form.assign_ipv6 ? `${networkText.publicIPv6}: ${form.ipv6_count || 1}` : t('未配置网络') // 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 validateStep = (step: number) => { if (step === 0) { if (!form.name.trim() || nameError) { dialog.alert(t('基础信息有误'), t('请填写有效且未被占用的容器名称')) return false } if (Object.keys(resourceErrors).length > 0) { dialog.alert(t('资源配置有误'), t('请按红色提示修改 vCPU、内存或磁盘配置')) return false } if (!storageReady) { dialog.alert(t('未配置存储'), `${t('请先在存储管理中为')} ${form.virtualization === 'kvm' ? t('KVM 磁盘') : t('LXC 容器')} ${t('开启至少一块存储磁盘')}`) return false } const authError = validateSSHAuthInputs(form) if (authError) { dialog.alert(t('登录方式有误'), authError) return false } } if (step === 1 && !form.template_id) { dialog.alert(t('请选择镜像'), t('请选择用于创建容器的系统镜像')) return false } if (step === 2) { if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false && form.lan_ipv4_mode !== 'dhcp' && form.lan_ipv4_mode !== 'static') { dialog.alert(t('网络配置有误'), t('请至少启用一种网络连接方式')) return false } if (form.lan_ipv4_mode === 'static' && (!isIPv4Address(form.lan_ipv4_address || '') || !isIPv4Address(form.lan_ipv4_gateway || '') || !form.lan_ipv4_prefix_len)) { dialog.alert(t('局域网 IPv4 配置有误'), t('请填写有效的 IPv4 地址、子网掩码和网关')) return false } const natMappingError = natEnabled ? validateBatchNATPortMappings(customNATMappings, managementPort, batchCount) : '' if (natMappingError) { dialog.alert(t('NAT 端口配置有误'), natMappingError) return false } } return true } const handleNextStep = () => { if (!validateStep(currentStep)) return setCurrentStep((step) => Math.min(wizardSteps.length - 1, step + 1)) } 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 natMappingError = natEnabled ? validateBatchNATPortMappings(customNATMappings, managementPort, batchCount) : '' if (natMappingError) { dialog.alert('NAT 端口配置有误', natMappingError) 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 const expandedNAT = wantsNAT ? expandBatchNATConfig(boundedForm.nat_port_mappings || [], boundedForm.management_port || 0, i) : { mappings: [], managementPort: 0 } const natPortMappings = expandedNAT.mappings containers.push({ ...boundedForm, name, assign_nat: wantsNAT, port_mapping_count: wantsNAT ? (natPortMappings.length > 0 ? natPortMappings.length + 1 : Math.max(2, boundedForm.port_mapping_count || 2)) : 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: [], nat_port_mappings: natPortMappings, management_port: expandedNAT.managementPort, }) } 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 (

创建新容器

{currentStep === 0 && ( <>
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}

}
)} {currentStep === 1 && ( <> {templates.length === 0 ? (
暂无可用的{form.virtualization === 'kvm' ? ' KVM' : ' LXC'}系统镜像,请先在「镜像管理」中下载镜像模板。
) : ( )}
)} {currentStep === 0 && ( {storageLoading ? (
正在检查存储配置...
) : storagePools.length > 0 ? ( ) : (
尚未开启{form.virtualization === 'kvm' ? ' KVM 磁盘' : ' LXC 容器'}存储,当前无法创建。
)}
)} {currentStep === 1 && templates.length > 0 && (
默认勾选当前系统;取消后,子用户也不能重装该系统。
{templates.map((template) => { const checked = (form.allowed_image_ids || []).includes(template.id) const current = template.id === form.template_id return ( ) })}
)} {currentStep === 0 && 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' && (