mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
支持公网IPV4分配,单独IPV6分配,以及混合网络分配。
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { CalendarClock, X } from 'lucide-react'
|
||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
|
||||
import { useDialog } from './Dialog'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
|
||||
interface CreateContainerModalProps {
|
||||
isOpen: boolean
|
||||
@@ -26,13 +27,21 @@ const defaultForm: CreateContainerRequest = {
|
||||
io_speed_mbps: 0,
|
||||
extra_ports: [],
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
snapshot_limit: 1,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
public_ipv4s: [],
|
||||
assign_ipv6: false,
|
||||
ipv6_count: 1,
|
||||
ipv6_addresses: [],
|
||||
expires_at: '',
|
||||
}
|
||||
|
||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
||||
const dialog = useDialog()
|
||||
const { language } = useLanguage()
|
||||
const networkText = createNetworkText[language]
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [batchCount, setBatchCount] = useState(1)
|
||||
@@ -74,16 +83,23 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
}, [isOpen, form.virtualization])
|
||||
|
||||
const ipv6Available = !!ipv6Status?.available
|
||||
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
|
||||
const ipv6Prefixes = ipv6Status?.prefixes || []
|
||||
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
|
||||
const publicIPv4s = hostInfo?.network.public_ipv4_addresses || []
|
||||
const ipv4Available = publicIPv4s.length > 0
|
||||
const manualIPv4s = form.public_ipv4s || []
|
||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||
const natEnabled = form.assign_nat !== false
|
||||
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
|
||||
|
||||
const autoPorts = useMemo(() => {
|
||||
const count = Math.max(2, form.port_mapping_count)
|
||||
if (!natEnabled) return []
|
||||
const count = natPortCount
|
||||
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
|
||||
}, [form.port_mapping_count])
|
||||
}, [natEnabled, natPortCount])
|
||||
|
||||
// SSH port preview (will be allocated sequentially, starting around 22000+)
|
||||
const sshPortPreview = 22000
|
||||
@@ -127,7 +143,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) {
|
||||
dialog.alert('提示', '请勾选任意一个可用网络')
|
||||
return
|
||||
}
|
||||
|
||||
const boundedForm = normalizeCreateForm(form)
|
||||
const wantsNAT = boundedForm.assign_nat !== false
|
||||
|
||||
// Build batch of containers
|
||||
const containers: CreateContainerRequest[] = []
|
||||
@@ -137,8 +159,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
containers.push({
|
||||
...boundedForm,
|
||||
name,
|
||||
port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2),
|
||||
assign_nat: wantsNAT,
|
||||
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0,
|
||||
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
|
||||
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
|
||||
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
|
||||
extra_ports: [],
|
||||
})
|
||||
}
|
||||
@@ -229,21 +254,157 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
|
||||
</Field>
|
||||
|
||||
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.assign_ipv6}
|
||||
disabled={!ipv6Available}
|
||||
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">Public IPv6</span>
|
||||
<span className="block text-xs text-gray-500 truncate">
|
||||
{ipv6Available ? `Use ${ipv6Prefix}` : (ipv6Status?.reason || 'Checking IPv6 prefix...')}
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.assign_ipv4}
|
||||
disabled={!ipv4Available}
|
||||
onChange={(event) => setForm({ ...form, assign_ipv4: event.target.checked, public_ipv4s: event.target.checked ? form.public_ipv4s : [] })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">{networkText.publicIPv4}</span>
|
||||
<span className="block text-xs text-gray-500">
|
||||
{ipv4Available ? formatAllocatableIPv4Count(publicIPv4s.length, language) : networkText.noAllocatableIPv4}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</label>
|
||||
{form.assign_ipv4 && (
|
||||
<div className="mt-3 space-y-3 pl-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={manualIPv4s.length === 0}
|
||||
onChange={() => setForm({ ...form, public_ipv4s: [] })}
|
||||
/>
|
||||
Auto assign
|
||||
</label>
|
||||
<Field label="IPv4 count">
|
||||
<NumberInput
|
||||
value={form.ipv4_count || 1}
|
||||
min={1}
|
||||
max={Math.max(1, publicIPv4s.length)}
|
||||
onChange={(value) => setForm({ ...form, ipv4_count: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={manualIPv4s.length > 0}
|
||||
onChange={() => setForm({ ...form, public_ipv4s: publicIPv4s[0]?.address ? [publicIPv4s[0].address] : [], ipv4_count: 1 })}
|
||||
/>
|
||||
Manual select
|
||||
</label>
|
||||
{manualIPv4s.length > 0 && (
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{publicIPv4s.map((ip) => (
|
||||
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded border border-gray-200 px-2 py-1.5 text-xs text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualIPv4s.includes(ip.address)}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? [...manualIPv4s, ip.address]
|
||||
: manualIPv4s.filter((value) => value !== ip.address)
|
||||
setForm({ ...form, public_ipv4s: next, ipv4_count: Math.max(1, next.length || 1) })
|
||||
}}
|
||||
/>
|
||||
<span className="truncate font-mono">{ip.address}</span>
|
||||
<span className="shrink-0 text-gray-400">{ip.interface}</span>
|
||||
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.assign_ipv6}
|
||||
disabled={!ipv6Available}
|
||||
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
|
||||
<span className="block text-xs text-gray-500 truncate">
|
||||
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{form.assign_ipv6 && (
|
||||
<span className="block w-24 shrink-0">
|
||||
<NumberInput
|
||||
value={form.ipv6_count || 1}
|
||||
min={1}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, ipv6_count: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={natEnabled}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked
|
||||
setForm({
|
||||
...form,
|
||||
assign_nat: checked,
|
||||
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
|
||||
extra_ports: [],
|
||||
})
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">{networkText.publicNAT}</span>
|
||||
<span className="block text-xs text-gray-500">
|
||||
{natEnabled ? formatNATPortCount(natPortCount, language) : networkText.noNATPorts}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{natEnabled && (
|
||||
<span className="block w-24 shrink-0">
|
||||
<NumberInput
|
||||
value={natPortCount}
|
||||
min={2}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{natEnabled && (
|
||||
<div className="mt-2 pl-6">
|
||||
<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}
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{port} -> {port}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="vCPU">
|
||||
@@ -319,25 +480,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="NAT 端口映射数量">
|
||||
<NumberInput
|
||||
value={form.port_mapping_count}
|
||||
min={2}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2) })}
|
||||
/>
|
||||
<div className="mt-2 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}
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{port} -> {port}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="子用户快照上限">
|
||||
<NumberInput
|
||||
value={form.snapshot_limit}
|
||||
@@ -475,11 +617,22 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
|
||||
|
||||
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||
const normalized = applyTemplateDefaults(form)
|
||||
const wantsNAT = normalized.assign_nat !== false
|
||||
const wantsIPv4 = !!normalized.assign_ipv4
|
||||
const wantsIPv6 = !!normalized.assign_ipv6
|
||||
return {
|
||||
...normalized,
|
||||
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
||||
ram_mb: Math.round(normalized.ram_mb),
|
||||
disk_gb: Math.round(normalized.disk_gb),
|
||||
assign_nat: wantsNAT,
|
||||
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
|
||||
assign_ipv4: wantsIPv4,
|
||||
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
|
||||
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
|
||||
assign_ipv6: wantsIPv6,
|
||||
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
|
||||
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
|
||||
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
|
||||
}
|
||||
}
|
||||
@@ -509,5 +662,38 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
|
||||
return Math.min(Math.max(next, min), max ?? next)
|
||||
}
|
||||
|
||||
const createNetworkText = {
|
||||
zh: {
|
||||
publicIPv4: '公网 IPv4',
|
||||
noAllocatableIPv4: '未检测到可分配公网 IPv4',
|
||||
publicIPv6: '公网 IPv6',
|
||||
use: '使用',
|
||||
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
|
||||
publicNAT: '公网 NAT',
|
||||
noNATPorts: '不分配 NAT 端口',
|
||||
},
|
||||
en: {
|
||||
publicIPv4: 'Public IPv4',
|
||||
noAllocatableIPv4: 'No allocatable public IPv4 detected',
|
||||
publicIPv6: 'Public IPv6',
|
||||
use: 'Use',
|
||||
checkingIPv6Prefix: 'Checking IPv6 prefix...',
|
||||
publicNAT: 'Public NAT',
|
||||
noNATPorts: 'No NAT ports will be assigned',
|
||||
},
|
||||
} as const
|
||||
|
||||
function formatAllocatableIPv4Count(count: number, language: Language) {
|
||||
return language === 'en'
|
||||
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
|
||||
: `检测到 ${count} 个可分配地址`
|
||||
}
|
||||
|
||||
function formatNATPortCount(count: number, language: Language) {
|
||||
return language === 'en'
|
||||
? `${count} NAT ports will be assigned`
|
||||
: `将分配 ${count} 个 NAT 端口`
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
|
||||
|
||||
@@ -93,6 +93,7 @@ type MappingDraft = {
|
||||
index: number | null
|
||||
description: string
|
||||
host_port: string
|
||||
host_ip: string
|
||||
container_port: string
|
||||
protocol: string
|
||||
}
|
||||
@@ -101,6 +102,7 @@ const emptyDraft: MappingDraft = {
|
||||
index: null,
|
||||
description: '',
|
||||
host_port: '',
|
||||
host_ip: '',
|
||||
container_port: '',
|
||||
protocol: 'all',
|
||||
}
|
||||
@@ -526,6 +528,7 @@ export default function ContainerDetail() {
|
||||
index,
|
||||
description: pm.description,
|
||||
host_port: String(pm.host_port),
|
||||
host_ip: pm.host_ip || '',
|
||||
container_port: String(pm.container_port),
|
||||
protocol: pm.protocol || 'all',
|
||||
})
|
||||
@@ -538,7 +541,11 @@ export default function ContainerDetail() {
|
||||
if (!(await ensureSubUserCanOperate())) return false
|
||||
if (draft.index === null && container) {
|
||||
const currentCount = container.port_mappings?.length || 0
|
||||
const limit = container.port_mapping_limit || Math.max(currentCount, 2)
|
||||
const limit = Math.max(container.port_mapping_limit || 0, currentCount)
|
||||
if (limit <= 0) {
|
||||
dialog.alert('未分配 IPv4 NAT', '该容器未分配 IPv4 NAT 端口配额。')
|
||||
return false
|
||||
}
|
||||
if (currentCount >= limit) {
|
||||
dialog.alert('端口配额已满', '已达到管理员分配的 NAT 端口配额。')
|
||||
return false
|
||||
@@ -563,6 +570,7 @@ export default function ContainerDetail() {
|
||||
const payload: PortMapping = {
|
||||
container_port: containerPort,
|
||||
host_port: hostPortVal,
|
||||
host_ip: isSubUser ? undefined : (draft.host_ip || undefined),
|
||||
protocol: protocolVal,
|
||||
description: draft.description.trim() || `Port-${containerPort}`,
|
||||
}
|
||||
@@ -737,10 +745,17 @@ export default function ContainerDetail() {
|
||||
const isPolicyBlocked = !!container.policy_blocked
|
||||
const isSubUserPolicyBlocked = isSubUser && isPolicyBlocked
|
||||
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
||||
const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
||||
const publicIPv4s = container.public_ipv4s || []
|
||||
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
|
||||
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
||||
const ipv6List = (container.ipv6_addresses || [])
|
||||
.map((item) => item.address)
|
||||
.filter(Boolean)
|
||||
if (ipv6List.length === 0 && container.ipv6) ipv6List.push(container.ipv6)
|
||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||
const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
|
||||
const publicEndpoint = container.ssh_port > 0 ? `${publicHost}:${container.ssh_port}` : '-'
|
||||
const sshCommand = container.ssh_port > 0 ? `ssh -p ${container.ssh_port} root@${publicHost}` : ''
|
||||
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
|
||||
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
|
||||
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
|
||||
@@ -758,8 +773,9 @@ export default function ContainerDetail() {
|
||||
const netPct = Math.min(((usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)) / (container.network_bw_mbps > 0 ? container.network_bw_mbps * 125000 : 125000000) * 100, 100)
|
||||
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
|
||||
const mappingCount = container.port_mappings?.length || 0
|
||||
const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2)
|
||||
const canAddMapping = isSubUser ? mappingCount < mappingLimit && !isSubUserPolicyBlocked : true
|
||||
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
||||
const hasNATQuota = mappingLimit > 0
|
||||
const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked
|
||||
const managementUrl = subUser?.access_code
|
||||
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
|
||||
: ''
|
||||
@@ -828,8 +844,8 @@ export default function ContainerDetail() {
|
||||
<InfoTag color="blue">系统 {container.template}</InfoTag>
|
||||
<InfoTag color="slate">类型 {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
|
||||
<InfoTag color="emerald">内网 {container.ip || '-'}</InfoTag>
|
||||
<InfoTag color="amber">NAT {mappingCount} 条</InfoTag>
|
||||
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
|
||||
<InfoTag color="amber">IPv4 NAT {hasNATQuota ? `${mappingCount} 条` : '未分配'}</InfoTag>
|
||||
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicEndpoint}</InfoTag>
|
||||
{isPolicyBlocked && <InfoTag color="red">策略封禁</InfoTag>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -874,7 +890,7 @@ export default function ContainerDetail() {
|
||||
<>
|
||||
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
NAT 管理
|
||||
IPv4 NAT 管理
|
||||
</ActionButton>
|
||||
</>
|
||||
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
|
||||
@@ -926,7 +942,7 @@ export default function ContainerDetail() {
|
||||
</div>
|
||||
) : isWindows ? (
|
||||
<>
|
||||
<PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
|
||||
<PlainRow label="RDP 地址" value={publicEndpoint} mono />
|
||||
<PlainRow label="用户名" value="Administrator" mono />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-gray-500">管理员密码</span>
|
||||
@@ -951,7 +967,7 @@ export default function ContainerDetail() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
|
||||
<PlainRow label="SSH 地址" value={publicEndpoint} mono copyValue={sshCommand} onCopy={copyText} />
|
||||
<PlainRow label="用户名" value="root" mono />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-gray-500">SSH 密码</span>
|
||||
@@ -993,8 +1009,9 @@ export default function ContainerDetail() {
|
||||
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
|
||||
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
|
||||
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
|
||||
<PlainRow label="IPv6" value={container.ipv6 || '-'} mono copyValue={container.ipv6} onCopy={copyText}>
|
||||
{!isSubUser && !container.ipv6 && (
|
||||
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} />
|
||||
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
|
||||
{!isSubUser && ipv6List.length === 0 && (
|
||||
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||
Assign
|
||||
</button>
|
||||
@@ -1386,7 +1403,7 @@ export default function ContainerDetail() {
|
||||
)}
|
||||
|
||||
{showNat && (
|
||||
<Modal title="NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
||||
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
||||
!isSubUser && canAddMapping && (
|
||||
<button onClick={openAddMapping} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
|
||||
<Plus className="w-3.5 h-3.5" />添加映射
|
||||
@@ -1396,10 +1413,14 @@ export default function ContainerDetail() {
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-xs text-gray-500">
|
||||
端口配额:<span className="font-mono text-gray-800">{mappingCount}/{mappingLimit}</span>
|
||||
{hasNATQuota ? (
|
||||
<>端口配额:<span className="font-mono text-gray-800">{mappingCount}/{mappingLimit}</span></>
|
||||
) : (
|
||||
<span>未分配 IPv4 NAT 端口配额</span>
|
||||
)}
|
||||
</div>
|
||||
{!isSubUser && !canAddMapping && (
|
||||
<div className="text-xs text-amber-600">已达到管理员分配的 NAT 端口配额</div>
|
||||
{!isSubUser && hasNATQuota && !canAddMapping && (
|
||||
<div className="text-xs text-amber-600">已达到管理员分配的 IPv4 NAT 端口配额</div>
|
||||
)}
|
||||
</div>
|
||||
<MappingTable mappings={container.port_mappings || []} publicHost={publicHost} onEdit={openEditMapping} onDelete={isSubUser ? () => {} : removeMapping} isSubUser={isSubUser} />
|
||||
@@ -1419,6 +1440,7 @@ export default function ContainerDetail() {
|
||||
canAddMapping={canAddMapping}
|
||||
saving={savingMapping}
|
||||
containerIdentifier={containerIdentifier}
|
||||
publicIPv4s={publicIPv4s}
|
||||
onCancel={() => { setShowMappingEditor(false); setDraft(emptyDraft) }}
|
||||
onSubmit={async () => {
|
||||
if (await submitMapping()) {
|
||||
@@ -1739,6 +1761,7 @@ function MappingEditor({
|
||||
canAddMapping,
|
||||
saving,
|
||||
containerIdentifier,
|
||||
publicIPv4s,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
@@ -1748,6 +1771,7 @@ function MappingEditor({
|
||||
canAddMapping: boolean
|
||||
saving: boolean
|
||||
containerIdentifier: string
|
||||
publicIPv4s: { address: string; interface?: string }[]
|
||||
onCancel: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
@@ -1757,7 +1781,8 @@ function MappingEditor({
|
||||
|
||||
const fillRandomPort = async () => {
|
||||
try {
|
||||
const res = await api.get<APIResponse<{ port: number }>>(`/containers/${containerIdentifier}/random-port`)
|
||||
const params = draft.host_ip ? { host_ip: draft.host_ip } : undefined
|
||||
const res = await api.get<APIResponse<{ port: number }>>(`/containers/${containerIdentifier}/random-port`, { params })
|
||||
const port = res.data.data?.port || 0
|
||||
if (port > 0) updateDraft({ host_port: String(port) })
|
||||
} catch {
|
||||
@@ -1815,6 +1840,21 @@ function MappingEditor({
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Host IPv4">
|
||||
{isSubUser ? (
|
||||
<input value={draft.host_ip || 'All IPv4'} disabled className={disabledInputClass} />
|
||||
) : (
|
||||
<select value={draft.host_ip} onChange={(e) => updateDraft({ host_ip: e.target.value })} className={inputClass}>
|
||||
<option value="">All assigned IPv4</option>
|
||||
{publicIPv4s.map((ip) => (
|
||||
<option key={`${ip.interface}-${ip.address}`} value={ip.address}>
|
||||
{ip.address}{ip.interface ? ` (${ip.interface})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="内部端口">
|
||||
<input
|
||||
value={draft.container_port}
|
||||
@@ -1854,6 +1894,7 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
|
||||
<tr>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>协议</TableHead>
|
||||
<TableHead>Host IPv4</TableHead>
|
||||
<TableHead>外部端口</TableHead>
|
||||
<TableHead>内部端口</TableHead>
|
||||
{!compact && <th className="text-right px-3 py-2 text-xs font-medium text-gray-500">操作</th>}
|
||||
@@ -1869,7 +1910,8 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
|
||||
{isSSH && <span className="ml-2 px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 text-xs">默认</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500">{pm.protocol.toUpperCase()}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-800">{publicHost}:{pm.host_port}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.host_ip || publicHost || 'All IPv4'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.host_port}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.container_port}</td>
|
||||
{!compact && (
|
||||
<td className="px-3 py-2">
|
||||
|
||||
@@ -704,14 +704,16 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
io_speed_mbps: cfg.io_speed_mbps,
|
||||
status: 'creating',
|
||||
ip: '',
|
||||
public_ipv4s: [],
|
||||
ipv6: '',
|
||||
ipv6_prefix_len: 0,
|
||||
ipv6_interface: '',
|
||||
ipv6_addresses: [],
|
||||
vnc_port: 0,
|
||||
ssh_port: 0,
|
||||
ssh_password: '',
|
||||
port_mappings: [],
|
||||
port_mapping_limit: 2,
|
||||
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
|
||||
snapshot_limit: cfg.snapshot_limit || 3,
|
||||
created_at: '',
|
||||
expires_at: cfg.expires_at,
|
||||
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { getHostReport, HostProbeReport } from '../services/api'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
import { translateText } from '../utils/i18n'
|
||||
|
||||
export default function HostReport() {
|
||||
const { language } = useLanguage()
|
||||
const text = hostReportText[language]
|
||||
const [report, setReport] = useState<HostProbeReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -31,61 +35,61 @@ export default function HostReport() {
|
||||
}, [fetchReport])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6" data-no-translate>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black">宿主机信息</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">硬件、网络、磁盘健康与运行环境探测报告</p>
|
||||
<h1 className="text-2xl font-bold text-black">{text.title}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">{text.subtitle}</p>
|
||||
</div>
|
||||
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
{text.refresh}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && !report ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">正在探测宿主机环境...</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">{text.loading}</div>
|
||||
) : !report ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">暂未获取到宿主机信息</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">{text.emptyReport}</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
|
||||
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
|
||||
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
|
||||
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
|
||||
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={formatCPUThreads(report.cpu.cores, report.cpu.threads, language)} />
|
||||
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={formatUsedMemory(report.memory.used_mb, language)} />
|
||||
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={formatDiskCount(report.disks.length, language)} sub={report.disks.map(d => diskTypeLabel(d, language)).filter(Boolean).join(' / ') || 'Unknown'} />
|
||||
<ProbeMetric icon={<Activity className="h-4 w-4" />} label={text.runtimeStatus} value={translateDynamic(report.system.uptime_text, language)} sub={formatProcessCount(report.system.process_count, language)} />
|
||||
</div>
|
||||
|
||||
<ProbeSection title="系统概览">
|
||||
<ProbeSection title={text.systemOverview}>
|
||||
<ProbeRows rows={[
|
||||
['主机名', report.hostname],
|
||||
['操作系统', report.os],
|
||||
['内核', report.kernel],
|
||||
['生成时间', report.generated_at],
|
||||
['CPU 架构', report.cpu.architecture],
|
||||
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
|
||||
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
|
||||
['显卡', report.gpus.length ? `${report.gpus.length} 个` : '未检测到'],
|
||||
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
|
||||
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
|
||||
[text.hostname, report.hostname],
|
||||
[text.os, report.os],
|
||||
[text.kernel, report.kernel],
|
||||
[text.generatedAt, report.generated_at],
|
||||
[text.cpuArch, report.cpu.architecture],
|
||||
[text.cpuVirtualization, report.cpu.virtualization ? `${text.supported} (${report.cpu.virtualization_key})` : text.notDetected],
|
||||
[text.cpuIntegratedGPU, report.cpu.has_integrated_gpu ? text.detected : text.notDetected],
|
||||
[text.gpu, report.gpus.length ? formatItemCount(report.gpus.length, language) : text.notDetected],
|
||||
[text.runtimeCapability, runtimeModeLabel(report.runtime.support_mode, language)],
|
||||
[text.kvmNested, `${report.runtime.nested_virtualization ? text.supported : text.notDetected} (${translateDynamic(report.runtime.nested_detail || '-', language)})`],
|
||||
]} />
|
||||
</ProbeSection>
|
||||
|
||||
<ProbeSection title="公网与路由">
|
||||
<ProbeSection title={text.publicNetwork}>
|
||||
<ProbeRows rows={[
|
||||
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
|
||||
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
|
||||
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
|
||||
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
|
||||
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
|
||||
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
|
||||
[text.publicIPv4, report.public_ipv4.length ? report.public_ipv4.join('\n') : text.notDetected],
|
||||
[text.ipv4Address, report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : text.notDetected],
|
||||
[text.ipv4Prefix, report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : text.notDetected],
|
||||
[text.ipv6Address, report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : text.notDetected],
|
||||
[text.ipv6Prefix, report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : text.notDetected],
|
||||
[text.gateway, report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : text.notDetected],
|
||||
]} />
|
||||
</ProbeSection>
|
||||
|
||||
<ProbeTable
|
||||
title="内存条"
|
||||
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
|
||||
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
|
||||
title={text.memoryModules}
|
||||
empty={text.noMemoryModules}
|
||||
headers={[text.slot, text.capacity, text.type, text.frequency, text.vendor, text.modelSerial]}
|
||||
rows={(report.memory.modules || []).map(m => [
|
||||
m.locator || '-',
|
||||
m.size || '-',
|
||||
@@ -97,29 +101,29 @@ export default function HostReport() {
|
||||
/>
|
||||
|
||||
<ProbeTable
|
||||
title="硬盘与健康"
|
||||
empty="未检测到硬盘"
|
||||
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
|
||||
title={text.disksHealth}
|
||||
empty={text.noDisks}
|
||||
headers={[text.device, text.model, text.capacity, text.type, text.mountPoint, text.health, text.lifetime, text.powerOn, text.reads, text.writes, text.commands, text.eraseCount]}
|
||||
rows={report.disks.map(d => [
|
||||
`${d.path || d.name}\n${d.serial || ''}`,
|
||||
d.model || '-',
|
||||
formatBytes(d.size_bytes),
|
||||
d.type || (d.rotational ? 'HDD' : 'SSD'),
|
||||
diskTypeLabel(d, language),
|
||||
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
|
||||
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
|
||||
formatLifeUsed(d.smart?.life_used_percent),
|
||||
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
|
||||
formatBytes(d.smart?.read_data_bytes || 0),
|
||||
formatBytes(d.smart?.written_data_bytes || 0),
|
||||
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
|
||||
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
|
||||
`${diskHealthLabel(d.health, language)}\n${diskHealthDetail(d, language)}`,
|
||||
d.virtual ? text.unsupported : formatLifeUsed(d.smart?.life_used_percent, language),
|
||||
d.virtual ? text.unsupported : (d.smart?.power_on_hours ? `${d.smart.power_on_hours} ${text.hours}\n${formatPowerOnDays(d.smart.power_on_hours, language)}` : '-'),
|
||||
d.virtual ? text.unsupported : formatBytes(d.smart?.read_data_bytes || 0),
|
||||
d.virtual ? text.unsupported : formatBytes(d.smart?.written_data_bytes || 0),
|
||||
d.virtual ? text.unsupported : formatCommands(d.smart?.read_commands, d.smart?.write_commands, language),
|
||||
d.virtual ? text.unsupported : formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count, language),
|
||||
])}
|
||||
/>
|
||||
|
||||
<ProbeTable
|
||||
title="网卡"
|
||||
empty="未检测到网卡"
|
||||
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
|
||||
title={text.networkInterfaces}
|
||||
empty={text.noNetworkInterfaces}
|
||||
headers={[text.nic, text.status, text.driverSpeed, 'MAC', 'IPv4', 'IPv6']}
|
||||
rows={report.network_interfaces.map(n => [
|
||||
`${n.name}\n${n.model || ''}`,
|
||||
n.state || '-',
|
||||
@@ -131,25 +135,25 @@ export default function HostReport() {
|
||||
/>
|
||||
|
||||
<ProbeTable
|
||||
title="显卡"
|
||||
empty="未检测到显卡"
|
||||
headers={['名称', '厂商', '类型', '驱动']}
|
||||
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
|
||||
title={text.gpus}
|
||||
empty={text.noGPUs}
|
||||
headers={[text.name, text.vendor, text.type, text.driver]}
|
||||
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type, language), g.driver || '-'])}
|
||||
/>
|
||||
|
||||
<ProbeSection title="环境支持">
|
||||
<ProbeSection title={text.environmentSupport}>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{report.environment.map(item => (
|
||||
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
|
||||
<span>{item.label}</span>
|
||||
<span>{translateDynamic(item.label, language)}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
|
||||
{item.required ? '必要' : '可选'}
|
||||
{item.required ? text.required : text.optional}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
|
||||
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{translateDynamic(item.detail || '-', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -174,6 +178,153 @@ function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: stri
|
||||
)
|
||||
}
|
||||
|
||||
const hostReportText = {
|
||||
zh: {
|
||||
title: '宿主机信息',
|
||||
subtitle: '硬件、网络、磁盘健康与运行环境探测报告',
|
||||
refresh: '刷新',
|
||||
loading: '正在探测宿主机环境...',
|
||||
emptyReport: '暂未获取到宿主机信息',
|
||||
runtimeStatus: '运行状态',
|
||||
systemOverview: '系统概览',
|
||||
hostname: '主机名',
|
||||
os: '操作系统',
|
||||
kernel: '内核',
|
||||
generatedAt: '生成时间',
|
||||
cpuArch: 'CPU 架构',
|
||||
cpuVirtualization: 'CPU 虚拟化指令',
|
||||
cpuIntegratedGPU: 'CPU 核显',
|
||||
gpu: '显卡',
|
||||
runtimeCapability: '运行能力',
|
||||
kvmNested: 'KVM 嵌套虚拟化',
|
||||
supported: '支持',
|
||||
detected: '检测到',
|
||||
notDetected: '未检测到',
|
||||
publicNetwork: '公网与路由',
|
||||
publicIPv4: '公网 IPv4',
|
||||
ipv4Address: 'IPv4 地址',
|
||||
ipv4Prefix: 'IPv4 段',
|
||||
ipv6Address: 'IPv6 地址',
|
||||
ipv6Prefix: 'IPv6 段',
|
||||
gateway: '网关',
|
||||
memoryModules: '内存条',
|
||||
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
|
||||
slot: '插槽',
|
||||
capacity: '容量',
|
||||
type: '类型',
|
||||
frequency: '频率',
|
||||
vendor: '厂商',
|
||||
modelSerial: '型号/序列号',
|
||||
disksHealth: '硬盘与健康',
|
||||
noDisks: '未检测到硬盘',
|
||||
device: '设备',
|
||||
model: '型号',
|
||||
mountPoint: '挂载点',
|
||||
health: '健康',
|
||||
lifetime: '寿命',
|
||||
powerOn: '通电',
|
||||
reads: '读取',
|
||||
writes: '写入',
|
||||
commands: '命令数',
|
||||
eraseCount: '擦写',
|
||||
virtualDisk: '虚拟磁盘',
|
||||
virtualDiskDetail: '虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看',
|
||||
unsupported: '不支持',
|
||||
hours: '小时',
|
||||
used: '已用',
|
||||
remaining: '剩余',
|
||||
read: '读',
|
||||
write: '写',
|
||||
wear: '磨损',
|
||||
erase: '擦写',
|
||||
powerCycles: '启停',
|
||||
networkInterfaces: '网卡',
|
||||
noNetworkInterfaces: '未检测到网卡',
|
||||
nic: '网卡',
|
||||
status: '状态',
|
||||
driverSpeed: '驱动/速率',
|
||||
gpus: '显卡',
|
||||
noGPUs: '未检测到显卡',
|
||||
name: '名称',
|
||||
driver: '驱动',
|
||||
environmentSupport: '环境支持',
|
||||
required: '必要',
|
||||
optional: '可选',
|
||||
},
|
||||
en: {
|
||||
title: 'Host Info',
|
||||
subtitle: 'Hardware, network, disk health, and runtime environment report',
|
||||
refresh: 'Refresh',
|
||||
loading: 'Probing host environment...',
|
||||
emptyReport: 'No host information available',
|
||||
runtimeStatus: 'Runtime Status',
|
||||
systemOverview: 'System Overview',
|
||||
hostname: 'Hostname',
|
||||
os: 'Operating System',
|
||||
kernel: 'Kernel',
|
||||
generatedAt: 'Generated At',
|
||||
cpuArch: 'CPU Architecture',
|
||||
cpuVirtualization: 'CPU Virtualization',
|
||||
cpuIntegratedGPU: 'CPU Integrated GPU',
|
||||
gpu: 'GPU',
|
||||
runtimeCapability: 'Runtime Capability',
|
||||
kvmNested: 'KVM Nested Virtualization',
|
||||
supported: 'Supported',
|
||||
detected: 'Detected',
|
||||
notDetected: 'Not detected',
|
||||
publicNetwork: 'Public Network & Routing',
|
||||
publicIPv4: 'Public IPv4',
|
||||
ipv4Address: 'IPv4 Addresses',
|
||||
ipv4Prefix: 'IPv4 Prefixes',
|
||||
ipv6Address: 'IPv6 Addresses',
|
||||
ipv6Prefix: 'IPv6 Prefixes',
|
||||
gateway: 'Gateway',
|
||||
memoryModules: 'Memory Modules',
|
||||
noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
|
||||
slot: 'Slot',
|
||||
capacity: 'Capacity',
|
||||
type: 'Type',
|
||||
frequency: 'Frequency',
|
||||
vendor: 'Vendor',
|
||||
modelSerial: 'Model / Serial',
|
||||
disksHealth: 'Disks & Health',
|
||||
noDisks: 'No disks detected',
|
||||
device: 'Device',
|
||||
model: 'Model',
|
||||
mountPoint: 'Mount Point',
|
||||
health: 'Health',
|
||||
lifetime: 'Lifetime',
|
||||
powerOn: 'Power-on',
|
||||
reads: 'Reads',
|
||||
writes: 'Writes',
|
||||
commands: 'Commands',
|
||||
eraseCount: 'Erase Count',
|
||||
virtualDisk: 'Virtual Disk',
|
||||
virtualDiskDetail: 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
|
||||
unsupported: 'Unsupported',
|
||||
hours: 'hours',
|
||||
used: 'used',
|
||||
remaining: 'remaining',
|
||||
read: 'Read',
|
||||
write: 'Write',
|
||||
wear: 'Wear',
|
||||
erase: 'Erase',
|
||||
powerCycles: 'Power cycles',
|
||||
networkInterfaces: 'Network Interfaces',
|
||||
noNetworkInterfaces: 'No network interfaces detected',
|
||||
nic: 'NIC',
|
||||
status: 'Status',
|
||||
driverSpeed: 'Driver / Speed',
|
||||
gpus: 'GPUs',
|
||||
noGPUs: 'No GPUs detected',
|
||||
name: 'Name',
|
||||
driver: 'Driver',
|
||||
environmentSupport: 'Environment Support',
|
||||
required: 'Required',
|
||||
optional: 'Optional',
|
||||
},
|
||||
} as const
|
||||
|
||||
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
@@ -255,6 +406,26 @@ function formatMB(value: number) {
|
||||
return `${value} MB`
|
||||
}
|
||||
|
||||
function formatCPUThreads(cores: number, threads: number, language: Language) {
|
||||
return language === 'en' ? `${cores} cores / ${threads} threads` : `${cores} 核 / ${threads} 线程`
|
||||
}
|
||||
|
||||
function formatUsedMemory(usedMB: number, language: Language) {
|
||||
return language === 'en' ? `${formatMB(usedMB)} used` : `${formatMB(usedMB)} 已用`
|
||||
}
|
||||
|
||||
function formatDiskCount(count: number, language: Language) {
|
||||
return language === 'en' ? `${count} disk${count === 1 ? '' : 's'}` : `${count} 块硬盘`
|
||||
}
|
||||
|
||||
function formatProcessCount(count: number, language: Language) {
|
||||
return language === 'en' ? `${count} process${count === 1 ? '' : 'es'}` : `${count} 个进程`
|
||||
}
|
||||
|
||||
function formatItemCount(count: number, language: Language) {
|
||||
return language === 'en' ? `${count} item${count === 1 ? '' : 's'}` : `${count} 个`
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!value) return '-'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
@@ -267,20 +438,24 @@ function formatBytes(value: number) {
|
||||
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||
}
|
||||
|
||||
function formatLifeUsed(value?: number) {
|
||||
function formatLifeUsed(value: number | undefined, language: Language) {
|
||||
if (value === undefined || value === null) return '-'
|
||||
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
|
||||
const text = hostReportText[language]
|
||||
return `${value}% ${text.used}\n${Math.max(0, 100 - value)}% ${text.remaining}`
|
||||
}
|
||||
|
||||
function formatPowerOnDays(hours: number) {
|
||||
function formatPowerOnDays(hours: number, language: Language) {
|
||||
const days = Math.floor(hours / 24)
|
||||
const rest = hours % 24
|
||||
return days > 0 ? `${days} 天 ${rest} 小时` : `${hours} 小时`
|
||||
return language === 'en'
|
||||
? (days > 0 ? `${days} days ${rest} hours` : `${hours} hours`)
|
||||
: (days > 0 ? `${days} 天 ${rest} 小时` : `${hours} 小时`)
|
||||
}
|
||||
|
||||
function formatCommands(read?: number, write?: number) {
|
||||
function formatCommands(read: number | undefined, write: number | undefined, language: Language) {
|
||||
if (!read && !write) return '-'
|
||||
return `读 ${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
|
||||
const text = hostReportText[language]
|
||||
return `${text.read} ${formatCount(read || 0)}\n${text.write} ${formatCount(write || 0)}`
|
||||
}
|
||||
|
||||
function formatCount(value: number) {
|
||||
@@ -291,38 +466,62 @@ function formatCount(value: number) {
|
||||
return `${value}`
|
||||
}
|
||||
|
||||
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
|
||||
function formatWear(wear: string | undefined, erase: string | undefined, powerCycles: number | undefined, language: Language) {
|
||||
const text = hostReportText[language]
|
||||
const rows: string[] = []
|
||||
if (wear) rows.push(`磨损 ${wear}`)
|
||||
if (erase) rows.push(`擦写 ${erase}`)
|
||||
if (powerCycles) rows.push(`启停 ${powerCycles}`)
|
||||
if (wear) rows.push(`${text.wear} ${wear}`)
|
||||
if (erase) rows.push(`${text.erase} ${erase}`)
|
||||
if (powerCycles) rows.push(`${text.powerCycles} ${powerCycles}`)
|
||||
return rows.length ? rows.join('\n') : '-'
|
||||
}
|
||||
|
||||
function runtimeModeLabel(value: string) {
|
||||
function runtimeModeLabel(value: string, language: Language) {
|
||||
switch (value) {
|
||||
case 'kvm_lxc':
|
||||
return '支持 KVM + LXC'
|
||||
return language === 'en' ? 'KVM + LXC supported' : '支持 KVM + LXC'
|
||||
case 'lxc_only':
|
||||
return '仅支持 LXC'
|
||||
return language === 'en' ? 'LXC only' : '仅支持 LXC'
|
||||
default:
|
||||
return '未满足运行环境'
|
||||
return language === 'en' ? 'Runtime requirements not met' : '未满足运行环境'
|
||||
}
|
||||
}
|
||||
|
||||
function diskHealthLabel(value: string) {
|
||||
function diskHealthLabel(value: string, language: Language) {
|
||||
const text = hostReportText[language]
|
||||
switch (value) {
|
||||
case 'ok':
|
||||
return '健康'
|
||||
return language === 'en' ? 'Healthy' : '健康'
|
||||
case 'failed':
|
||||
return '异常'
|
||||
return language === 'en' ? 'Failed' : '异常'
|
||||
case 'virtual':
|
||||
return text.virtualDisk
|
||||
default:
|
||||
return '未知'
|
||||
return language === 'en' ? 'Unknown' : '未知'
|
||||
}
|
||||
}
|
||||
|
||||
function gpuTypeLabel(value: string) {
|
||||
if (value === 'integrated') return '核显'
|
||||
if (value === 'discrete') return '独显'
|
||||
function diskHealthDetail(d: { virtual?: boolean; health_detail?: string }, language: Language) {
|
||||
if (d.virtual) return hostReportText[language].virtualDiskDetail
|
||||
return translateDynamic(d.health_detail || '', language)
|
||||
}
|
||||
|
||||
function diskTypeLabel(d: { type?: string; rotational?: boolean; virtual?: boolean }, language: Language) {
|
||||
if (d.virtual || d.type === 'Virtual') return hostReportText[language].virtualDisk
|
||||
return d.type || (d.rotational ? 'HDD' : 'SSD')
|
||||
}
|
||||
|
||||
function gpuTypeLabel(value: string, language: Language) {
|
||||
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
|
||||
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
|
||||
return value || '-'
|
||||
}
|
||||
|
||||
function translateDynamic(value: string, language: Language) {
|
||||
if (language !== 'en' || !value) return value
|
||||
return translateText(value)
|
||||
.replace(/寿命已用\s*(\d+)%/g, 'Lifetime used $1%')
|
||||
.replace(/通电\s*(\d+)h/g, 'Power-on $1h')
|
||||
.replace(/写入\s*([^|]+)/g, 'Written $1')
|
||||
.replace(/读取\s*([^|]+)/g, 'Read $1')
|
||||
.replace(/介质错误\s*(\d+)/g, 'Media errors $1')
|
||||
}
|
||||
|
||||
+660
-234
File diff suppressed because it is too large
Load Diff
@@ -40,10 +40,24 @@ export type ContainerIdentifier = number | string
|
||||
export interface PortMapping {
|
||||
container_port: number
|
||||
host_port: number
|
||||
host_ip?: string
|
||||
protocol: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PublicIPv4Assignment {
|
||||
address: string
|
||||
interface?: string
|
||||
prefix_len?: number
|
||||
gateway?: string
|
||||
}
|
||||
|
||||
export interface IPv6Assignment {
|
||||
address: string
|
||||
prefix_len: number
|
||||
interface?: string
|
||||
}
|
||||
|
||||
export interface Container {
|
||||
id: number
|
||||
uuid: string
|
||||
@@ -64,9 +78,11 @@ export interface Container {
|
||||
io_speed_mbps: number
|
||||
status: string
|
||||
ip: string
|
||||
public_ipv4s?: PublicIPv4Assignment[]
|
||||
ipv6: string
|
||||
ipv6_prefix_len: number
|
||||
ipv6_interface: string
|
||||
ipv6_addresses?: IPv6Assignment[]
|
||||
vnc_port: number
|
||||
ssh_port: number
|
||||
ssh_password: string
|
||||
@@ -114,8 +130,14 @@ export interface CreateContainerRequest {
|
||||
io_speed_mbps: number
|
||||
extra_ports: number[]
|
||||
port_mapping_count: number
|
||||
assign_nat?: boolean
|
||||
snapshot_limit: number
|
||||
assign_ipv4?: boolean
|
||||
ipv4_count?: number
|
||||
public_ipv4s?: string[]
|
||||
assign_ipv6: boolean
|
||||
ipv6_count?: number
|
||||
ipv6_addresses?: string[]
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
@@ -136,6 +158,17 @@ export interface IPv6Status {
|
||||
prefixes: IPv6PrefixInfo[]
|
||||
}
|
||||
|
||||
export interface PublicIPv4Info {
|
||||
interface: string
|
||||
address: string
|
||||
prefix: string
|
||||
prefix_len?: number
|
||||
subnet_mask?: string
|
||||
gateway?: string
|
||||
is_tunnel?: boolean
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface IPv4PrefixInfo {
|
||||
interface: string
|
||||
address: string
|
||||
@@ -163,6 +196,7 @@ export interface HostInfo {
|
||||
tx_bps: number
|
||||
public_ipv4?: string
|
||||
public_ipv4_interface?: string
|
||||
public_ipv4_addresses?: PublicIPv4Info[]
|
||||
public_ipv6?: string
|
||||
public_ipv6_interface?: string
|
||||
ipv6_prefixes?: IPv6PrefixInfo[]
|
||||
@@ -207,6 +241,7 @@ export interface HostProbeReport {
|
||||
serial: string
|
||||
size_bytes: number
|
||||
type: string
|
||||
virtual?: boolean
|
||||
rotational: boolean
|
||||
mountpoints: string[]
|
||||
health: string
|
||||
@@ -453,12 +488,24 @@ export interface NAT4Route {
|
||||
lxc_name: string
|
||||
status: string
|
||||
ip: string
|
||||
host_ip: string
|
||||
host_port: number
|
||||
container_port: number
|
||||
protocol: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface IPv4Route {
|
||||
container_id: number
|
||||
container_name: string
|
||||
lxc_name: string
|
||||
status: string
|
||||
address: string
|
||||
interface: string
|
||||
prefix_len?: number
|
||||
gateway?: string
|
||||
}
|
||||
|
||||
export interface IPv6Route {
|
||||
container_id: number
|
||||
container_name: string
|
||||
@@ -471,15 +518,37 @@ export interface IPv6Route {
|
||||
|
||||
export interface RoutingInfo {
|
||||
nat4: RouteCapacity
|
||||
ipv4: RouteCapacity
|
||||
ipv6: RouteCapacity
|
||||
host_public_ipv4?: PublicIPv4Info
|
||||
public_ipv4_addresses: PublicIPv4Info[]
|
||||
ipv4_assignments: IPv4Route[]
|
||||
nat4_mappings: NAT4Route[]
|
||||
ipv6_assignments: IPv6Route[]
|
||||
ipv6_prefixes: IPv6PrefixInfo[]
|
||||
}
|
||||
|
||||
export interface PublicIPv4ScanResult extends PublicIPv4Info {
|
||||
status: string
|
||||
usable: boolean
|
||||
reason: string
|
||||
}
|
||||
|
||||
export const getRoutingInfo = () =>
|
||||
api.get<APIResponse<RoutingInfo>>('/routing')
|
||||
|
||||
export const updateRoutingPools = (payload: { items?: PublicIPv4Info[]; ipv6_prefixes?: IPv6PrefixInfo[] }) =>
|
||||
api.put<APIResponse<RoutingInfo>>('/routing', payload)
|
||||
|
||||
export const updateRoutingIPv4Pool = (items: PublicIPv4Info[]) =>
|
||||
updateRoutingPools({ items })
|
||||
|
||||
export const updateRoutingIPv6Prefixes = (ipv6_prefixes: IPv6PrefixInfo[]) =>
|
||||
updateRoutingPools({ ipv6_prefixes })
|
||||
|
||||
export const scanRoutingIPv4Segment = (payload: { cidr: string; interface: string; gateway: string; verify: boolean; limit?: number }) =>
|
||||
api.post<APIResponse<PublicIPv4ScanResult[]>>('/routing/ipv4-scan', payload)
|
||||
|
||||
// Templates
|
||||
export const getTemplates = () =>
|
||||
api.get<APIResponse<Template[]>>('/templates')
|
||||
|
||||
@@ -245,6 +245,7 @@ const exact: Record<string, string> = {
|
||||
'暂无登录记录': 'No login records',
|
||||
'暂无 NAT4 端口映射': 'No NAT4 port mappings',
|
||||
'暂无 IPv6 地址分配': 'No IPv6 assignments',
|
||||
'暂无可分配 IPv6 前缀': 'No allocatable IPv6 prefixes',
|
||||
'暂无镜像': 'No images',
|
||||
'暂无数据': 'No data',
|
||||
'容器': 'Container',
|
||||
@@ -327,6 +328,7 @@ const exact: Record<string, string> = {
|
||||
'地址': 'Address',
|
||||
'前缀': 'Prefix',
|
||||
'出口网卡': 'Uplink',
|
||||
'宿主地址': 'Host Address',
|
||||
'协议': 'Protocol',
|
||||
'说明': 'Description',
|
||||
'端口': 'Port',
|
||||
@@ -334,6 +336,12 @@ const exact: Record<string, string> = {
|
||||
'宿主机端口': 'Host Port',
|
||||
'容器 IPv4': 'Container IPv4',
|
||||
'IPv6 地址': 'IPv6 Address',
|
||||
'IPv6 前缀': 'IPv6 Prefix',
|
||||
'可分配 IPv6 前缀': 'Allocatable IPv6 Prefixes',
|
||||
'编辑前缀': 'Edit Prefixes',
|
||||
'添加 IPv6 前缀': 'Add IPv6 Prefix',
|
||||
'保存前缀': 'Save Prefixes',
|
||||
'服务商面板里的额外 IPv6 段不会自动出现在网卡里,请把可分配的前缀手动填入这里,例如 2401:b60:26:5e::2/64。': 'Extra IPv6 prefixes from the provider panel will not automatically appear on the NIC. Enter allocatable prefixes here manually, for example 2401:b60:26:5e::2/64.',
|
||||
'LXC 名称': 'LXC Name',
|
||||
'快照时间': 'Snapshot Time',
|
||||
'删除快照': 'Delete Snapshot',
|
||||
@@ -730,6 +738,10 @@ const exact: Record<string, string> = {
|
||||
'厂商': 'Vendor',
|
||||
'型号/序列号': 'Model / Serial',
|
||||
'未检测到硬盘': 'No disks detected',
|
||||
'虚拟磁盘': 'Virtual Disk',
|
||||
'不支持': 'Unsupported',
|
||||
'虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看': 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
|
||||
'虚拟Disk,真实 SMART/Lifetime/Power-on数据需在物理宿主机View': 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
|
||||
'型号': 'Model',
|
||||
'挂载点': 'Mount Point',
|
||||
'寿命': 'Lifetime',
|
||||
@@ -782,10 +794,16 @@ const artifactPatterns: RegExp[] = [
|
||||
/实时\s*Status/,
|
||||
/Create\s*Time/,
|
||||
/长期\s*Valid/,
|
||||
/虚拟Disk/,
|
||||
/宿主机View/,
|
||||
/SMART\/Lifetime\/Power-on数据/,
|
||||
]
|
||||
|
||||
const replacements: Array<[RegExp, string]> = [
|
||||
[/Back\s*列表/g, 'Back to list'],
|
||||
[/虚拟Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.'],
|
||||
[/虚拟\s*Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机\s*View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.'],
|
||||
[/真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Real SMART, lifetime, and power-on data must be checked on the physical host'],
|
||||
[/Search\s*名称、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
|
||||
[/All\s*类型/g, 'All types'],
|
||||
[/All\s*系统/g, 'All systems'],
|
||||
@@ -817,6 +835,7 @@ const replacements: Array<[RegExp, string]> = [
|
||||
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
|
||||
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
|
||||
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
|
||||
[/(\d+)\s*个前缀,(\d+)\s*个地址已分配/g, '$1 prefixes, $2 addresses assigned'],
|
||||
[/共\s*(\d+)\s*条/g, 'Total $1'],
|
||||
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
|
||||
[/,筛选后\s*(\d+)\s*个/g, ', filtered $1 items'],
|
||||
@@ -898,6 +917,11 @@ export function shouldTranslateText(value: string): boolean {
|
||||
|
||||
function cleanupTranslatedText(value: string): string {
|
||||
return value
|
||||
.replace(/虚拟Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
|
||||
.replace(/Virtual Disk,真实 SMART\/Lifetime\/Power-on数据需在物理Host View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
|
||||
.replace(/Virtual Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
|
||||
.replace(/虚拟\s*Disk/g, 'Virtual disk')
|
||||
.replace(/宿主机\s*View/g, 'physical host')
|
||||
.replace(/Back\s*List/g, 'Back to list')
|
||||
.replace(/Container\s*List/g, 'Container List')
|
||||
.replace(/Snapshot\s*List/g, 'Snapshot List')
|
||||
|
||||
Reference in New Issue
Block a user