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.
This commit is contained in:
MengMengCode
2026-07-26 04:04:45 +08:00
parent 8283b88ded
commit 38debab1aa
49 changed files with 4504 additions and 246 deletions
+353 -61
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { ArrowRight, CalendarClock, Plus, RefreshCw, Trash2, X } from 'lucide-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, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, PortMapping, StorageInfo, Template } from '../services/api'
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'
@@ -60,8 +60,10 @@ const defaultForm: CreateContainerRequest = {
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const navigate = useNavigate()
const dialog = useDialog()
const { language } = useLanguage()
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)
@@ -70,9 +72,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
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
@@ -134,6 +141,19 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
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 || '')
@@ -167,22 +187,34 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
: 0
const linuxTemplate = !isWindowsTemplate(form.template_id)
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
const autoPortMappings = useMemo(() => {
if (!natEnabled) return []
const count = natPortCount
return Array.from({ length: count - 1 }, (_, index) => ({
host_port: 22002 + index,
container_port: 22002 + index,
protocol: 'tcp',
description: `Port-${22002 + index}`,
}))
}, [natEnabled, natPortCount])
const natPreviewMappings = customNATMappings.length > 0 ? customNATMappings : autoPortMappings
const managementPort = Math.round(Number(form.management_port) || 0)
// Automatic allocation starts around 22000; an explicit value is exact.
const sshPortPreview = managementPort || 22000
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(() => {
@@ -212,6 +244,57 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
}
}
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('提示', '请填写容器名称并选择系统模板')
@@ -263,7 +346,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
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)
? expandBatchNATConfig(boundedForm.nat_port_mappings || [], boundedForm.management_port || 0, i)
: { mappings: [], managementPort: 0 }
const natPortMappings = expandedNAT.mappings
containers.push({
@@ -301,7 +384,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-3xl max-h-[92vh] overflow-y-auto">
<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="关闭">
@@ -309,7 +392,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</button>
</div>
<div className="px-5 py-4 space-y-3">
<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
@@ -352,7 +467,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</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">
@@ -378,7 +497,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
)}
</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">
@@ -411,8 +533,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</div>
)}
</Field>
)}
{templates.length > 0 && (
{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>
@@ -444,7 +567,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field>
)}
{linuxTemplate && (
{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">
@@ -493,6 +616,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</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">
@@ -751,6 +875,16 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<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>
@@ -817,10 +951,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<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: 22002, container_port: 22002, protocol: 'tcp', description: 'Port-22002' }]
: [{ 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 })
}}
/>
@@ -918,15 +1055,15 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
{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.'
: '批量创建时,每台容器使用不重叠的公网端口,容器目标端口保持不变。'}
? '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}
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview || '--'} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
{managementPort === 0 ? (language === 'en' ? ' (auto)' : '(自动)') : ''}
</span>
{natPreviewMappings.map((mapping, index) => (
@@ -939,7 +1076,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
)}
</div>
</div>
)}
{currentStep === 0 && (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Field label="vCPU">
<NumberInput
@@ -1035,25 +1174,131 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<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-end gap-3 px-6 py-4 border-t border-gray-200">
<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">
</button>
<button
onClick={handleSubmit}
disabled={loading || storageLoading || !storageReady}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '创建中...' : '创建容器'}
{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>
@@ -1115,9 +1360,10 @@ function NumberInput({
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 = windows ? 2048 : 128
const minDiskGB = windows ? 30 : 1
const minRAMMB = windows11 ? 4096 : windows ? 2048 : 128
const minDiskGB = windows11 ? 64 : windows ? 30 : 1
if (!Number.isFinite(form.vcpu)) {
errors.vcpu = '请输入 vCPU'
@@ -1272,8 +1518,68 @@ function normalizeNATPortMappings(mappings: PortMapping[]) {
})
}
function expandBatchNATConfig(mappings: PortMapping[], managementPort: number, batchIndex: number, batchCount: number) {
const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort), batchCount)
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) => ({
@@ -1297,26 +1603,12 @@ function batchNATSourceMappings(mappings: PortMapping[], managementPort: number)
]
}
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 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) {
@@ -1327,7 +1619,7 @@ function validateBatchNATPortMappings(mappings: PortMapping[], managementPort: n
if (mappings.length > 63) return '每个容器最多可配置 63 条自定义 NAT 映射'
const used = new Map<string, string>()
const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort), batchCount)
const stride = batchNATPortStride(batchNATSourceMappings(mappings, managementPort))
for (let batchIndex = 0; batchIndex < batchCount; batchIndex++) {
if (managementPort > 0) {
const expandedManagementPort = managementPort + batchIndex * stride
+1 -1
View File
@@ -12,7 +12,7 @@ export default function Layout() {
<AutoTranslate />
<BrowserDialogTranslator />
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
<main className={`min-w-0 flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
<div className="p-6">
<Outlet />
</div>
+3
View File
@@ -17,6 +17,7 @@ body {
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
@@ -199,4 +200,6 @@ body {
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
.dark .peer-checked\:bg-black:checked + *,
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
.dark .access-policy-switch .access-policy-switch-thumb { background-color: #e5e7eb !important; }
.dark .access-policy-switch[aria-checked="true"] .access-policy-switch-thumb { background-color: #111827 !important; }
+44 -2
View File
@@ -199,6 +199,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
['GET', '/api/v1/templates', '模板列表'],
['GET', '/api/v1/images', '镜像管理列表'],
['GET', '/api/v1/images/enabled?type=lxc&container={id}', '可用于创建或重装的已启用镜像'],
['POST', '/api/v1/images/custom', '添加第三方 LXC/KVM 镜像源'],
['DELETE', '/api/v1/images/custom', '移除第三方 LXC/KVM 镜像源'],
['POST', '/api/v1/images/download', '下载镜像'],
['POST', '/api/v1/images/cancel', '取消镜像下载'],
['DELETE', '/api/v1/images/delete', '删除镜像缓存'],
@@ -228,6 +230,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
['PUT', '/api/v1/ssl', '更新 SSL 配置'],
['GET', '/api/v1/webssh-origins', 'WebSSH/VNC Origin 白名单'],
['PUT', '/api/v1/webssh-origins', '更新 WebSSH/VNC Origin 白名单'],
['GET', '/api/v1/access-policy', '面板访问来源策略'],
['PUT', '/api/v1/access-policy', '更新面板访问来源策略'],
['GET', '/api/v1/language', '面板语言'],
['PUT', '/api/v1/language', '更新面板语言'],
],
@@ -845,6 +849,18 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
time: '03:00',
},
'PUT /api/v1/containers/{id}/snapshots/quota': { snapshot_limit: 2 },
'POST /api/v1/images/custom': {
type: 'kvm',
name: 'Custom Ubuntu Cloud',
description: 'Private mirror image',
distro: 'ubuntu',
release: 'noble',
arch: 'amd64',
url: 'https://images.example.com/ubuntu-noble.qcow2',
provisioner: 'linux-cloud-init',
sha256: '',
},
'DELETE /api/v1/images/custom': { id: 'custom-kvm-a1b2c3d4e5' },
'POST /api/v1/images/download': { template_id: 'debian-bookworm' },
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
@@ -873,6 +889,11 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
'PUT /api/v1/webssh-origins': {
origins: ['https://panel.example.com'],
},
'PUT /api/v1/access-policy': {
enabled: true,
allowed_sources: ['203.0.113.10', '192.168.1.0/24', '2001:db8::/32'],
trusted_proxies: ['127.0.0.1'],
},
'PUT /api/v1/language': { language: 'zh' },
'PUT /api/v1/routing': {
items: [
@@ -1027,6 +1048,11 @@ const responseSamples: Record<string, unknown> = {
data: {
nat4: { used: 62, remaining: '45474', total: '45536' },
nat4_port_range: { start: 20000, end: 65535 },
nat4_next_port: 22005,
nat4_networks: {
lxc: { subnet: '10.0.3.0/24', gateway: '10.0.3.1', netmask: '255.255.255.0', dhcp_start: '10.0.3.2', dhcp_end: '10.0.3.254', dhcp_max: 253, prefix_bits: 24 },
kvm: { subnet: '192.168.122.0/24', gateway: '192.168.122.1', netmask: '255.255.255.0', dhcp_start: '192.168.122.2', dhcp_end: '192.168.122.254', dhcp_max: 253, prefix_bits: 24 },
},
ipv4: { used: 1, remaining: '3', total: '4' },
ipv6: { used: 31, remaining: 'large', total: 'large' },
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
@@ -1042,6 +1068,11 @@ const responseSamples: Record<string, unknown> = {
data: {
nat4: { used: 62, remaining: '45474', total: '45536' },
nat4_port_range: { start: 20000, end: 65535 },
nat4_next_port: 22005,
nat4_networks: {
lxc: { subnet: '10.0.3.0/24', gateway: '10.0.3.1' },
kvm: { subnet: '192.168.122.0/24', gateway: '192.168.122.1' },
},
ipv4: { used: 1, remaining: '3', total: '4' },
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
ipv6_prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
@@ -1234,6 +1265,12 @@ const responseSamples: Record<string, unknown> = {
{ id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', type: 'lxc', downloaded: true, enabled: true },
],
},
'POST /api/v1/images/custom': {
success: true,
message: 'Custom image added',
data: { id: 'custom-kvm-a1b2c3d4e5', name: 'Custom Ubuntu Cloud' },
},
'DELETE /api/v1/images/custom': { success: true, message: 'Custom image removed' },
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
@@ -1277,6 +1314,8 @@ const responseSamples: Record<string, unknown> = {
'PUT /api/v1/ssl': { success: true, message: 'SSL settings saved', data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', needs_restart: true } },
'GET /api/v1/webssh-origins': { success: true, data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
'PUT /api/v1/webssh-origins': { success: true, message: 'Origin allowlist saved', data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
'GET /api/v1/access-policy': { success: true, data: { enabled: true, allowed_sources: ['203.0.113.10', '192.168.1.0/24'], trusted_proxies: ['127.0.0.1'], current_source: '203.0.113.10', direct_source: '127.0.0.1', using_forwarded: true } },
'PUT /api/v1/access-policy': { success: true, message: 'Panel access policy saved', data: { enabled: true, allowed_sources: ['203.0.113.10', '192.168.1.0/24'], trusted_proxies: ['127.0.0.1'], current_source: '203.0.113.10', direct_source: '127.0.0.1', using_forwarded: true } },
'GET /api/v1/language': { success: true, data: { language: 'zh' } },
'PUT /api/v1/language': { success: true, data: { language: 'zh' } },
'GET /api/v1/security/alerts': { success: true, data: [] },
@@ -1367,7 +1406,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.')
notes.push('Custom management_port and NAT host_port values must be unique across the batch. The panel places each later source-port group after the previous container\'s highest public port while keeping every target container_port 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.')
@@ -1399,9 +1438,12 @@ function endpointNoteFor(key: string) {
if (key === 'PUT /api/v1/storage') {
notes.push('Start from GET /api/v1/storage and submit mounted disks returned by the server. Paths and mount points are server-managed and custom paths are rejected. content_types enables a disk for each workload; only one pool may be the default for each type.')
}
if (key.includes('/api/v1/storage') || key.includes('/task-queue/settings') || key.includes('/api/v1/ssl') || key.includes('/webssh-origins')) {
if (key.includes('/api/v1/storage') || key.includes('/task-queue/settings') || key.includes('/api/v1/ssl') || key.includes('/webssh-origins') || key.includes('/access-policy')) {
notes.push('This endpoint requires an API key with admin:access.')
}
if (key === 'PUT /api/v1/access-policy') {
notes.push('allowed_sources and trusted_proxies accept IPv4, IPv6, or CIDR values. Forwarded client headers are ignored unless the direct peer matches trusted_proxies. The server rejects an enabled policy that excludes the current source.')
}
if (key === 'PUT /api/v1/task-queue/settings') {
notes.push('concurrency must be between 1 and 16. Tasks targeting the same container are still serialized.')
}
+1
View File
@@ -991,6 +991,7 @@ function getTemplateName(id: string) {
'kvm-debian-bookworm': 'Debian 12',
'kvm-debian-bullseye': 'Debian 11',
'kvm-rockylinux-9': 'Rocky 9',
'kvm-windows-11': 'Windows 11',
'kvm-windows-10': 'Windows 10',
}
return map[id] || id
+285 -9
View File
@@ -11,12 +11,29 @@ import {
Loader2,
AlertCircle,
X,
Plus,
Unlink,
CloudDownload,
} from 'lucide-react'
import { getImages, getStorageInfo, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo, StorageInfo } from '../services/api'
import {
getImages,
getStorageInfo,
downloadImage,
cancelImageDownload,
deleteImage,
toggleImage,
createCustomKVMImage,
removeCustomKVMImage,
ImageInfo,
StorageInfo,
CustomKVMImageInput,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useLanguage } from '../contexts/LanguageContext'
export default function ImageManagement() {
const dialog = useDialog()
const { t } = useLanguage()
const navigate = useNavigate()
const [images, setImages] = useState<ImageInfo[]>([])
const [loading, setLoading] = useState(true)
@@ -24,6 +41,7 @@ export default function ImageManagement() {
const [error, setError] = useState('')
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
const [storageLoading, setStorageLoading] = useState(true)
const [customModalOpen, setCustomModalOpen] = useState<'lxc' | 'kvm' | null>(null)
const fetchImages = useCallback(async () => {
try {
@@ -115,6 +133,34 @@ export default function ImageManagement() {
}
}
const handleRemoveCustom = async (templateId: string) => {
if (!(await dialog.confirm('移除第三方镜像', '确定移除该镜像源和已下载的缓存吗?正在使用该镜像的虚拟机不会允许移除。'))) return
setActionLoading(templateId)
setError('')
try {
await removeCustomKVMImage(templateId)
await fetchImages()
dialog.alert('完成', '第三方镜像已移除')
} catch (err: unknown) {
dialog.alert('失败', apiErrorMessage(err, '移除第三方镜像失败'))
} finally {
setActionLoading(null)
}
}
const handleCustomCreated = async (payload: CustomKVMImageInput) => {
const response = await createCustomKVMImage(payload)
const image = response.data.data
if (!image) throw new Error('镜像源保存成功,但服务器没有返回镜像 ID')
try {
await downloadImage(image.id)
dialog.alert('完成', '第三方镜像已添加,下载任务已启动')
} catch (err: unknown) {
dialog.alert('提示', `镜像源已保存,但下载未启动:${apiErrorMessage(err, '请在列表中重试')}`)
}
await fetchImages()
}
const downloadedCount = images.filter((img) => img.downloaded).length
const lxcImages = images.filter((img) => img.type === 'lxc')
const kvmImages = images.filter((img) => img.type === 'kvm')
@@ -185,8 +231,21 @@ export default function ImageManagement() {
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
onRemoveCustom={handleRemoveCustom}
storageReady={imageStorageReady}
storageLoading={storageLoading}
headerAction={(
<button
type="button"
onClick={() => setCustomModalOpen('lxc')}
disabled={storageLoading || !imageStorageReady}
title={storageLoading ? t('正在检查存储配置...') : imageStorageReady ? t('下载第三方 LXC 镜像') : t('请先在存储管理中开启镜像缓存存储')}
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-200"
>
<Plus className="h-3.5 w-3.5" />
{t('第三方镜像')}
</button>
)}
/>
{kvmImages.length > 0 && (
@@ -200,10 +259,206 @@ export default function ImageManagement() {
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
onRemoveCustom={handleRemoveCustom}
storageReady={imageStorageReady}
storageLoading={storageLoading}
headerAction={(
<button
type="button"
onClick={() => setCustomModalOpen('kvm')}
disabled={storageLoading || !imageStorageReady}
title={storageLoading ? t('正在检查存储配置...') : imageStorageReady ? t('下载第三方 KVM 镜像') : t('请先在存储管理中开启镜像缓存存储')}
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-200"
>
<Plus className="h-3.5 w-3.5" />
{t('第三方镜像')}
</button>
)}
/>
)}
{customModalOpen !== null && (
<CustomKVMImageModal
virtualization={customModalOpen}
arch={(customModalOpen === 'lxc' ? lxcImages[0]?.arch : kvmImages[0]?.arch) || 'amd64'}
onClose={() => setCustomModalOpen(null)}
onSubmit={handleCustomCreated}
/>
)}
</div>
)
}
const emptyCustomImage = (arch: string, virtualization: 'lxc' | 'kvm'): CustomKVMImageInput => ({
type: virtualization,
name: '',
description: '',
distro: '',
release: '',
arch,
url: '',
provisioner: virtualization === 'lxc' ? 'lxc-rootfs' : 'linux-cloud-init',
sha256: '',
})
function CustomKVMImageModal({
virtualization,
arch,
onClose,
onSubmit,
}: {
virtualization: 'lxc' | 'kvm'
arch: string
onClose: () => void
onSubmit: (payload: CustomKVMImageInput) => Promise<void>
}) {
const { t } = useLanguage()
const [form, setForm] = useState<CustomKVMImageInput>(() => emptyCustomImage(arch, virtualization))
const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState('')
const windows = virtualization === 'kvm' && form.provisioner !== 'linux-cloud-init'
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !submitting) onClose()
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [onClose, submitting])
const updateProvisioner = (provisioner: 'linux-cloud-init' | 'windows-10' | 'windows-11') => {
setForm((current) => ({
...current,
provisioner,
distro: provisioner === 'linux-cloud-init' ? (current.distro === 'windows' ? '' : current.distro) : 'windows',
release: provisioner === 'windows-10' ? '10' : provisioner === 'windows-11' ? '11' : (current.distro === 'windows' ? '' : current.release),
}))
}
const submit = async () => {
if (!form.name.trim() || !form.distro.trim() || !form.release.trim() || !form.url.trim()) {
setFormError(t('请填写名称、发行版、版本和下载地址'))
return
}
if (form.sha256 && !/^[a-fA-F0-9]{64}$/.test(form.sha256.trim())) {
setFormError(t('SHA-256 必须是 64 位十六进制字符串'))
return
}
setSubmitting(true)
setFormError('')
try {
await onSubmit({
...form,
name: form.name.trim(),
description: form.description.trim(),
distro: form.distro.trim().toLowerCase(),
release: form.release.trim().toLowerCase(),
url: form.url.trim(),
sha256: form.sha256?.trim().toLowerCase(),
})
onClose()
} catch (err: unknown) {
setFormError(apiErrorMessage(err, t('添加第三方镜像失败')))
} finally {
setSubmitting(false)
}
}
const inputClass = 'mt-1.5 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black outline-none focus:border-black focus:ring-2 focus:ring-black/10 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:focus:border-white dark:focus:ring-white/10'
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/55 p-4 dark:bg-black/75">
<div className="w-full max-w-2xl overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">{t(virtualization === 'lxc' ? '下载第三方 LXC 镜像' : '下载第三方 KVM 镜像')}</h3>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{t(virtualization === 'lxc' ? '支持 tar、tar.gz、tar.xz、tar.zst 格式的 Linux rootfs' : '镜像格式必须与所选无人值守安装模板匹配')}
</p>
</div>
<button type="button" onClick={onClose} disabled={submitting} className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-black disabled:opacity-50 dark:hover:bg-gray-800 dark:hover:text-white" title={t('关闭')}>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[72vh] space-y-5 overflow-y-auto px-5 py-4">
{virtualization === 'kvm' && <div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-200">{t('无人值守安装模板')}</label>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-3">
{([
['linux-cloud-init', 'Linux cloud-init', 'QCOW2 / IMG'],
['windows-10', 'Windows 10', '安装 ISO'],
['windows-11', 'Windows 11', '安装 ISO'],
] as const).map(([value, label, hint]) => (
<button
key={value}
type="button"
onClick={() => updateProvisioner(value)}
disabled={arch !== 'amd64' && value !== 'linux-cloud-init'}
className={`rounded-md border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
form.provisioner === value
? 'border-black bg-gray-50 dark:border-white dark:bg-gray-800'
: 'border-gray-200 hover:border-gray-400 dark:border-gray-700 dark:hover:border-gray-500'
}`}
>
<span className="block text-sm font-medium text-gray-900 dark:text-white">{label}</span>
<span className="mt-0.5 block text-xs text-gray-500 dark:text-gray-400">{t(hint)}</span>
</button>
))}
</div>
</div>}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="text-sm text-gray-700 dark:text-gray-200">
{t('镜像名称')}
<input className={inputClass} value={form.name} maxLength={100} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={virtualization === 'lxc' ? 'Alpine Custom Rootfs' : windows ? 'Windows 11 Custom' : 'Ubuntu Custom Cloud'} />
</label>
<label className="text-sm text-gray-700 dark:text-gray-200">
{t('架构')}
<select className={inputClass} value={form.arch} disabled>
<option value={arch}>{arch}</option>
</select>
</label>
<label className="text-sm text-gray-700 dark:text-gray-200">
{t('发行版')}
<input className={inputClass} value={form.distro} disabled={windows} maxLength={64} onChange={(event) => setForm({ ...form, distro: event.target.value })} placeholder="ubuntu" />
</label>
<label className="text-sm text-gray-700 dark:text-gray-200">
{t('版本 / 代号')}
<input className={inputClass} value={form.release} disabled={windows} maxLength={64} onChange={(event) => setForm({ ...form, release: event.target.value })} placeholder="noble" />
</label>
</div>
<label className="block text-sm text-gray-700 dark:text-gray-200">
{t('备注')}
<textarea className={`${inputClass} min-h-20 resize-y`} value={form.description} maxLength={500} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder={t('镜像来源、版本或用途')} />
</label>
<label className="block text-sm text-gray-700 dark:text-gray-200">
{t('镜像下载地址')}
<input className={`${inputClass} font-mono text-xs`} value={form.url} onChange={(event) => setForm({ ...form, url: event.target.value })} placeholder={virtualization === 'lxc' ? 'https://example.com/rootfs.tar.xz' : windows ? 'https://example.com/windows.iso' : 'https://example.com/image.qcow2'} />
</label>
<label className="block text-sm text-gray-700 dark:text-gray-200">
SHA-256 <span className="text-xs text-gray-400">({t('可选')})</span>
<input className={`${inputClass} font-mono text-xs`} value={form.sha256 || ''} maxLength={64} onChange={(event) => setForm({ ...form, sha256: event.target.value })} placeholder={t('用于校验下载文件完整性')} />
</label>
{formError && (
<div className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
{formError}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-gray-200 bg-gray-50 px-5 py-3 dark:border-gray-700 dark:bg-gray-800">
<button type="button" onClick={onClose} disabled={submitting} className="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-700">{t('取消')}</button>
<button type="button" onClick={submit} disabled={submitting} className="inline-flex items-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-200">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />}
{submitting ? t('正在添加...') : t('添加并下载')}
</button>
</div>
</div>
</div>
)
}
@@ -218,8 +473,10 @@ function ImageTable({
onCancelDownload,
onDelete,
onToggle,
onRemoveCustom,
storageReady,
storageLoading,
headerAction,
}: {
title: string
images: ImageInfo[]
@@ -230,16 +487,21 @@ function ImageTable({
onCancelDownload: (id: string) => void
onDelete: (id: string) => void
onToggle: (id: string, enabled: boolean) => void
onRemoveCustom: (id: string) => void
storageReady: boolean
storageLoading: boolean
headerAction?: ReactNode
}) {
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-800">{title}</h2>
<span className="text-xs text-gray-400">
{downloadedCount}/{totalCount}
</span>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100">{title}</h2>
<span className="text-xs text-gray-400">
{downloadedCount}/{totalCount}
</span>
</div>
{headerAction}
</div>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
@@ -274,10 +536,11 @@ function ImageTable({
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<span className="w-8 h-8 flex items-center justify-center flex-shrink-0">
{getTemplateIcon(img.id)}
{getTemplateIcon(img.id, img.distro, img.custom)}
</span>
<div>
<span className="font-medium text-gray-900 text-sm">{img.name}</span>
<span className="font-medium text-gray-900 text-sm dark:text-gray-100">{img.name}</span>
{img.custom && <span className="ml-2 rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300"></span>}
<p className="text-[11px] text-gray-400">{img.description}</p>
</div>
@@ -350,6 +613,16 @@ function ImageTable({
</button>
</>
)}
{img.custom && !img.downloading && (
<button
onClick={() => onRemoveCustom(img.id)}
disabled={isBusy}
className="inline-flex items-center rounded-md border border-gray-200 p-1.5 text-gray-500 hover:border-red-200 hover:bg-red-50 hover:text-red-600 disabled:opacity-50 dark:border-gray-700 dark:text-gray-400 dark:hover:border-red-900 dark:hover:bg-red-950 dark:hover:text-red-300"
title="移除第三方镜像源和缓存"
>
<Unlink className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
@@ -425,6 +698,7 @@ function StatusBadge({ img }: { img: ImageInfo }) {
function downloadStatusLabel(img: ImageInfo) {
if (img.stage === 'canceling') return '取消中'
if (img.stage === 'converting') return '转换中'
if (img.stage === 'validating') return '校验中'
if (img.stage === 'lxc-create') return '下载中'
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
if (img.downloaded_bytes > 0) return `下载中 · ${formatSize(img.downloaded_bytes)}`
@@ -444,8 +718,9 @@ function isWindowsImage(img: ImageInfo) {
return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
}
function getTemplateIcon(id: string): ReactNode {
function getTemplateIcon(id: string, distro = '', custom = false): ReactNode {
const size = 'w-5 h-5'
id = (custom && distro ? distro : id).toLowerCase()
id = id.startsWith('kvm-') ? id.slice(4) : id
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
@@ -455,6 +730,7 @@ function getTemplateIcon(id: string): ReactNode {
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
if (custom) return <CloudDownload className={`${size} text-blue-600 dark:text-blue-300`} />
return null
}
+11 -7
View File
@@ -40,8 +40,12 @@ export default function Login() {
await login(username, password)
}
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
setError(error.response?.data?.message || t('登录失败,请检查用户名和密码'))
const error = err as { response?: { status?: number; data?: { message?: string } } }
if (error.response?.status === 401) {
setError(t(isAccessCodeLogin ? '访问码或密码错误' : '用户名或密码错误'))
} else {
setError(error.response?.data?.message || t('登录失败,请检查用户名和密码'))
}
} finally {
setLoading(false)
}
@@ -79,7 +83,7 @@ export default function Login() {
{!isAccessCodeLogin && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
{t('用户名')}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -90,7 +94,7 @@ export default function Login() {
value={username}
onChange={(event) => setUsername(event.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-md text-black bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-black focus:border-black text-sm"
placeholder="输入用户名"
placeholder={t('输入用户名')}
required
autoComplete="username"
/>
@@ -100,7 +104,7 @@ export default function Login() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
{t('密码')}
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
@@ -111,7 +115,7 @@ export default function Login() {
value={password}
onChange={(event) => setPassword(event.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-md text-black bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-black focus:border-black text-sm"
placeholder="输入密码"
placeholder={t('输入密码')}
required
autoComplete="current-password"
/>
@@ -123,7 +127,7 @@ export default function Login() {
disabled={loading}
className="w-full bg-black text-white py-2.5 rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
{loading ? '登录中...' : '登录'}
{loading ? t('登录中...') : t('登录')}
</button>
</form>
</div>
+5 -1
View File
@@ -62,6 +62,7 @@ export default function Routing() {
const ipv6Prefixes = routing?.ipv6_prefixes || []
const ipv6Assignments = routing?.ipv6_assignments || []
const nat4Range = routing?.nat4_port_range || { start: 20000, end: 65535 }
const nat4Networks = routing?.nat4_networks
const defaultIPv4Interface = routing?.host_public_ipv4?.interface || publicIPv4s[0]?.interface || 'eth0'
const defaultIPv4Gateway = routing?.host_public_ipv4?.gateway || publicIPv4s[0]?.gateway || ''
const defaultIPv4PrefixLen = routing?.host_public_ipv4?.prefix_len || publicIPv4s[0]?.prefix_len || 32
@@ -287,7 +288,10 @@ export default function Routing() {
used={routing?.nat4.used || 0}
label={text.remainingTotal}
usedLabel={text.used}
detail={formatNATRange(nat4Range, language)}
detail={[
formatNATRange(nat4Range, language),
nat4Networks ? `LXC ${nat4Networks.lxc.subnet} · KVM ${nat4Networks.kvm.subnet}` : '',
].filter(Boolean).join(' · ')}
action={
<button onClick={startEditNAT4} className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-black" title={text.editNAT4Range}>
<Pencil className="h-4 w-4" />
+178 -4
View File
@@ -1,17 +1,20 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, ListTodo, Lock, LogIn, Minus, Monitor, Plus, RefreshCw, Save, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import { Clock, Globe, ListTodo, Lock, LogIn, Minus, Monitor, Plus, RefreshCw, Save, Shield, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getPanelAccessPolicy,
getSSLSettings,
getTaskQueueSettings,
getWebSSHOriginSettings,
LoginLog,
PanelAccessPolicy,
SSLSettings,
TaskQueueSettings,
updateTaskQueueSettings,
updateSSLSettings,
updatePanelAccessPolicy,
updateWebSSHOriginSettings,
WebSSHOriginSettings,
} from '../services/api'
@@ -19,11 +22,12 @@ import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
type SettingsSection = 'tasks' | 'account' | 'webssh' | 'ssl' | 'logs'
type SettingsSection = 'tasks' | 'account' | 'access' | 'webssh' | 'ssl' | 'logs'
const settingsSections = [
{ id: 'tasks', label: '任务队列', icon: ListTodo },
{ id: 'account', label: '账号设置', icon: UserCog },
{ id: 'access', label: '访问来源', icon: Shield },
{ id: 'webssh', label: 'WebSSH 访问', icon: Terminal },
{ id: 'ssl', label: 'SSL 证书', icon: ShieldCheck },
{ id: 'logs', label: '登录日志', icon: LogIn },
@@ -57,6 +61,11 @@ export default function Settings() {
const [taskQueue, setTaskQueue] = useState<TaskQueueSettings | null>(null)
const [taskConcurrency, setTaskConcurrency] = useState(2)
const [savingTaskQueue, setSavingTaskQueue] = useState(false)
const [accessPolicy, setAccessPolicy] = useState<PanelAccessPolicy | null>(null)
const [accessEnabled, setAccessEnabled] = useState(false)
const [allowedSourcesText, setAllowedSourcesText] = useState('')
const [trustedProxiesText, setTrustedProxiesText] = useState('')
const [savingAccessPolicy, setSavingAccessPolicy] = useState(false)
const [activeSection, setActiveSection] = useState<SettingsSection>('tasks')
const fetchLogs = useCallback(async () => {
@@ -109,18 +118,33 @@ export default function Settings() {
}
}, [])
const fetchAccessPolicy = useCallback(async () => {
try {
const res = await getPanelAccessPolicy()
const data = res.data.data
if (!data) return
setAccessPolicy(data)
setAccessEnabled(data.enabled)
setAllowedSourcesText((data.allowed_sources || []).join('\n'))
setTrustedProxiesText((data.trusted_proxies || []).join('\n'))
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
fetchWebSSHOrigins()
fetchTaskQueue()
fetchAccessPolicy()
const logTimer = setInterval(fetchLogs, 15000)
const taskTimer = setInterval(fetchTaskQueue, 5000)
return () => {
clearInterval(logTimer)
clearInterval(taskTimer)
}
}, [fetchLogs, fetchSSL, fetchTaskQueue, fetchWebSSHOrigins])
}, [fetchAccessPolicy, fetchLogs, fetchSSL, fetchTaskQueue, fetchWebSSHOrigins])
const handleSaveTaskQueue = async () => {
const concurrency = Math.max(1, Math.min(16, Math.round(taskConcurrency || 1)))
@@ -194,6 +218,38 @@ export default function Settings() {
}
}
const handleAccessEnabledChange = (enabled: boolean) => {
setAccessEnabled(enabled)
if (enabled && !allowedSourcesText.trim() && accessPolicy?.current_source) {
setAllowedSourcesText(accessPolicy.current_source)
}
}
const handleSaveAccessPolicy = async () => {
const splitEntries = (value: string) => value.split(/[\s,;]+/).map(item => item.trim()).filter(Boolean)
setSavingAccessPolicy(true)
try {
const res = await updatePanelAccessPolicy({
enabled: accessEnabled,
allowed_sources: splitEntries(allowedSourcesText),
trusted_proxies: splitEntries(trustedProxiesText),
})
const data = res.data.data
if (data) {
setAccessPolicy(data)
setAccessEnabled(data.enabled)
setAllowedSourcesText((data.allowed_sources || []).join('\n'))
setTrustedProxiesText((data.trusted_proxies || []).join('\n'))
}
dialog.alert('完成', accessEnabled ? '面板访问来源策略已保存并立即生效' : '面板访问来源限制已关闭')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || '面板访问来源策略保存失败')
} finally {
setSavingAccessPolicy(false)
}
}
const handleSaveAccount = async () => {
if (!oldPwd) {
dialog.alert('提示', '请输入当前密码以确认修改')
@@ -248,7 +304,7 @@ export default function Settings() {
<div className="space-y-5">
<div>
<h1 className="text-2xl font-bold text-black dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">访</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">访访</p>
</div>
<div className="grid items-start gap-4 lg:grid-cols-[210px_minmax(0,1fr)]">
@@ -324,6 +380,21 @@ export default function Settings() {
/>
)}
{activeSection === 'access' && (
<PanelAccessPolicyCard
policy={accessPolicy}
enabled={accessEnabled}
allowedSourcesText={allowedSourcesText}
trustedProxiesText={trustedProxiesText}
saving={savingAccessPolicy}
onEnabledChange={handleAccessEnabledChange}
onAllowedSourcesTextChange={setAllowedSourcesText}
onTrustedProxiesTextChange={setTrustedProxiesText}
onRefresh={fetchAccessPolicy}
onSave={handleSaveAccessPolicy}
/>
)}
{activeSection === 'ssl' && (
<SSLCard
ssl={ssl}
@@ -365,6 +436,109 @@ interface TaskQueueCardProps {
onSave: () => void
}
interface PanelAccessPolicyCardProps {
policy: PanelAccessPolicy | null
enabled: boolean
allowedSourcesText: string
trustedProxiesText: string
saving: boolean
onEnabledChange: (enabled: boolean) => void
onAllowedSourcesTextChange: (value: string) => void
onTrustedProxiesTextChange: (value: string) => void
onRefresh: () => void
onSave: () => void
}
function PanelAccessPolicyCard(props: PanelAccessPolicyCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-900">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
<Shield className="h-4 w-4" />访
</h2>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">访 API </p>
</div>
<button type="button" onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between gap-4 border-y border-gray-100 py-3 dark:border-gray-800">
<div>
<div className="text-sm font-medium text-gray-800 dark:text-gray-200">访</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">访</div>
</div>
<button
type="button"
role="switch"
aria-checked={props.enabled}
onClick={() => props.onEnabledChange(!props.enabled)}
className={`access-policy-switch relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full border transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 dark:focus:ring-white dark:focus:ring-offset-gray-900 ${
props.enabled
? 'border-black bg-black dark:border-white dark:bg-white'
: 'border-gray-300 bg-gray-300 dark:border-gray-600 dark:bg-gray-700'
}`}
title={props.enabled ? '关闭访问白名单' : '启用访问白名单'}
>
<span
aria-hidden="true"
className={`access-policy-switch-thumb pointer-events-none absolute left-0.5 top-0.5 h-5 w-5 rounded-full shadow-sm ring-1 ring-black/5 transition-[transform,background-color] duration-200 ${
props.enabled
? 'translate-x-5 bg-white dark:bg-gray-900'
: 'translate-x-0 bg-white dark:bg-gray-200'
}`}
/>
</button>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600 dark:text-gray-300"> IP / CIDR</label>
<textarea
value={props.allowedSourcesText}
onChange={(event) => props.onAllowedSourcesTextChange(event.target.value)}
rows={6}
disabled={!props.enabled}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black outline-none focus:border-black focus:ring-1 focus:ring-black disabled:bg-gray-50 disabled:text-gray-400 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:focus:border-white dark:focus:ring-white dark:disabled:bg-gray-800 dark:disabled:text-gray-500"
placeholder={'203.0.113.10\n192.168.1.0/24\n2001:db8::/32'}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-600 dark:text-gray-300"> IP / CIDR</label>
<textarea
value={props.trustedProxiesText}
onChange={(event) => props.onTrustedProxiesTextChange(event.target.value)}
rows={6}
disabled={!props.enabled}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black outline-none focus:border-black focus:ring-1 focus:ring-black disabled:bg-gray-50 disabled:text-gray-400 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:focus:border-white dark:focus:ring-white dark:disabled:bg-gray-800 dark:disabled:text-gray-500"
placeholder={'127.0.0.1\n10.0.0.0/8'}
/>
<p className="mt-1.5 text-xs text-gray-500 dark:text-gray-400">使</p>
</div>
</div>
<div className="mt-4 grid gap-2 rounded-md border border-gray-100 bg-gray-50 p-3 text-xs dark:border-gray-800 dark:bg-gray-950 sm:grid-cols-2">
<div>
<span className="text-gray-500 dark:text-gray-400"></span>
<div className="mt-0.5 break-all font-mono text-gray-800 dark:text-gray-200">{props.policy?.current_source || '-'}</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400"></span>
<div className="mt-0.5 break-all font-mono text-gray-800 dark:text-gray-200">{props.policy?.direct_source || '-'}</div>
</div>
</div>
<div className="mt-4 flex justify-end">
<button type="button" onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-200">
<Save className="h-4 w-4" />
{props.saving ? '保存中...' : '保存访问策略'}
</button>
</div>
</div>
)
}
function TaskQueueCard(props: TaskQueueCardProps) {
const setBounded = (value: number) => props.onConcurrencyChange(Math.max(1, Math.min(16, value)))
return (
+43 -39
View File
@@ -136,13 +136,13 @@ export default function Storage() {
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 space-y-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-black dark:text-white">{t('存储管理')}</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{t('只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。')}</p>
</div>
<div className="flex gap-2">
<div className="flex shrink-0 gap-2">
<button onClick={fetchData} className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
<RefreshCw className="h-4 w-4" />{t('刷新')}
</button>
@@ -169,53 +169,57 @@ export default function Storage() {
</div>
)}
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table className="w-full min-w-[1240px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-4 py-3 text-left font-medium">{t('磁盘')}</th>
<th className="px-4 py-3 text-left font-medium">{t('空间分布')}</th>
<th className="px-4 py-3 text-left font-medium">{t('用于存储')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{mountedDisks.length === 0 ? (
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400">{t('未检测到已挂载磁盘')}</td></tr>
) : mountedDisks.map((disk) => {
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white text-sm">
<div className="hidden grid-cols-[minmax(170px,0.65fr)_minmax(320px,1.2fr)_minmax(480px,1.8fr)] gap-4 border-b border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-500 2xl:grid">
<div className="font-medium">{t('磁盘')}</div>
<div className="font-medium">{t('空间分布')}</div>
<div className="font-medium">{t('用于存储')}</div>
</div>
{mountedDisks.length === 0 ? (
<div className="px-4 py-10 text-center text-gray-400">{t('未检测到已挂载磁盘')}</div>
) : (
<div className="divide-y divide-gray-100">
{mountedDisks.map((disk) => {
const pool = pools.find((item) => poolForDisk(item, disk))
const contentUsage = contentUsageMap(pool?.content_usage || disk.content_usage || [])
const clicdUsed = pool?.clicd_used_bytes || disk.clicd_used_bytes || 0
return (
<tr key={`${disk.path}-${disk.mount_point}`} className="align-top hover:bg-gray-50/70">
<td className="px-4 py-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-md bg-gray-100 text-gray-600">
<section
key={`${disk.path}-${disk.mount_point}`}
className="grid min-w-0 grid-cols-1 gap-4 px-4 py-4 hover:bg-gray-50/70 2xl:grid-cols-[minmax(170px,0.65fr)_minmax(320px,1.2fr)_minmax(480px,1.8fr)]"
>
<div className="min-w-0">
<div className="mb-2 text-xs font-medium text-gray-500 2xl:hidden">{t('磁盘')}</div>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600">
<HardDrive className="h-5 w-5" />
</div>
<div>
<div className="font-mono text-xs font-medium text-gray-900">{disk.path || disk.name}</div>
<div className="mt-1 text-xs text-gray-500">{disk.model || disk.fstype || disk.type || '-'}</div>
<div className="mt-1 font-mono text-xs text-gray-400">{disk.mount_point}</div>
<div className="min-w-0">
<div className="truncate font-mono text-xs font-medium text-gray-900" title={disk.path || disk.name}>{disk.path || disk.name}</div>
<div className="mt-1 truncate text-xs text-gray-500" title={disk.model || disk.fstype || disk.type || '-'}>{disk.model || disk.fstype || disk.type || '-'}</div>
<div className="mt-1 truncate font-mono text-xs text-gray-400" title={disk.mount_point}>{disk.mount_point}</div>
</div>
</div>
</td>
<td className="px-4 py-4">
</div>
<div className="min-w-0">
<div className="mb-2 text-xs font-medium text-gray-500 2xl:hidden">{t('空间分布')}</div>
<DiskUsageBar disk={disk} contentUsage={contentUsage} clicdUsed={clicdUsed} />
</td>
<td className="px-4 py-4">
<div className="flex min-w-[620px] flex-nowrap items-start gap-2">
</div>
<div className="min-w-0">
<div className="mb-2 text-xs font-medium text-gray-500 2xl:hidden">{t('用于存储')}</div>
<div className="grid min-w-0 grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{contentOptions.map(([value, label]) => {
const checked = (pool?.content_types || []).includes(value)
const isDefault = (pool?.default_contents || []).includes(value)
return (
<div key={value} className={`w-[116px] shrink-0 rounded-md border px-2.5 py-2 ${checked ? 'border-gray-300 bg-white' : 'border-gray-200 bg-gray-50'}`}>
<div key={value} className={`min-w-0 rounded-md border px-2.5 py-2 ${checked ? 'border-gray-300 bg-white' : 'border-gray-200 bg-gray-50'}`}>
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-700">
<input type="checkbox" checked={checked} onChange={() => toggleContent(disk, value)} />
{t(label)}
<input className="shrink-0" type="checkbox" checked={checked} onChange={() => toggleContent(disk, value)} />
<span className="truncate" title={t(label)}>{t(label)}</span>
</label>
{checked && (
<div className="mt-1.5 flex items-center justify-between gap-2 border-t border-gray-100 pt-1.5">
<span className="text-[11px] text-gray-500">{t('默认盘')}</span>
<span className="truncate text-[11px] text-gray-500">{t('默认盘')}</span>
<button
type="button"
role="switch"
@@ -232,12 +236,12 @@ export default function Storage() {
)
})}
</div>
</td>
</tr>
</div>
</section>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)
@@ -279,8 +283,8 @@ function DiskUsageBar({
].filter((segment) => segment.size > 0)
return (
<div className="min-w-[420px] max-w-[620px]">
<div className="flex items-center justify-between gap-4 text-xs text-gray-600">
<div className="w-full min-w-0">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-gray-600">
<span>{t('已用')} {formatBytes(used)} / {formatBytes(total)}</span>
<span>{usagePct(used, total).toFixed(1)}% · {t('可用')} {formatBytes(free)}</span>
</div>
+63 -2
View File
@@ -21,10 +21,15 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const requestURL = String(error.config?.url || '')
const isLoginRequest = ['/login', '/sub-user/login', '/sub-user/access']
.some((path) => requestURL === path || requestURL.endsWith(path))
if (error.response?.status === 401 && !isLoginRequest) {
localStorage.removeItem('clicd_token')
localStorage.removeItem('clicd_username')
window.location.href = '/login'
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
@@ -548,6 +553,21 @@ export const getWebSSHOriginSettings = () =>
export const updateWebSSHOriginSettings = (origins: string[]) =>
api.put<APIResponse<WebSSHOriginSettings>>('/webssh-origins', { origins })
export interface PanelAccessPolicy {
enabled: boolean
allowed_sources: string[]
trusted_proxies: string[]
current_source: string
direct_source: string
using_forwarded: boolean
}
export const getPanelAccessPolicy = () =>
api.get<APIResponse<PanelAccessPolicy>>('/access-policy')
export const updatePanelAccessPolicy = (data: Pick<PanelAccessPolicy, 'enabled' | 'allowed_sources' | 'trusted_proxies'>) =>
api.put<APIResponse<PanelAccessPolicy>>('/access-policy', data)
// Containers
export const getContainers = () =>
api.get<APIResponse<Container[]>>('/containers')
@@ -717,6 +737,11 @@ export interface IPv6Route {
export interface RoutingInfo {
nat4: RouteCapacity
nat4_port_range: NAT4PortRange
nat4_next_port: number
nat4_networks: {
lxc: NATNetworkInfo
kvm: NATNetworkInfo
}
ipv4: RouteCapacity
lan_dhcp: RouteCapacity
ipv6: RouteCapacity
@@ -774,11 +799,37 @@ export interface ImageInfo {
size_bytes: number
manual_path?: string
desktop?: string
provisioner?: string
custom?: boolean
sha256?: string
}
export interface CustomKVMImageInput {
type: 'lxc' | 'kvm'
name: string
description: string
distro: string
release: string
arch: string
url: string
provisioner?: 'linux-cloud-init' | 'windows-10' | 'windows-11' | 'lxc-rootfs'
sha256?: string
}
export interface CustomKVMImage extends CustomKVMImageInput {
id: string
created_at: string
}
export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images')
export const createCustomKVMImage = (payload: CustomKVMImageInput) =>
api.post<APIResponse<CustomKVMImage>>('/images/custom', payload)
export const removeCustomKVMImage = (id: string) =>
api.delete<APIResponse>('/images/custom', { data: { id } })
export const downloadImage = (templateId: string) =>
api.post<APIResponse>('/images/download', { template_id: templateId })
@@ -923,6 +974,16 @@ export interface SubUser {
created_at: string
}
export interface NATNetworkInfo {
subnet: string
gateway: string
netmask: string
dhcp_start: string
dhcp_end: string
dhcp_max: number
prefix_bits: number
}
export const createSubUser = (containerId: ContainerIdentifier) =>
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
+73
View File
@@ -138,6 +138,8 @@ const exact: Record<string, string> = {
'输入密码': 'Enter password',
'登录': 'Log in',
'登录中...': 'Logging in...',
'用户名或密码错误': 'Incorrect username or password',
'访问码或密码错误': 'Incorrect access code or password',
'登录失败,请检查用户名和密码': 'Login failed. Check your username and password.',
'Authentication required': 'Authentication required',
'Administrator permission required': 'Administrator permission required',
@@ -193,6 +195,33 @@ const exact: Record<string, string> = {
'请按红色提示修改 vCPU、内存或磁盘配置': 'Fix the vCPU, memory, or disk fields marked in red',
'创建失败': 'Create failed',
'创建新容器': 'Create New Container',
'创建步骤': 'Creation steps',
'基础信息': 'Basics',
'镜像选择': 'Image',
'网络配置': 'Network',
'预览清单': 'Review',
'基础信息有误': 'Invalid basic information',
'请填写有效且未被占用的容器名称': 'Enter a valid, available container name',
'KVM 磁盘': 'KVM Disk',
'请选择镜像': 'Select an image',
'请选择用于创建容器的系统镜像': 'Select the system image used to create the container',
'网络配置有误': 'Invalid network configuration',
'请至少启用一种网络连接方式': 'Enable at least one network connection mode',
'NAT 端口配置有误': 'Invalid NAT port configuration',
'自动分配': 'Auto assign',
'个端口': 'ports',
'局域网': 'LAN',
'未配置网络': 'No network configured',
'创建数量': 'Count',
'自动选择': 'Automatic',
'镜像与登录': 'Image and Login',
'镜像默认': 'Image default',
'自动生成密码': 'Auto-generated password',
'子用户可用镜像': 'Sub-user Images',
'主要网络': 'Primary Network',
'上一步': 'Back',
'下一步': 'Next',
'确认创建': 'Create',
'批量创建数量': 'Batch Count',
'虚拟化架构': 'Virtualization',
'LXC 容器': 'LXC Container',
@@ -407,7 +436,25 @@ const exact: Record<string, string> = {
'存储配置已保存': 'Storage settings saved',
'保存存储配置失败': 'Failed to save storage settings',
'任务队列、账号、安全证书与访问记录': 'Task queue, account, certificates, and access records',
'任务队列、账号、访问控制、安全证书与访问记录': 'Task queue, account, access control, certificates, and access records',
'设置分类': 'Settings categories',
'访问来源': 'Access Sources',
'访问来源策略': 'Access Source Policy',
'限制可访问面板、登录和 API 的来源地址': 'Restrict source addresses that can access the panel, login, and APIs',
'启用访问白名单': 'Enable Access Allowlist',
'关闭后不限制访问来源': 'No source restrictions when disabled',
'关闭访问白名单': 'Disable Access Allowlist',
'允许的 IP / CIDR': 'Allowed IP / CIDR',
'可信代理 IP / CIDR': 'Trusted Proxy IP / CIDR',
'仅可信代理可提供真实客户端地址;未使用反向代理时留空': 'Only trusted proxies may supply the real client address. Leave empty without a reverse proxy.',
'当前识别来源': 'Detected Source',
'直接连接来源': 'Direct Connection Source',
'保存访问策略': 'Save Access Policy',
'面板访问来源策略已保存并立即生效': 'Panel access source policy saved and applied immediately',
'面板访问来源限制已关闭': 'Panel access source restriction disabled',
'面板访问来源策略保存失败': 'Failed to save panel access source policy',
'面板访问来源策略': 'Panel Access Source Policy',
'更新面板访问来源策略': 'Update Panel Access Source Policy',
'WebSSH 访问': 'WebSSH Access',
'账号设置': 'Account Settings',
'当前用户名': 'Current Username',
@@ -668,6 +715,32 @@ const exact: Record<string, string> = {
'快照配额': 'Snapshot Quota',
'模板列表': 'Template List',
'镜像管理列表': 'Image Management List',
'第三方镜像': 'Third-party Image',
'下载第三方 KVM 镜像': 'Download Third-party KVM Image',
'下载第三方 LXC 镜像': 'Download Third-party LXC Image',
'支持 tar、tar.gz、tar.xz、tar.zst 格式的 Linux rootfs': 'Supports Linux rootfs archives in tar, tar.gz, tar.xz, and tar.zst formats',
'移除第三方镜像': 'Remove Third-party Image',
'第三方镜像已移除': 'Third-party image removed',
'移除第三方镜像失败': 'Failed to remove third-party image',
'第三方镜像已添加,下载任务已启动': 'Third-party image added and download started',
'镜像格式必须与所选无人值守安装模板匹配': 'The image format must match the selected unattended installation template',
'无人值守安装模板': 'Unattended Installation Template',
'镜像名称': 'Image Name',
'版本 / 代号': 'Version / Codename',
'镜像下载地址': 'Image Download URL',
'镜像来源、版本或用途': 'Image source, version, or purpose',
'用于校验下载文件完整性': 'Used to verify download integrity',
'添加并下载': 'Add and Download',
'正在添加...': 'Adding...',
'添加第三方镜像失败': 'Failed to add third-party image',
'添加第三方 KVM 镜像源': 'Add Third-party KVM Image Source',
'移除第三方 KVM 镜像源': 'Remove Third-party KVM Image Source',
'添加第三方 LXC/KVM 镜像源': 'Add Third-party LXC/KVM Image Source',
'移除第三方 LXC/KVM 镜像源': 'Remove Third-party LXC/KVM Image Source',
'请填写名称、发行版、版本和下载地址': 'Enter the name, distribution, version, and download URL',
'SHA-256 必须是 64 位十六进制字符串': 'SHA-256 must be a 64-character hexadecimal string',
'安装 ISO': 'Installation ISO',
'校验中': 'Validating',
'取消镜像下载': 'Cancel Image Download',
'启用/禁用镜像': 'Enable / Disable Image',
'安全连接日志': 'Security Connection Logs',