Files
CLICD/frontend/src/components/CreateContainerModal.tsx
T
MengMengCode 38debab1aa Add custom image handling and access policy management
- Implement tests for custom KVM and LXC image creation, ensuring invalid sources and architecture mismatches are rejected.
- Introduce access policy management in CLI, allowing configuration of allowed sources and trusted proxies.
- Add NAT network configuration with validation for RFC1918 compliance and subnet parsing.
- Create panel access policy management, including normalization and evaluation of access decisions based on client IPs and forwarded headers.
- Develop middleware for enforcing access policies in the server, returning appropriate responses for allowed and denied requests.
- Enhance custom image downloading and validation, ensuring integrity and security of downloaded root filesystem archives.
- Include comprehensive tests for all new functionalities to ensure reliability and correctness.
2026-07-26 04:04:45 +08:00

1746 lines
79 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 { 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<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: [],
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<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 [routingInfo, setRoutingInfo] = useState<RoutingInfo | null>(null)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="flex max-h-[92vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<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>
<nav aria-label={t('创建步骤')} className="border-b border-gray-200 px-5 py-3">
<ol className="grid grid-cols-4 gap-2">
{wizardSteps.map((label, index) => {
const completed = index < currentStep
const active = index === currentStep
return (
<li key={label} className="min-w-0">
<button
type="button"
disabled={index > currentStep}
onClick={() => setCurrentStep(index)}
aria-current={active ? 'step' : undefined}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors disabled:cursor-default ${
active ? 'bg-gray-100 text-black' : completed ? 'text-gray-700 hover:bg-gray-50' : 'text-gray-400'
}`}
>
<span className={`inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold ${
active || completed ? 'border-black bg-black text-white' : 'border-gray-300 bg-white'
}`}>
{completed ? <Check className="h-3.5 w-3.5" /> : index + 1}
</span>
<span className="min-w-0 truncate text-xs font-medium sm:text-sm">{label}</span>
</button>
</li>
)
})}
</ol>
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="space-y-3">
{currentStep === 0 && (
<>
<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>
</>
)}
{currentStep === 1 && (
<>
<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>
</>
)}
{currentStep === 0 && (
<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>
)}
{currentStep === 1 && 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>
)}
{currentStep === 0 && 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>
)}
{currentStep === 2 && (
<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: [], nat_port_mappings: [], management_port: 0, 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,
nat_port_mappings: checked ? [] : form.nat_port_mappings,
management_port: checked ? 0 : form.management_port,
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: [],
nat_port_mappings: [],
management_port: checked ? form.management_port : 0,
...(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}
{natEnabled && routingInfo && (
<span className="mt-0.5 block font-mono">
{language === 'en' ? 'Range' : '范围'} {routingInfo.nat4_port_range.start}-{routingInfo.nat4_port_range.end}
{' · '}
{managementPort > 0
? (language === 'en' ? 'management' : '管理端口')
: (language === 'en' ? 'next' : '下一个')}
{' '}{sshPortPreview || '-'}
</span>
)}
</span>
</span>
</label>
{natEnabled && customNATMappings.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: [], nat_port_mappings: [] })}
/>
</span>
)}
</div>
{natEnabled && (
<div className="mt-2 space-y-2 pl-6">
<div className="space-y-1">
<div className="grid grid-cols-[minmax(0,1fr)_20px_minmax(0,1fr)_56px] items-end gap-2">
<label className="min-w-0 text-[11px] text-gray-500">
<span className="mb-1 block">
{language === 'en'
? `${isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'} public source port`
: `${isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'} 公网源端口`}
</span>
<input
type="number"
min={1}
max={65535}
value={managementPort || ''}
onChange={(event) => setForm({
...form,
management_port: Number(event.target.value) || 0,
assign_nat: true,
})}
className={`${inputClass} min-w-0 font-mono text-xs`}
placeholder={language === 'en' ? 'Auto' : '自动'}
/>
</label>
<ArrowRight className="mb-3 h-4 w-4 text-gray-400" />
<label className="min-w-0 text-[11px] text-gray-500">
<span className="mb-1 block">{language === 'en' ? 'Container target port' : '容器目标端口'}</span>
<input
type="number"
readOnly
value={isWindowsTemplate(form.template_id) ? 3389 : 22}
className={`${inputClass} min-w-0 bg-gray-50 font-mono text-xs text-gray-500`}
/>
</label>
<span className="mb-1 inline-flex h-9 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-xs text-gray-600">
TCP
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={customNATMappings.length === 0}
onChange={() => setForm({ ...form, extra_ports: [], nat_port_mappings: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })}
/>
{language === 'en' ? 'Auto ports' : '自动端口'}
</label>
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={customNATMappings.length > 0}
disabled={!routingInfo}
onChange={() => {
const suggestedPort = autoPortMappings[0]?.host_port || natAllocationPreview.managementPort
if (!suggestedPort) return
const next = customNATMappings.length > 0
? customNATMappings
: [{ host_port: suggestedPort, container_port: suggestedPort, protocol: 'tcp', description: `Port-${suggestedPort}` }]
setForm({ ...form, extra_ports: [], nat_port_mappings: next, port_mapping_count: next.length + 1, assign_nat: true })
}}
/>
{language === 'en' ? 'Custom mappings' : '自定义映射'}
</label>
</div>
{customNATMappings.length > 0 && (
<div className="space-y-2">
<div className="grid grid-cols-[minmax(0,1fr)_20px_minmax(0,1fr)_72px_32px] items-center gap-2 px-1 text-[11px] text-gray-500">
<span>{language === 'en' ? 'Public source port' : '源端口(公网)'}</span>
<span />
<span>{language === 'en' ? 'Container target port' : '目标端口(容器)'}</span>
<span>{language === 'en' ? 'Protocol' : '协议'}</span>
<span />
</div>
{customNATMappings.map((mapping, index) => (
<div key={index} className="grid grid-cols-[minmax(0,1fr)_20px_minmax(0,1fr)_72px_32px] items-center gap-2">
<input
type="number"
min={1}
max={65535}
value={mapping.host_port || ''}
onChange={(event) => {
const next = customNATMappings.map((item, itemIndex) => itemIndex === index
? { ...item, host_port: Number(event.target.value) }
: item)
setForm({ ...form, extra_ports: [], nat_port_mappings: next, port_mapping_count: next.length + 1 })
}}
className={`${inputClass} min-w-0 font-mono text-xs`}
placeholder="30080"
/>
<ArrowRight className="h-4 w-4 text-gray-400" />
<input
type="number"
min={1}
max={65535}
value={mapping.container_port || ''}
onChange={(event) => {
const targetPort = Number(event.target.value)
const next = customNATMappings.map((item, itemIndex) => itemIndex === index
? { ...item, container_port: targetPort, description: `Port-${targetPort}` }
: item)
setForm({ ...form, extra_ports: [], nat_port_mappings: next, port_mapping_count: next.length + 1 })
}}
className={`${inputClass} min-w-0 font-mono text-xs`}
placeholder="80"
/>
<select
value={mapping.protocol || 'tcp'}
onChange={(event) => {
const next = customNATMappings.map((item, itemIndex) => itemIndex === index
? { ...item, protocol: event.target.value }
: item)
setForm({ ...form, nat_port_mappings: next })
}}
className="h-10 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700"
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<button
type="button"
disabled={customNATMappings.length <= 1}
onClick={() => {
const next = customNATMappings.filter((_, itemIndex) => itemIndex !== index)
setForm({ ...form, nat_port_mappings: next, port_mapping_count: next.length + 1 })
}}
className="inline-flex h-8 w-8 items-center justify-center rounded text-gray-400 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30"
title={language === 'en' ? 'Remove mapping' : '删除映射'}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
<button
type="button"
disabled={customNATMappings.length >= 63}
onClick={() => {
const previous = customNATMappings[customNATMappings.length - 1]
const hostPort = Math.min(65535, (previous?.host_port || 22001) + 1)
const containerPort = Math.min(65535, (previous?.container_port || 22001) + 1)
const next = [...customNATMappings, {
host_port: hostPort,
container_port: containerPort,
protocol: 'tcp',
description: `Port-${containerPort}`,
}]
setForm({ ...form, nat_port_mappings: next, port_mapping_count: next.length + 1 })
}}
className="inline-flex items-center gap-1 rounded-md border border-gray-300 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
<Plus className="h-3.5 w-3.5" />
{language === 'en' ? 'Add mapping' : '添加映射'}
</button>
{batchCount > 1 && (
<p className="text-[11px] text-gray-500">
{language === 'en'
? 'Each later container starts after the previous highest public port; target ports stay unchanged.'
: '后续容器从上一台的最高公网端口之后开始,容器内部端口保持不变。'}
</p>
)}
</div>
)}
<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}
{managementPort === 0 ? (language === 'en' ? ' (auto)' : '(自动)') : ''}
</span>
{natPreviewMappings.map((mapping, index) => (
<span key={`${mapping.host_port}-${mapping.container_port}-${mapping.protocol}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{mapping.host_port} -&gt; {mapping.container_port}/{mapping.protocol.toUpperCase()}
</span>
))}
</div>
</div>
)}
</div>
</div>
)}
{currentStep === 0 && (
<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>
)}
{currentStep === 3 && (
<div className="space-y-5">
<section>
<h3 className="mb-2 text-sm font-semibold text-gray-900">{t('基础信息')}</h3>
<dl className="grid grid-cols-1 border-y border-gray-200 sm:grid-cols-2 lg:grid-cols-4">
<ReviewItem label={t('容器名称')} value={batchCount > 1 ? `${form.name}-${batchStartIndex}${form.name}-${batchStartIndex + batchCount - 1}` : form.name} />
<ReviewItem label={t('创建数量')} value={String(batchCount)} />
<ReviewItem label={t('虚拟化架构')} value={form.virtualization === 'kvm' ? 'KVM' : 'LXC'} />
<ReviewItem label={t('存储磁盘')} value={selectedStoragePool ? `${selectedStoragePool.name} · ${selectedStoragePool.mount_point || selectedStoragePool.path}` : t('自动选择')} />
<ReviewItem label="vCPU" value={String(form.vcpu)} />
<ReviewItem label={t('内存')} value={`${form.ram_mb} MB`} />
<ReviewItem label={t('磁盘')} value={`${form.disk_gb} GB`} />
<ReviewItem label={t('到期时间')} value={form.expires_at || t('长期有效')} />
</dl>
</section>
<section>
<h3 className="mb-2 text-sm font-semibold text-gray-900">{t('镜像与登录')}</h3>
<dl className="grid grid-cols-1 border-y border-gray-200 sm:grid-cols-3">
<ReviewItem label={t('系统镜像')} value={selectedTemplate?.name || '-'} />
<ReviewItem label={t('登录方式')} value={
!linuxTemplate
? t('镜像默认')
: sshAuthMode === 'key'
? 'SSH Key'
: sshAuthMode === 'password'
? t('自定义密码')
: t('自动生成密码')
} />
<ReviewItem label={t('子用户可用镜像')} value={`${selectedAllowedImages.length} ${t('个')}`} />
</dl>
{selectedAllowedImages.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{selectedAllowedImages.map((template) => (
<span key={template.id} className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700">
{template.name}
</span>
))}
</div>
)}
</section>
<section>
<h3 className="mb-2 text-sm font-semibold text-gray-900">{t('网络配置')}</h3>
<dl className="grid grid-cols-1 border-y border-gray-200 sm:grid-cols-2">
<ReviewItem label={t('主要网络')} value={networkSummary} />
<ReviewItem
label={networkText.publicIPv6}
value={form.assign_ipv6 ? `${form.ipv6_count || 1} ${t('个地址')}` : t('未启用')}
/>
</dl>
{natEnabled && (
<div className="mt-3">
<div className="mb-1.5 text-xs font-medium text-gray-500">{t('端口映射')}</div>
<div className="flex flex-wrap gap-1.5">
<span className="rounded bg-emerald-50 px-2 py-1 font-mono text-xs text-emerald-700">
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview || t('自动')} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}/TCP
</span>
{natPreviewMappings.map((mapping, index) => (
<span key={`${mapping.host_port}-${mapping.container_port}-${mapping.protocol}-${index}`} className="rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-700">
{mapping.host_port || t('自动')} -&gt; {mapping.container_port}/{mapping.protocol.toUpperCase()}
</span>
))}
</div>
</div>
)}
</section>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-gray-200 px-6 py-4">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md transition-colors">
{t('取消')}
</button>
<div className="flex items-center gap-2">
{currentStep > 0 && (
<button
type="button"
onClick={() => setCurrentStep((step) => Math.max(0, step - 1))}
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50"
>
<ArrowLeft className="h-4 w-4" />
{t('上一步')}
</button>
)}
{currentStep < wizardSteps.length - 1 ? (
<button
type="button"
onClick={handleNextStep}
disabled={currentStep === 0 && storageLoading}
className="inline-flex items-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('下一步')}
<ArrowRight className="h-4 w-4" />
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={loading}
className="inline-flex items-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? t('创建中...') : t('确认创建')}
</button>
)}
</div>
</div>
</div>
</div>
)
}
function ReviewItem({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 border-b border-gray-100 px-3 py-2.5 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0">
<dt className="text-xs text-gray-500">{label}</dt>
<dd className="mt-1 break-words text-sm font-medium text-gray-800">{value || '-'}</dd>
</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 windows11 = form.template_id.toLowerCase().includes('windows-11')
const minVCPU = windows ? 2 : (form.virtualization === 'kvm' ? 1 : 0.25)
const minRAMMB = windows11 ? 4096 : windows ? 2048 : 128
const minDiskGB = windows11 ? 64 : 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 legacyMappings = normalizePortList(normalized.extra_ports || []).map((port) => ({
host_port: port,
container_port: port,
protocol: 'tcp',
description: `Port-${port}`,
}))
const natPortMappings = wantsNAT
? normalizeNATPortMappings((normalized.nat_port_mappings?.length ? normalized.nat_port_mappings : legacyMappings))
: []
const managementPort = wantsNAT ? Math.round(Number(normalized.management_port) || 0) : 0
const portMappingCount = wantsNAT
? (natPortMappings.length > 0 ? natPortMappings.length + 1 : clampInt(normalized.port_mapping_count || 2, 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: [],
nat_port_mappings: natPortMappings,
management_port: managementPort,
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 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 normalizeNATPortMappings(mappings: PortMapping[]) {
return mappings.slice(0, 63).map((mapping) => {
const hostPort = Math.round(Number(mapping.host_port) || 0)
const containerPort = Math.round(Number(mapping.container_port) || 0)
const protocol = (mapping.protocol || 'tcp').toLowerCase() === 'udp' ? 'udp' : 'tcp'
return {
host_port: hostPort,
container_port: containerPort,
protocol,
description: mapping.description?.trim() || `Port-${containerPort}`,
}
})
}
function previewNATAllocation(
routing: RoutingInfo | null,
customMappings: PortMapping[],
explicitManagementPort: number,
autoMappingCount: number,
managementTargetPort: number
) {
if (!routing) {
return { managementPort: explicitManagementPort, autoMappings: [] as PortMapping[] }
}
const { start, end } = routing.nat4_port_range
const used = new Set(
(routing.nat4_mappings || [])
.map((mapping) => Math.round(Number(mapping.host_port) || 0))
.filter((port) => port >= start && port <= end)
)
const excluded = new Set(
customMappings
.map((mapping) => Math.round(Number(mapping.host_port) || 0))
.filter((port) => port >= start && port <= end)
)
let managementPort = explicitManagementPort
if (managementPort === 0) {
const cursor = routing.nat4_next_port >= start && routing.nat4_next_port <= end
? routing.nat4_next_port
: start
managementPort = findAvailableNATPort(start, end, cursor, new Set([...used, ...excluded]))
}
const autoMappings: PortMapping[] = []
if (customMappings.length === 0 && autoMappingCount > 0) {
const unavailable = new Set(used)
if (managementPort > 0) unavailable.add(managementPort)
if (managementTargetPort >= start && managementTargetPort <= end) unavailable.add(managementTargetPort)
for (let port = start; port <= end && autoMappings.length < autoMappingCount; port++) {
if (unavailable.has(port)) continue
unavailable.add(port)
autoMappings.push({
host_port: port,
container_port: port,
protocol: 'tcp',
description: `Port-${port}`,
})
}
}
return { managementPort, autoMappings }
}
function findAvailableNATPort(start: number, end: number, cursor: number, unavailable: Set<number>) {
const capacity = end - start + 1
for (let offset = 0; offset < capacity; offset++) {
const candidate = start + ((cursor - start + offset) % capacity)
if (!unavailable.has(candidate)) return candidate
}
return 0
}
function expandBatchNATConfig(mappings: PortMapping[], managementPort: number, batchIndex: number) {
const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort))
const offset = batchIndex * stride
return {
mappings: mappings.map((mapping) => ({
...mapping,
host_port: mapping.host_port + offset,
})),
managementPort: managementPort > 0 ? managementPort + offset : 0,
}
}
function batchNATSourceMappings(mappings: PortMapping[], managementPort: number) {
if (managementPort <= 0) return mappings
return [
{
host_port: managementPort,
container_port: 0,
protocol: 'tcp',
description: 'Management',
},
...mappings,
]
}
function batchNATPortStride(mappings: PortMapping[]) {
const sourcePorts = mappings
.map((mapping) => Math.round(Number(mapping.host_port) || 0))
.filter((port) => port > 0)
if (sourcePorts.length === 0) return 1
return Math.max(...sourcePorts) - Math.min(...sourcePorts) + 1
}
function validateBatchNATPortMappings(mappings: PortMapping[], managementPort: number, batchCount: number) {
if (mappings.length === 0 && managementPort === 0) return ''
if (managementPort < 0 || managementPort > 65535) {
return 'SSH/RDP 公网源端口必须在 1-65535 之间,留空则自动分配'
}
if (mappings.length > 63) return '每个容器最多可配置 63 条自定义 NAT 映射'
const used = new Map<string, string>()
const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort))
for (let batchIndex = 0; batchIndex < batchCount; batchIndex++) {
if (managementPort > 0) {
const expandedManagementPort = managementPort + batchIndex * stride
if (expandedManagementPort > 65535) {
return '批量展开后的 SSH/RDP 公网源端口超出 1-65535'
}
const managementKey = `${expandedManagementPort}/tcp`
const managementOwner = used.get(managementKey)
if (managementOwner) {
return `批量端口冲突:${managementKey} 同时被 ${managementOwner} 和第 ${batchIndex + 1} 台容器的管理端口使用`
}
used.set(managementKey, `第 ${batchIndex + 1} 台容器的管理端口`)
}
for (let mappingIndex = 0; mappingIndex < mappings.length; mappingIndex++) {
const mapping = mappings[mappingIndex]
const hostPort = Math.round(Number(mapping.host_port) || 0) + batchIndex * stride
const containerPort = Math.round(Number(mapping.container_port) || 0)
const protocol = (mapping.protocol || 'tcp').toLowerCase() === 'udp' ? 'udp' : 'tcp'
if (hostPort < 1 || hostPort > 65535) {
return `第 ${mappingIndex + 1} 条映射展开后的公网端口超出 1-65535`
}
if (containerPort < 1 || containerPort > 65535) {
return `第 ${mappingIndex + 1} 条映射的容器端口必须在 1-65535 之间`
}
const key = `${hostPort}/${protocol}`
const owner = used.get(key)
if (owner) {
return `批量端口冲突:${key} 同时被 ${owner} 和第 ${batchIndex + 1} 台容器使用`
}
used.set(key, `第 ${batchIndex + 1} 台容器`)
}
}
return ''
}
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'