import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { ArrowLeft, AlertTriangle, Camera, Clock, Copy, Cpu, HardDrive, Key, Maximize2, MemoryStick, Minimize2, Monitor, Network, Pencil, Play, Plus, RefreshCw, Save, Settings, Square, TerminalSquare, Trash2, UserPlus, X, } from 'lucide-react' import { default as api, addPortMapping, assignIPv6, APIResponse, Container, ContainerUsage, createSubUser, createContainerSnapshot, deleteContainer, deleteContainerSnapshot, deletePortMapping, getContainer, getContainerSnapshots, getContainerUsage, getHostInfo, getTrafficInfo, HostInfo, TrafficInfo, getEnabledImages, getFirewall, PortMapping, FirewallRule, reinstallContainer, resetSSHPassword, restartContainer, startContainer, stopContainer, Snapshot, SnapshotSchedule, Template, updateContainerExpiry, updateFirewall, updateSnapshotQuota, updateSnapshotSchedule, restoreContainerSnapshot, resetTraffic, updateTrafficLimit, updateResourceLimit, updatePortMapping, SubUser, } from '../services/api' import { useDialog } from '../components/Dialog' import { useAuth } from '../contexts/AuthContext' import WebSSHViewer from '../components/WebSSHViewer' import WebVNCViewer from '../components/WebVNCViewer' import { RingStat } from '../components/RingStats' import { copyToClipboard } from '../utils/clipboard' import ResourceStatsPanel, { ChartPoint, ResourceChartConfig, StatsRangeKey, statsRanges, } from '../components/ResourceStatsPanel' import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type ReinstallSSHAuthMode } from '../utils/sshAuth' const PUBLIC_HOST = window.location.hostname 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' type MetricPoint = { ts: number cpu: number memory: number network: number diskIO: number } type MappingDraft = { index: number | null description: string host_port: string host_ip: string container_port: string protocol: string } const emptyDraft: MappingDraft = { index: null, description: '', host_port: '', host_ip: '', container_port: '', protocol: 'all', } export default function ContainerDetail() { const { id: paramId } = useParams<{ id: string }>() const containerIdentifier = paramId || '' const navigate = useNavigate() const dialog = useDialog() const { isSubUser } = useAuth() const [container, setContainer] = useState(null) const [hostInfo, setHostInfo] = useState(null) const [usage, setUsage] = useState(null) const [history, setHistory] = useState(() => readHistory(containerIdentifier)) const [range, setRange] = useState('30m') const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) const [taskStatus, setTaskStatus] = useState('') // current task type for this container const [showSSH, setShowSSH] = useState(false) const [showVNC, setShowVNC] = useState(false) const vncFullscreenRef = useRef(null) const [vncFullscreen, setVncFullscreen] = useState(false) const [showNat, setShowNat] = useState(false) const [showMappingEditor, setShowMappingEditor] = useState(false) const [showExpiryEdit, setShowExpiryEdit] = useState(false) const [editExpiry, setEditExpiry] = useState('') const [savingExpiry, setSavingExpiry] = useState(false) const [draft, setDraft] = useState(emptyDraft) const [savingMapping, setSavingMapping] = useState(false) const [showReinstall, setShowReinstall] = useState(false) const [templates, setTemplates] = useState([]) const [selectedTemplate, setSelectedTemplate] = useState('') const [reinstallAuthMode, setReinstallAuthMode] = useState('keep') const [reinstallPasswordDraft, setReinstallPasswordDraft] = useState('') const [reinstallPublicKeyDraft, setReinstallPublicKeyDraft] = useState('') const [reinstalling, setReinstalling] = useState(false) const [traffic, setTraffic] = useState(null) const [subUser, setSubUser] = useState(null) const [showSubUser, setShowSubUser] = useState(false) const [showTrafficEdit, setShowTrafficEdit] = useState(false) const [trafficEdit, setTrafficEdit] = useState({ mode: 'total', monthly: 0, inGB: 0, outGB: 0 }) const [savingTraffic, setSavingTraffic] = useState(false) const [showResourceEdit, setShowResourceEdit] = useState(false) const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, networkDownMbps: 0, networkUpMbps: 0, ioReadMbps: 0, ioWriteMbps: 0 }) const [savingResource, setSavingResource] = useState(false) const [showPassword, setShowPassword] = useState(false) const [showResetPassword, setShowResetPassword] = useState(false) const [resetPasswordDraft, setResetPasswordDraft] = useState('') const [resetPasswordResult, setResetPasswordResult] = useState('') const [resetPasswordSaving, setResetPasswordSaving] = useState(false) const [showSnapshots, setShowSnapshots] = useState(false) const [snapshots, setSnapshots] = useState([]) const [snapshotQuota, setSnapshotQuota] = useState(3) const [snapshotQuotaDraft, setSnapshotQuotaDraft] = useState(3) const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false) const [snapshotSchedule, setSnapshotSchedule] = useState(null) const [snapshotBusy, setSnapshotBusy] = useState('') const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false) const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' }) const [showFirewall, setShowFirewall] = useState(false) const [firewallEnabled, setFirewallEnabled] = useState(false) const [firewallDefaultAction, setFirewallDefaultAction] = useState<'ACCEPT' | 'DROP'>('DROP') const [firewallRules, setFirewallRules] = useState([]) const [firewallSaving, setFirewallSaving] = useState(false) const [firewallMessage, setFirewallMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const [editingFirewallRule, setEditingFirewallRule] = useState(null) const [showFirewallEditor, setShowFirewallEditor] = useState(false) const fetchContainer = useCallback(async () => { if (!containerIdentifier) return try { const [res, hostRes] = await Promise.all([ getContainer(containerIdentifier), isSubUser ? Promise.resolve(null) : getHostInfo().catch(() => null), ]) if (res.data.data) setContainer(res.data.data) if (hostRes?.data.data) setHostInfo(hostRes.data.data) } catch (err) { console.error('Failed to fetch container:', err) } finally { setLoading(false) } }, [containerIdentifier, isSubUser]) const fetchSnapshots = useCallback(async () => { if (!containerIdentifier) return try { const res = await getContainerSnapshots(containerIdentifier) const data = res.data.data const quota = data?.quota || container?.snapshot_limit || 3 setSnapshots(data?.snapshots || []) setSnapshotQuota(quota) setSnapshotQuotaDraft(quota) setSnapshotSchedule(data?.schedule || null) } catch (err) { console.error('Failed to fetch snapshots:', err) } }, [containerIdentifier, container?.snapshot_limit]) const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => { if (!containerIdentifier || !currentContainer) return const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0 ? nextUsage.memory_total_bytes : currentContainer.ram_mb * 1024 * 1024 const memoryPct = memoryTotalBytes > 0 ? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100 : 0 const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0) const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0) const point: MetricPoint = { ts: Date.now(), cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)), memory: clamp(memoryPct), network: networkBps, diskIO: diskIOBps, } setHistory((prev) => { const cutoff = Date.now() - statsRanges['1w'] const next = [...prev.filter((item) => item.ts >= cutoff), point] localStorage.setItem(historyKey(currentContainer.uuid || containerIdentifier), JSON.stringify(next)) return next }) }, [containerIdentifier]) const fetchUsage = useCallback(async () => { if (!containerIdentifier) return try { const res = await getContainerUsage(containerIdentifier) if (res.data.data) { setUsage(res.data.data) appendUsagePoint(res.data.data, container) } } catch (err) { console.error('Failed to fetch usage:', err) } }, [containerIdentifier, container, appendUsagePoint]) useEffect(() => { if (!containerIdentifier) return setHistory(readHistory(container?.uuid || containerIdentifier)) }, [containerIdentifier, container?.uuid]) useEffect(() => { fetchContainer() // Auto-refresh container status every 5s (silent, no spinner) const timer = window.setInterval(fetchContainer, 5000) return () => window.clearInterval(timer) }, [fetchContainer]) useEffect(() => { const handleFullscreenChange = () => { setVncFullscreen(document.fullscreenElement === vncFullscreenRef.current) } document.addEventListener('fullscreenchange', handleFullscreenChange) return () => document.removeEventListener('fullscreenchange', handleFullscreenChange) }, []) const toggleVNCFullscreen = async () => { const target = vncFullscreenRef.current if (!target) return try { if (document.fullscreenElement === target) { await document.exitFullscreen() } else { await target.requestFullscreen() } } catch { setVncFullscreen((value) => !value) } } useEffect(() => { fetchUsage() const timer = window.setInterval(fetchUsage, 5000) return () => window.clearInterval(timer) }, [fetchUsage]) useEffect(() => { if (showSnapshots) fetchSnapshots() }, [showSnapshots, fetchSnapshots]) // Poll task status for this container useEffect(() => { if (!containerIdentifier) return const check = async () => { try { const { getTasks } = await import('../services/api') const res = await getTasks() if (res.data.data) { for (const t of res.data.data) { const matchesContainer = container ? t.container_id === container.id || t.container_name === container.name : false if (matchesContainer && (t.status === 'pending' || t.status === 'running')) { setTaskStatus(t.type) return } } } setTaskStatus('') } catch { /* ignore */ } } check() const t = setInterval(check, 2000) return () => clearInterval(t) }, [containerIdentifier, container?.id]) const taskActionLabels: Record = { start: '开机中...', stop: '关机中...', restart: '重启中...', delete: '删除中...', reinstall: '重装中...', } const ensureSubUserCanOperate = async () => { if (isSubUser && container?.policy_blocked) { await dialog.alert('策略临时封禁', container.policy_blocked_reason || '虚拟机被策略临时封禁,暂不能执行操作。') return false } return true } const handleAction = async (action: string) => { if (!containerIdentifier) return if (!(await ensureSubUserCanOperate())) return setActionLoading(action) try { switch (action) { case 'start': await startContainer(containerIdentifier) break case 'stop': await stopContainer(containerIdentifier) setShowSSH(false) setShowVNC(false) break case 'restart': await restartContainer(containerIdentifier) break case 'delete': if (!(await dialog.confirm('删除容器', `确定要删除容器 ${container?.name} 吗?此操作不可撤销。`))) return await deleteContainer(containerIdentifier) navigate('/containers') return } await fetchContainer() } catch (err) { console.error('Action failed:', err) dialog.alert('操作失败', (err as Error).message || '请稍后重试') } finally { setActionLoading(null) } } const openTrafficEdit = () => { if (!container) return setTrafficEdit({ mode: container.traffic_mode || 'total', monthly: container.monthly_traffic_gb || 0, inGB: container.traffic_in_gb || 0, outGB: container.traffic_out_gb || 0, }) setShowTrafficEdit(true) } const saveTrafficLimit = async () => { if (!container) return setSavingTraffic(true) try { await updateTrafficLimit(container.id, { traffic_mode: trafficEdit.mode, monthly_traffic_gb: trafficEdit.monthly, traffic_in_gb: trafficEdit.inGB, traffic_out_gb: trafficEdit.outGB, }) setShowTrafficEdit(false) fetchContainer() } catch (err) { dialog.alert('错误', '保存失败') } finally { setSavingTraffic(false) } } const openResourceEdit = () => { if (!container) return setResourceEdit({ vcpu: container.vcpu, ramMb: container.ram_mb, networkDownMbps: resourceLimitValue(container.network_down_mbps, container.network_bw_mbps), networkUpMbps: resourceLimitValue(container.network_up_mbps, container.network_bw_mbps), ioReadMbps: resourceLimitValue(container.io_read_mbps, container.io_speed_mbps), ioWriteMbps: resourceLimitValue(container.io_write_mbps, container.io_speed_mbps), }) setShowResourceEdit(true) } const saveResourceLimit = async () => { if (!container) return setSavingResource(true) try { await updateResourceLimit(container.id, { vcpu: resourceEdit.vcpu, ram_mb: resourceEdit.ramMb, network_down_mbps: resourceEdit.networkDownMbps, network_up_mbps: resourceEdit.networkUpMbps, network_bw_mbps: symmetricLimit(resourceEdit.networkDownMbps, resourceEdit.networkUpMbps), io_read_mbps: resourceEdit.ioReadMbps, io_write_mbps: resourceEdit.ioWriteMbps, io_speed_mbps: symmetricLimit(resourceEdit.ioReadMbps, resourceEdit.ioWriteMbps), }) setShowResourceEdit(false) fetchContainer() } catch (err) { dialog.alert('错误', '保存失败') } finally { setSavingResource(false) } } const syncFirewallState = (enabled: boolean, defaultAction: 'ACCEPT' | 'DROP', rules: FirewallRule[]) => { const nextRules = rules.map(r => ({ ...r })) setFirewallEnabled(enabled) setFirewallDefaultAction(defaultAction) setFirewallRules(nextRules) setContainer(prev => prev ? { ...prev, firewall_enabled: enabled, firewall_default_action: defaultAction, firewall_rules: nextRules.map(r => ({ ...r })), } : prev) } const openFirewall = async () => { if (!container) return syncFirewallState(container.firewall_enabled || false, container.firewall_default_action || 'DROP', container.firewall_rules || []) setFirewallMessage(null) setShowFirewall(true) try { const res = await getFirewall(container.id) const data = res.data.data if (data) syncFirewallState(data.enabled, data.default_action || 'DROP', data.rules || []) } catch (err) { console.error('Failed to load firewall:', err) } } const saveFirewall = async () => { if (!container) return setFirewallSaving(true) try { const res = await updateFirewall(container.id, { enabled: firewallEnabled, default_action: firewallDefaultAction, rules: firewallRules }) const data = res.data.data if (data) { syncFirewallState(data.enabled, data.default_action || 'DROP', data.rules || []) } setFirewallMessage({ type: 'success', text: '防火墙设置已保存并应用' }) fetchContainer() } catch (err: any) { const message = err?.response?.data?.message || '保存防火墙设置失败' setFirewallMessage({ type: 'error', text: message }) dialog.alert('错误', message) } finally { setFirewallSaving(false) } } const addFirewallRule = () => { const hasIPv4Firewall = (container?.public_ipv4s?.length || 0) > 0 || Math.max(container?.port_mapping_limit || 0, container?.port_mappings?.length || 0) > 0 const hasIPv6Firewall = !!container?.ipv6 || (container?.ipv6_addresses?.length || 0) > 0 setEditingFirewallRule({ id: '', network: hasIPv4Firewall ? 'ipv4' : hasIPv6Firewall ? 'ipv6' : 'ipv4', direction: 'in', protocol: 'tcp', port: '', source_ip: '', action: 'DROP', description: '', enabled: true, }) setShowFirewallEditor(true) } const saveFirewallRule = (rule: FirewallRule) => { if (rule.id) { // Update existing setFirewallRules(firewallRules.map(r => r.id === rule.id ? rule : r)) } else { // Add new with temporary ID const newRule = { ...rule, id: `tmp-${Date.now()}` } setFirewallRules([...firewallRules, newRule]) } setShowFirewallEditor(false) setEditingFirewallRule(null) } const deleteFirewallRule = (ruleId: string) => { setFirewallRules(firewallRules.filter(r => r.id !== ruleId)) } const toggleFirewallRule = (ruleId: string) => { setFirewallRules(firewallRules.map(r => r.id === ruleId ? { ...r, enabled: !r.enabled } : r)) } const openReinstall = async () => { try { const res = await getEnabledImages(container?.virtualization || 'lxc') if (res.data.data) { setTemplates(res.data.data) setSelectedTemplate(res.data.data[0]?.id || '') } setReinstallAuthMode('keep') setReinstallPasswordDraft('') setReinstallPublicKeyDraft('') setShowReinstall(true) } catch (err) { console.error(err) } } const handleCreateSubUser = async () => { if (!container?.uuid) return try { const res = await createSubUser(container.uuid) if (res.data.success && res.data.data) { setSubUser(res.data.data) setShowSubUser(true) } } catch (err) { console.error(err) } } const handleReinstall = async () => { if (!containerIdentifier || !selectedTemplate) return const linuxTemplate = !isWindowsTemplate(selectedTemplate) if (linuxTemplate && reinstallAuthMode === 'password') { const validationError = sshPasswordError(reinstallPasswordDraft.trim()) if (validationError) { await dialog.alert('密码格式不正确', validationError) return } } if (linuxTemplate && reinstallAuthMode === 'key') { const validationError = sshPublicKeyError(reinstallPublicKeyDraft) if (validationError) { await dialog.alert('SSH Key 格式不正确', validationError) return } } setReinstalling(true) try { await reinstallContainer(containerIdentifier, selectedTemplate, linuxTemplate ? { ssh_auth_mode: reinstallAuthMode, ssh_password: reinstallAuthMode === 'password' ? reinstallPasswordDraft.trim() : '', ssh_public_key: reinstallAuthMode === 'key' ? reinstallPublicKeyDraft.trim() : '', } : undefined) setShowReinstall(false) setShowSSH(false) setShowVNC(false) await fetchContainer() } catch (err) { console.error('Reinstall failed:', err) dialog.alert('重装失败', '请稍后重试') } finally { setReinstalling(false) } } const generateResetPassword = () => { setResetPasswordDraft(generateSSHPassword()) setResetPasswordResult('') } const resetPasswordError = (password: string) => { return sshPasswordError(password) } const handleResetPassword = async () => { if (!containerIdentifier) return const password = resetPasswordDraft.trim() const validationError = resetPasswordError(password) if (validationError) { await dialog.alert('密码格式不正确', validationError) return } setResetPasswordSaving(true) try { const res = await resetSSHPassword(containerIdentifier, password) if (res.data.success) { const nextPassword = (res.data.data as { password: string })?.password || password setResetPasswordResult(nextPassword) setResetPasswordDraft(nextPassword) await fetchContainer() } } catch (err: unknown) { console.error(err) const error = err as { response?: { data?: { message?: string } } } dialog.alert('密码重置失败', error.response?.data?.message || '请稍后重试') } finally { setResetPasswordSaving(false) } } const openResetPassword = () => { setResetPasswordDraft('') setResetPasswordResult('') setShowResetPassword(true) } const handleAssignIPv6 = async () => { if (!containerIdentifier) return setActionLoading('ipv6') try { await assignIPv6(containerIdentifier) await fetchContainer() } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } dialog.alert('IPv6 allocation failed', error.response?.data?.message || 'Please try again later') } finally { setActionLoading(null) } } const openAddMapping = () => { if (isSubUser && container?.policy_blocked) return setDraft(emptyDraft) setShowNat(true) setShowMappingEditor(true) } const openEditMapping = (pm: PortMapping, index: number) => { if (isSubUser && container?.policy_blocked) return setDraft({ 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', }) setShowNat(true) setShowMappingEditor(true) } const submitMapping = async (): Promise => { if (!containerIdentifier) return false if (!(await ensureSubUserCanOperate())) return false if (draft.index === null && container) { const currentCount = container.port_mappings?.length || 0 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 } } const containerPort = parseInt(draft.container_port, 10) const hostPort = isSubUser ? 0 : (draft.host_port.trim() ? parseInt(draft.host_port, 10) : 0) if (!containerPort || containerPort < 1 || containerPort > 65535) { dialog.alert('输入错误', '请输入有效的内部端口') return false } // For sub-users editing existing mappings: use original host_port and protocol let hostPortVal = hostPort let protocolVal = draft.protocol if (isSubUser && draft.index !== null && container?.port_mappings?.[draft.index]) { const orig = container.port_mappings[draft.index] hostPortVal = orig.host_port protocolVal = orig.protocol || 'all' } 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}`, } setSavingMapping(true) try { if (draft.index === null) { await addPortMapping(containerIdentifier, payload) } else { await updatePortMapping(containerIdentifier, draft.index, payload) } setDraft(emptyDraft) await fetchContainer() return true } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } dialog.alert('操作失败', error.response?.data?.message || '保存端口映射失败') return false } finally { setSavingMapping(false) } } const removeMapping = async (index: number) => { if (!containerIdentifier || !(await dialog.confirm('删除映射', '确定要删除这条映射规则吗?'))) return if (!(await ensureSubUserCanOperate())) return try { await deletePortMapping(containerIdentifier, index) await fetchContainer() if (draft.index === index) setDraft(emptyDraft) } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } dialog.alert('操作失败', error.response?.data?.message || '删除端口映射失败') } } const handleCreateSnapshot = async () => { if (!containerIdentifier) return if (!(await ensureSubUserCanOperate())) return if (isSubUser && snapshots.length >= snapshotQuota) { await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。') return } if (container?.status === 'running') { const confirmed = await dialog.confirm( '拍摄快照', `拍摄快照需要先关机,完成后会自动重启容器 ${container.name}。是否继续?` ) if (!confirmed) return } setSnapshotBusy('create') try { await createContainerSnapshot(containerIdentifier) await Promise.all([fetchSnapshots(), fetchContainer()]) } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } await dialog.alert('创建快照失败', error.response?.data?.message || '请稍后重试。') } finally { setSnapshotBusy('') } } const openSnapshotSchedule = () => { if (isSubUser && container?.policy_blocked) return setSnapshotScheduleDraft({ intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24), time: snapshotSchedule?.time || '03:00', }) setShowSnapshotSchedule(true) } const saveSnapshotSchedule = async (enabled: boolean) => { if (!containerIdentifier) return if (!(await ensureSubUserCanOperate())) return const intervalHours = snapshotScheduleDraft.intervalHours const scheduleTime = snapshotScheduleDraft.time || '03:00' if (enabled && intervalHours < 24) { await dialog.alert('参数错误', '自动快照周期最低是 1 天一次。') return } setSnapshotBusy('schedule') try { await updateSnapshotSchedule(containerIdentifier, enabled, intervalHours, scheduleTime) await Promise.all([fetchSnapshots(), fetchContainer()]) setShowSnapshotSchedule(false) } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } await dialog.alert('定时快照失败', error.response?.data?.message || '请稍后重试。') } finally { setSnapshotBusy('') } } const saveSnapshotQuota = async () => { if (!containerIdentifier || isSubUser) return const nextQuota = Math.max(1, Math.round(snapshotQuotaDraft || 1)) setSnapshotBusy('quota') try { await updateSnapshotQuota(containerIdentifier, nextQuota) setSnapshotQuota(nextQuota) setSnapshotQuotaDraft(nextQuota) setEditingSnapshotQuota(false) await Promise.all([fetchSnapshots(), fetchContainer()]) } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } await dialog.alert('保存快照配额失败', error.response?.data?.message || '请稍后重试。') } finally { setSnapshotBusy('') } } const handleDeleteSnapshot = async (snapshot: Snapshot) => { if (!containerIdentifier) return if (!(await ensureSubUserCanOperate())) return if (!(await dialog.confirm('删除快照', `确定删除 ${snapshot.created_at} 的快照吗?`))) return setSnapshotBusy(snapshot.id) try { await deleteContainerSnapshot(containerIdentifier, snapshot.id) await fetchSnapshots() } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } await dialog.alert('删除快照失败', error.response?.data?.message || '请稍后重试。') } finally { setSnapshotBusy('') } } const handleRestoreSnapshot = async (snapshot: Snapshot) => { if (!containerIdentifier) return if (!(await ensureSubUserCanOperate())) return if (!(await dialog.confirm('恢复快照', `确定恢复到 ${snapshot.created_at} 的快照吗?当前容器数据会被覆盖。`))) return setSnapshotBusy(snapshot.id) try { await restoreContainerSnapshot(containerIdentifier, snapshot.id) await Promise.all([fetchSnapshots(), fetchContainer()]) } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } await dialog.alert('恢复快照失败', error.response?.data?.message || '请稍后重试。') } finally { setSnapshotBusy('') } } const copyText = async (text: string) => { await copyToClipboard(text) } if (loading) { return (
) } if (!container) { return (

容器不存在

) } const isRunning = container.status === 'running' const isInitializing = container.status === 'initializing' const isKVM = (container.virtualization || 'lxc') === 'kvm' const isWindows = container.template?.includes('windows') const reinstallLinuxTemplate = !isWindowsTemplate(selectedTemplate) const canOpenVNC = isKVM && isRunning const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false const isPolicyBlocked = !!container.policy_blocked const isSubUserPolicyBlocked = isSubUser && isPolicyBlocked const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁' 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 hasIndependentIPv4 = assignedIPv4List.length > 0 const hasIndependentIPv6 = ipv6List.length > 0 const defaultConnPort = isWindows ? 3389 : 22 let publicEndpoint = '-' let sshCommand = '' if (hasIndependentIPv4) { // Direct connection via independent IPv4 — all ports forwarded publicEndpoint = `${assignedIPv4List[0]}:${defaultConnPort}` if (!isWindows) { sshCommand = `ssh root@${assignedIPv4List[0]}` } } else if (hasIndependentIPv6) { publicEndpoint = `[${ipv6List[0]}]:${defaultConnPort}` if (!isWindows) { sshCommand = `ssh root@[${ipv6List[0]}]` } } else if (container.ssh_port > 0) { // NAT port mapping mode publicEndpoint = `${publicHost}:${container.ssh_port}` sshCommand = `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 ) const filtered = filterHistory(history, range) const cpuPct = clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))) const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0 ? usage.memory_total_bytes : container.ram_mb * 1024 * 1024 const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0 const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0 const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0 const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0) const rx = usage?.network_rx_bps || 0 const networkDownLimit = resourceLimitValue(container.network_down_mbps, container.network_bw_mbps) const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps) const netPct = Math.max( directionUsagePercent(usage?.network_rx_bps || 0, networkDownLimit, 125000, 125000000), directionUsagePercent(usage?.network_tx_bps || 0, networkUpLimit, 125000, 125000000), ) const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0) const ioReadLimit = resourceLimitValue(container.io_read_mbps, container.io_speed_mbps) const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps) const diskIOPct = Math.max( directionUsagePercent(usage?.disk_read_bps || 0, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024), directionUsagePercent(usage?.disk_write_bps || 0, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024), ) const mappingCount = container.port_mappings?.length || 0 const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount) const hasNATQuota = mappingLimit > 0 const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked const hasFirewallIPv4 = hasIndependentIPv4 || hasNATQuota const firewallNetworkOptions: Array<{ value: NonNullable; label: string }> = [] if (hasFirewallIPv4) { firewallNetworkOptions.push({ value: 'ipv4', label: hasIndependentIPv4 ? 'IPv4(公网 IPv4)' : 'IPv4(NAT)', }) } if (hasIndependentIPv6) { firewallNetworkOptions.push({ value: 'ipv6', label: 'IPv6' }) } if (hasFirewallIPv4 && hasIndependentIPv6) { firewallNetworkOptions.push({ value: 'all', label: '全部网络' }) } const managementUrl = subUser?.access_code ? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}` : '' const charts: ResourceChartConfig[] = [ { title: 'CPU 使用率', icon: , current: clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))), points: toChartPoints(filtered, 'cpu'), max: 100, formatValue: formatPercent, detail: `${container.vcpu} 核`, }, { title: '内存使用', icon: , current: ramPct, points: toChartPoints(filtered, 'memory'), max: 100, formatValue: formatPercent, detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${formatBytes(ramTotalBytes)}`, }, { title: '网络流量', icon: , current: networkBps, points: toChartPoints(filtered, 'network'), formatValue: formatRate, detail: `入 ${formatRate(usage?.network_rx_bps || 0)} / 出 ${formatRate(usage?.network_tx_bps || 0)},限速占用 ${netPct.toFixed(1)}%,累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`, }, { title: '磁盘IO', icon: , current: diskIOBps, points: toChartPoints(filtered, 'diskIO'), formatValue: formatRate, detail: `读 ${formatRate(usage?.disk_read_bps || 0)} / 写 ${formatRate(usage?.disk_write_bps || 0)},限速占用 ${diskIOPct.toFixed(1)}%,累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`, }, ] return (
{getTemplateIcon(container.template || '') || }

