mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 06:14:42 +08:00
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:
@@ -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} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview || '--'} -> {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('自动')} -> {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('自动')} -> {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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) })
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user