FIX port setting

This commit is contained in:
MengMengCode
2026-07-24 18:51:52 +08:00
parent f2fa2449e9
commit 6c9f24bb24
13 changed files with 892 additions and 189 deletions
+311 -45
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, RefreshCw, X } from 'lucide-react'
import { ArrowRight, CalendarClock, Plus, RefreshCw, Trash2, 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 { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, PortMapping, 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'
@@ -33,6 +33,8 @@ const defaultForm: CreateContainerRequest = {
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: '',
@@ -159,20 +161,28 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
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 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 autoPorts = useMemo(() => {
const autoPortMappings = useMemo(() => {
if (!natEnabled) return []
const count = natPortCount
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
return Array.from({ length: count - 1 }, (_, index) => ({
host_port: 22002 + index,
container_port: 22002 + index,
protocol: 'tcp',
description: `Port-${22002 + index}`,
}))
}, [natEnabled, natPortCount])
const natPreviewPorts = customNATPorts.length > 0 ? customNATPorts : autoPorts
const natPreviewMappings = customNATMappings.length > 0 ? customNATMappings : autoPortMappings
// SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000
const managementPort = Math.round(Number(form.management_port) || 0)
// Automatic allocation starts around 22000; an explicit value is exact.
const sshPortPreview = managementPort || 22000
// Find next available batch index to avoid name conflicts
const batchStartIndex = useMemo(() => {
@@ -230,6 +240,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return
}
const natMappingError = natEnabled
? validateBatchNATPortMappings(customNATMappings, managementPort, batchCount)
: ''
if (natMappingError) {
dialog.alert('NAT 端口配置有误', natMappingError)
return
}
const authError = validateSSHAuthInputs(form)
if (authError) {
dialog.alert('登录方式有误', authError)
@@ -244,15 +262,23 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
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, batchCount)
: { mappings: [], managementPort: 0 }
const natPortMappings = expandedNAT.mappings
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,
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: wantsNAT ? (boundedForm.extra_ports || []) : [],
extra_ports: [],
nat_port_mappings: natPortMappings,
management_port: expandedNAT.managementPort,
})
}
@@ -478,7 +504,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
...form,
assign_ipv4: event.target.checked,
public_ipv4s: event.target.checked ? form.public_ipv4s : [],
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [], lan_ipv4_mode: '', lan_interface: '' } : {}),
...(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"
/>
@@ -560,6 +586,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
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,
@@ -712,6 +740,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
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: '' } : {}),
})
}}
@@ -724,58 +754,184 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</span>
</span>
</label>
{natEnabled && customNATPorts.length === 0 && (
{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: [] })}
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={customNATPorts.length === 0}
onChange={() => setForm({ ...form, extra_ports: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })}
checked={customNATMappings.length === 0}
onChange={() => setForm({ ...form, extra_ports: [], nat_port_mappings: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })}
/>
Auto ports
{language === 'en' ? 'Auto ports' : '自动端口'}
</label>
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={customNATPorts.length > 0}
checked={customNATMappings.length > 0}
onChange={() => {
const next = customNATPorts.length > 0 ? customNATPorts : [22002]
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
const next = customNATMappings.length > 0
? customNATMappings
: [{ host_port: 22002, container_port: 22002, protocol: 'tcp', description: 'Port-22002' }]
setForm({ ...form, extra_ports: [], nat_port_mappings: next, port_mapping_count: next.length + 1, assign_nat: true })
}}
/>
Custom ports
{language === 'en' ? 'Custom mappings' : '自定义映射'}
</label>
</div>
{customNATPorts.length > 0 && (
<textarea
value={customNATPorts.join('\n')}
onChange={(event) => {
const next = parsePortList(event.target.value)
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
}}
className={`${inputClass} min-h-16 font-mono text-xs`}
placeholder={'22002\n8080\n8443'}
/>
{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'
? 'Batch mode shifts the public source-port group for each container; 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>
{natPreviewPorts.map((port, index) => (
<span key={`${port}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{port} -&gt; {port}
{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>
@@ -1001,8 +1157,19 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
const wantsIPv6 = !!normalized.assign_ipv6
// IPv4 and NAT are mutually exclusive
const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
const extraPorts = wantsNAT ? normalizePortList(normalized.extra_ports || []) : []
const portMappingCount = wantsNAT ? clampInt(Math.max(normalized.port_mapping_count || 2, extraPorts.length + 1), 2, 64, 2) : 0
const 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 {
@@ -1012,7 +1179,9 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
disk_gb: Math.round(normalized.disk_gb),
assign_nat: wantsNAT,
port_mapping_count: portMappingCount,
extra_ports: extraPorts,
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() : '',
@@ -1075,14 +1244,6 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
return Math.min(Math.max(next, min), max ?? next)
}
function parsePortList(value: string) {
return normalizePortList(
value
.split(/[\s,;]+/)
.map((item) => Number(item.trim()))
)
}
function normalizePortList(ports: number[]) {
const seen = new Set<number>()
const result: number[] = []
@@ -1097,6 +1258,111 @@ function normalizePortList(ports: number[]) {
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 expandBatchNATConfig(mappings: PortMapping[], managementPort: number, batchIndex: number, batchCount: number) {
const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort), batchCount)
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[], batchCount: number) {
if (mappings.length === 0 || batchCount <= 1) return 1
const invalid = new Set<number>()
for (let left = 0; left < mappings.length; left++) {
for (let right = left + 1; right < mappings.length; right++) {
const leftProtocol = (mappings[left].protocol || 'tcp').toLowerCase()
const rightProtocol = (mappings[right].protocol || 'tcp').toLowerCase()
if (leftProtocol !== rightProtocol) continue
const difference = Math.abs(
Math.round(Number(mappings[left].host_port) || 0)
- Math.round(Number(mappings[right].host_port) || 0)
)
for (let distance = 1; difference > 0 && distance < batchCount; distance++) {
if (difference % distance === 0) invalid.add(difference / distance)
}
}
}
let stride = 1
while (invalid.has(stride)) stride++
return stride
}
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), batchCount)
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) => {
+22 -1
View File
@@ -763,7 +763,16 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
io_speed_mbps: 0,
io_read_mbps: 80,
io_write_mbps: 30,
extra_ports: [8080],
extra_ports: [],
nat_port_mappings: [
{
host_port: 30080,
container_port: 80,
protocol: 'tcp',
description: 'HTTP',
},
],
management_port: 30022,
port_mapping_count: 2,
assign_nat: true,
lan_ipv4_mode: '',
@@ -913,7 +922,16 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
ram_mb: 512,
disk_gb: 10,
assign_nat: true,
management_port: 30022,
port_mapping_count: 2,
nat_port_mappings: [
{
host_port: 30080,
container_port: 80,
protocol: 'tcp',
description: 'HTTP',
},
],
snapshot_limit: 1,
assign_ipv4: false,
ipv4_count: 1,
@@ -1338,6 +1356,8 @@ function endpointNoteFor(key: string) {
const notes: string[] = []
if (key === 'POST /api/v1/containers') {
notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
notes.push('Set management_port to choose the public/source port for SSH (target 22) or Windows RDP (target 3389). Omit it or pass 0 for automatic allocation.')
notes.push('For other custom NAT rules, use nat_port_mappings with host_port (public/source port), container_port (target port), and protocol=tcp|udp. extra_ports remains accepted for compatibility and maps each port to the same port inside the container.')
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
notes.push('storage_pool_id selects an enabled disk for the runtime. For an LXC with an independent LAN address, set lan_ipv4_mode=dhcp or static and set assign_nat=false; static mode also requires lan_ipv4_address, lan_ipv4_prefix_len, and lan_ipv4_gateway.')
notes.push('allowed_image_ids and image_limit_configured define which downloaded images the container owner may use for reinstall. Include the initial template ID when it should remain reinstallable.')
@@ -1347,6 +1367,7 @@ function endpointNoteFor(key: string) {
}
if (key === 'POST /api/v1/batch-create') {
notes.push('Each containers[] item in batch creation supports the same storage, network, image allowlist, and SSH authentication fields as POST /api/v1/containers.')
notes.push('Custom management_port and NAT host_port values must be unique across the batch. The panel shifts each source-port group for later containers while keeping target ports unchanged; direct API clients should submit the expanded values explicitly.')
}
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
+2
View File
@@ -161,6 +161,8 @@ export interface CreateContainerRequest {
io_read_mbps: number
io_write_mbps: number
extra_ports: number[]
nat_port_mappings?: PortMapping[]
management_port?: number
port_mapping_count: number
assign_nat?: boolean
lan_ipv4_mode?: string