{container.name}

系统 {container.template} 类型 {(container.virtualization || 'lxc').toUpperCase()} 内网 {container.ip || '-'} {hasIndependentIPv4 ? ( 独立 IPv4 {assignedIPv4List[0]} ) : ( IPv4 NAT {hasNATQuota ? `${mappingCount} 条` : '未分配'} )} {isWindows ? 'RDP' : 'SSH'} {publicEndpoint} {isPolicyBlocked && 策略封禁}
{!isRunning ? ( handleAction('start')}> {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'} ) : ( <> handleAction('stop')}> {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'} handleAction('restart')}> {isSubUserPolicyBlocked ? '已封禁' : isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'} {!isWindows && ( setShowSSH(true)}> WebSSH )} {isKVM && ( setShowVNC(true)}> WebVNC )} )} {!isSubUser && ( 管理链接 )} {!hasIndependentIPv4 && hasNATQuota && ( setShowNat(true)}> IPv4 NAT 管理 )} 防火墙 setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}> 快照 {isExpired ? '已到期' : taskStatus === 'reinstall' ? taskActionLabels['reinstall'] : '重装'} {!isSubUser && ( handleAction('delete')}> {taskStatus === 'delete' ? taskActionLabels['delete'] : '删除'} )}
{isSubUserPolicyBlocked && (
虚拟机被策略临时封禁
{policyBlockedText}
)}
重置 SSH 密码 ) : undefined} > {isSubUserPolicyBlocked ? (
虚拟机被策略临时封禁,连接信息暂不可用。
) : isWindows ? ( <>
管理员密码
setShowPassword(!showPassword)} title={showPassword ? '点击隐藏' : '点击显示'} > {container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'} {container.ssh_password && ( )}
{container.vnc_port > 0 && ( )} ) : ( <>
SSH 密码
setShowPassword(!showPassword)} title={showPassword ? '点击隐藏' : '点击显示'} > {container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'} {container.ssh_password && ( )}
)}
) : undefined}> {!isSubUser && ipv6List.length === 0 && ( )} {!isSubUser && ( )}
{/* Container resource ring stats (matching host dashboard style) */} {container && (

状态

)} {/* Traffic usage bar */} {container && (

月流量

{!isSubUser && ( )}
)} {/* Traffic limit edit modal */} {showTrafficEdit && ( setShowTrafficEdit(false)}>
{trafficEdit.mode === 'total' ? (
setTrafficEdit({ ...trafficEdit, monthly: Math.max(0, Number(e.target.value)) })} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
) : ( <>
setTrafficEdit({ ...trafficEdit, inGB: Math.max(0, Number(e.target.value)) })} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
setTrafficEdit({ ...trafficEdit, outGB: Math.max(0, Number(e.target.value)) })} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
)}
)} { fetchContainer(); fetchUsage() }} charts={charts} /> {showResetPassword && ( setShowResetPassword(false)}>
{ setResetPasswordDraft(e.target.value); setResetPasswordResult('') }} placeholder="请输入 8-64 位,至少包含字母和数字" className={inputClass} />
{resetPasswordDraft && resetPasswordError(resetPasswordDraft) && (

{resetPasswordError(resetPasswordDraft)}

)}
{resetPasswordResult && (
密码已修改成功
{resetPasswordResult}
)}

