import { useEffect, useMemo, useState, type ReactNode } from 'react' import { CalendarClock, X } from 'lucide-react' import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api' import { useDialog } from './Dialog' interface CreateContainerModalProps { isOpen: boolean onClose: () => void onSuccess: (containers: CreateContainerRequest[]) => void | Promise existingNames?: string[] } const defaultForm: CreateContainerRequest = { name: '', virtualization: 'lxc', template_id: '', vcpu: 1, cpu_percent: 100, ram_mb: 512, disk_gb: 10, network_bw_mbps: 0, monthly_traffic_gb: 0, traffic_mode: 'total', traffic_in_gb: 0, traffic_out_gb: 0, io_speed_mbps: 0, extra_ports: [], port_mapping_count: 2, snapshot_limit: 1, assign_ipv6: false, expires_at: '', } export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) { const dialog = useDialog() 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 [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 || '') return applyTemplateDefaults({ ...prev, template_id: templateID }) }) }) .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)) }, [isOpen, form.virtualization]) const ipv6Available = !!ipv6Status?.available const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || '' const maxVCPU = hostInfo?.cpu.cores || 64 const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB) const autoPorts = useMemo(() => { const count = Math.max(2, form.port_mapping_count) return Array.from({ length: count - 1 }, (_, index) => 22002 + index) }, [form.port_mapping_count]) // 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 } const boundedForm = normalizeCreateForm(form) // 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, port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3), extra_ports: [], }) } setLoading(true) try { await batchCreate(containers) await onSuccess(containers) onClose() setBatchCount(1) setForm({ ...defaultForm, template_id: templates[0]?.id || '' }) } 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'}系统镜像,请先在「镜像管理」中下载镜像模板。
) : ( )}
setForm({ ...form, vcpu: value })} /> {resourceErrors.vcpu &&

{resourceErrors.vcpu}

}
setForm({ ...form, ram_mb: value })} /> {resourceErrors.ram_mb &&

{resourceErrors.ram_mb}

}
setForm({ ...form, disk_gb: value })} /> {resourceErrors.disk_gb &&

{resourceErrors.disk_gb}

}
setForm({ ...form, network_bw_mbps: value })} /> setForm({ ...form, io_speed_mbps: value })} />
{/* Traffic control */}
{form.traffic_mode === 'total' ? (
setForm({ ...form, monthly_traffic_gb: value })} /> GB (0=不限制)
) : (
setForm({ ...form, traffic_in_gb: value || 0 })} /> setForm({ ...form, traffic_out_gb: value || 0 })} />
)}
setForm({ ...form, port_mapping_count: Math.max(2, value || 2) })} />
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22} {autoPorts.map((port) => ( {port} -> {port} ))}
setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })} />
setForm({ ...form, expires_at: event.target.value })} min={new Date().toISOString().slice(0, 10)} className={`${inputClass} pl-10`} />

不选择则长期有效;选择日期后,到期会自动关机。

) } function Field({ label, children }: { label: string; children: ReactNode }) { return (
{children}
) } 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 ( 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> = {} 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) 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, 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) } 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'