Linux LXC/KVM 修改 root SSH 密码通常无需重启;KVM 需要虚拟机运行且 guest agent 或 SSH 可用。

)} {showSSH && ( setShowSSH(false)} wide>
{isRunning ? ( setShowSSH(false)} /> ) : (
容器未运行,请先开机
)}
)} {showVNC && ( setShowVNC(false)} wide flush extra={ } >
{canOpenVNC ? ( setShowVNC(false)} /> ) : (
VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面
)}
)} {showSnapshots && ( { setShowSnapshots(false) setEditingSnapshotQuota(false) }} wide extra={
} >
快照数量: {snapshots.length}
子用户配额: {snapshotQuota} {!isSubUser && ( )}
定时状态: {snapshotSchedule?.enabled ? `已开启,每 ${formatScheduleInterval(snapshotSchedule.interval_hours || 24)},${snapshotSchedule.time || '03:00'} 执行` : '未开启'}
{snapshotSchedule?.next_run && (
下次执行:{formatDateTime(snapshotSchedule.next_run)}
)}
{editingSnapshotQuota && !isSubUser && (
setSnapshotQuotaDraft(Math.max(1, Math.round(Number(event.target.value) || 1)))} className="w-44 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" />
)}
)} {showSnapshotSchedule && ( setShowSnapshotSchedule(false)}>
setSnapshotScheduleDraft({ ...snapshotScheduleDraft, time: e.target.value || '03:00' })} className={inputClass} />
{`每 ${formatScheduleInterval(snapshotScheduleDraft.intervalHours)} 在 ${snapshotScheduleDraft.time || '03:00'} 执行。`}
{snapshotSchedule?.enabled ? ( ) :
}
)} {showFirewall && ( { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={ !isSubUser && ( ) }>
{/* Global toggle */}
防火墙
{firewallEnabled ? (firewallDefaultAction === 'DROP' ? '已启用,未匹配规则的流量将被拒绝' : '已启用,未匹配规则的流量将被放行') : '未启用时不接管该容器流量'}
默认动作
没有命中下方规则时如何处理
网络范围
{firewallNetworkOptions.length > 0 ? `可配置:${firewallNetworkOptions.filter((option) => option.value !== 'all').map((option) => option.label).join('、')}。` : '当前容器未分配 IPv4 NAT、独立公网 IPv4 或 IPv6,暂无可配置网络。'} {hasFirewallIPv4 ? ` IPv4 规则覆盖${hasIndependentIPv4 ? '独立公网 IPv4' : 'IPv4 NAT 端口映射'}。` : ''} {hasNATQuota && !hasIndependentIPv4 ? ' NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。' : ''} {hasIndependentIPv6 ? ' IPv6 规则覆盖该容器已分配的 IPv6 地址。' : ''}
{firewallMessage && (
{firewallMessage.text}
)} {/* Rules table */}
{!isSubUser && } {firewallRules.map((rule) => ( {!isSubUser && ( )} ))} {firewallRules.length === 0 && ( )}
状态 网络 方向 协议 端口 来源/目标 IP 动作 描述操作
{(rule.network || 'ipv4') === 'ipv6' ? 'IPv6' : (rule.network || 'ipv4') === 'all' ? '全部' : 'IPv4'} {rule.direction === 'in' ? '入站' : '出站'} {rule.protocol.toUpperCase()} {rule.port || '全部'} {rule.source_ip || '任意'} {rule.action === 'ACCEPT' ? '放行' : '拒绝'} {rule.description || '-'}
暂无防火墙规则
{/* Save button */} {!isSubUser && (
)}
)} {showFirewallEditor && editingFirewallRule && ( { setShowFirewallEditor(false); setEditingFirewallRule(null) }}>
{firewallNetworkOptions.length > 0 ? ( ) : ( )} 容器 22,这里填 22' : '入站填容器服务端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000') : '出站填远端目标端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000') : '端口仅适用于 TCP/UDP'} > setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })} placeholder={editingFirewallRule.protocol === 'tcp' || editingFirewallRule.protocol === 'udp' ? '如: 22 或 80,443 或 8000-9000' : '当前协议不使用端口'} disabled={editingFirewallRule.protocol !== 'tcp' && editingFirewallRule.protocol !== 'udp'} className={`${inputClass} disabled:bg-gray-100 disabled:text-gray-400`} /> setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })} placeholder={(editingFirewallRule.network || 'ipv4') === 'ipv6' ? '如: 2001:db8::/64' : (editingFirewallRule.network || 'ipv4') === 'all' ? '如: 192.168.1.0/24 或 2001:db8::/64' : '如: 192.168.1.0/24'} className={inputClass} /> setEditingFirewallRule({ ...editingFirewallRule, description: e.target.value })} placeholder="规则描述" className={inputClass} />
)} {showNat && !hasIndependentIPv4 && ( { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={ !isSubUser && canAddMapping && ( ) }>
{hasNATQuota ? ( <>端口配额:{mappingCount}/{mappingLimit} ) : ( 未分配 IPv4 NAT 端口配额 )}
{!isSubUser && hasNATQuota && !canAddMapping && (
已达到管理员分配的 IPv4 NAT 端口配额
)}
{} : removeMapping} isSubUser={isSubUser} />
)} {showMappingEditor && ( { setShowMappingEditor(false); setDraft(emptyDraft) }} > { setShowMappingEditor(false); setDraft(emptyDraft) }} onSubmit={async () => { if (await submitMapping()) { setShowMappingEditor(false) setDraft(emptyDraft) } }} /> )} {showSubUser && subUser && ( setShowSubUser(false)}>
地址
{managementUrl}
密码
{subUser.password || ''}
)} {showReinstall && ( setShowReinstall(false)}>

重装系统会删除容器内所有数据,请谨慎操作。

{reinstallLinuxTemplate && (
登录方式
{([ ['keep', '保留当前密码'], ['auto_password', '生成新密码'], ['password', '自定义密码'], ['key', 'SSH Key'], ] as Array<[ReinstallSSHAuthMode, string]>).map(([mode, label]) => ( ))}
{reinstallAuthMode === 'password' && (
setReinstallPasswordDraft(event.target.value)} className={inputClass} placeholder="RootPass123" />
)} {reinstallAuthMode === 'key' && (