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, PortMapping, reinstallContainer, resetSSHPassword, restartContainer, startContainer, stopContainer, Snapshot, SnapshotSchedule, Template, updateContainerExpiry, 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' 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 container_port: string protocol: string } const emptyDraft: MappingDraft = { index: null, description: '', host_port: '', 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 [showNatAdd, setShowNatAdd] = 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 [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, ioMbps: 500, bwMbps: 100 }) 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 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, ioMbps: container.io_speed_mbps || 0, bwMbps: container.network_bw_mbps || 0, }) setShowResourceEdit(true) } const saveResourceLimit = async () => { if (!container) return setSavingResource(true) try { await updateResourceLimit(container.id, { vcpu: resourceEdit.vcpu, ram_mb: resourceEdit.ramMb, io_speed_mbps: resourceEdit.ioMbps, network_bw_mbps: resourceEdit.bwMbps, }) setShowResourceEdit(false) fetchContainer() } catch (err) { dialog.alert('错误', '保存失败') } finally { setSavingResource(false) } } 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 || '') } 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 setReinstalling(true) try { await reinstallContainer(containerIdentifier, selectedTemplate) setShowReinstall(false) setShowSSH(false) setShowVNC(false) await fetchContainer() } catch (err) { console.error('Reinstall failed:', err) dialog.alert('重装失败', '请稍后重试') } finally { setReinstalling(false) } } const generateResetPassword = () => { const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' const digits = '23456789' const symbols = '!@#$%*-_+=' const all = letters + digits + symbols const pick = (chars: string) => chars[Math.floor(Math.random() * chars.length)] let password = pick(letters) + pick(digits) while (password.length < 16) password += pick(all) setResetPasswordDraft(password.split('').sort(() => Math.random() - 0.5).join('')) setResetPasswordResult('') } const resetPasswordError = (password: string) => { if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位' if (/\s/.test(password)) return '密码不能包含空白字符' if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母' if (!/\d/.test(password)) return '密码至少需要包含数字' return '' } 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) } const openEditMapping = (pm: PortMapping, index: number) => { if (isSubUser && container?.policy_blocked) return if (isSubUser) { // Sub-user: only edit container_port in a simple modal setDraft({ index, description: pm.description, host_port: String(pm.host_port), container_port: String(pm.container_port), protocol: pm.protocol || 'all', }) return } setDraft({ index, description: pm.description, host_port: String(pm.host_port), container_port: String(pm.container_port), protocol: pm.protocol || 'all', }) } 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 = container.port_mapping_limit || Math.max(currentCount, 2) 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, 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 isKVM = (container.virtualization || 'lxc') === 'kvm' const isWindows = container.template?.includes('windows') 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 publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST 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 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 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 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)},累计 ${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)},累计 ${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 || '-'} NAT {mappingCount} 条 {isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port} {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 && ( 管理链接 )} <> setShowNat(true)}> NAT 管理 setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}> 快照 {!isSubUser && ( {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}> 0 ? `${container.network_bw_mbps} Mbps` : '不限制'} /> 0 ? `${container.io_speed_mbps} MB/s` : '不限制'} /> {!isSubUser && !container.ipv6 && ( )} {!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 ? ( ) :
}
)} {showNat && ( { setShowNat(false); setDraft(emptyDraft); setShowNatAdd(false) }} wide extra={ !isSubUser && canAddMapping && !showNatAdd && ( ) }>
端口配额:{mappingCount}/{mappingLimit}
{!isSubUser && !canAddMapping && (
已达到管理员分配的 NAT 端口配额
)}
{} : removeMapping} isSubUser={isSubUser} /> {showNatAdd && !isSubUser && (

添加映射规则

setDraft({ ...draft, description: e.target.value })} className={inputClass} placeholder="Web / API" />
setDraft({ ...draft, host_port: e.target.value })} className={inputClass + ' flex-1'} placeholder="默认同内部" />
setDraft({ ...draft, container_port: e.target.value })} className={inputClass} placeholder="例如 80" />
)} {/* Sub-user edit port modal: only container_port is editable */} {draft.index !== null && isSubUser && (

修改端口映射

setDraft({ ...draft, container_port: e.target.value })} className={inputClass} placeholder="例如 80" />
)}
)} {showSubUser && subUser && ( setShowSubUser(false)}>
地址
{managementUrl}
密码
{subUser.password || ''}
)} {showReinstall && ( setShowReinstall(false)}>

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

)} {showExpiryEdit && ( setShowExpiryEdit(false)}>

当前:{formatExpiration(container.expires_at)}

setEditExpiry(e.target.value)} min={new Date().toISOString().slice(0, 10)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" />
)} {/* Resource limit edit modal */} {showResourceEdit && ( setShowResourceEdit(false)}>
setResourceEdit({ ...resourceEdit, vcpu: clampVCPU(Number(e.target.value), maxVCPU) })} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
setResourceEdit({ ...resourceEdit, ramMb: clampResourceInt(Number(e.target.value), 128, maxRAMMB, 128) })} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
setResourceEdit({ ...resourceEdit, bwMbps: Math.max(0, Number(e.target.value) || 0) })} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
setResourceEdit({ ...resourceEdit, ioMbps: Math.max(0, Number(e.target.value) || 0) })} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />

磁盘容量不支持动态修改。修改后运行中的容器会立即应用新的 cgroup 限制。

)}
) } function RangeSwitch({ value, onChange }: { value: StatsRangeKey; onChange: (value: StatsRangeKey) => void }) { return (
{(['30m', '1h', '1d', '1w'] as StatsRangeKey[]).map((item) => ( ))}
) } function StatusBadge({ running }: { running: boolean }) { return ( {running ? '运行中' : '已停止'} ) } function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate' | 'red'; children: ReactNode }) { const classes = { blue: 'bg-blue-50 text-blue-700 border-blue-100', emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100', amber: 'bg-amber-50 text-amber-700 border-amber-100', violet: 'bg-violet-50 text-violet-700 border-violet-100', slate: 'bg-slate-50 text-slate-700 border-slate-100', red: 'bg-red-50 text-red-700 border-red-100', } return {children} } function ActionButton({ children, onClick, disabled, dark = false }: { children: ReactNode; onClick: () => void; disabled?: boolean; dark?: boolean }) { return ( ) } function MetricChart({ icon, title, value, detail, points, percent, color }: { icon: ReactNode; title: string; value: string; detail: string; points: number[]; percent: number; color: 'blue' | 'emerald' | 'amber' | 'violet' }) { const colors = { blue: { text: 'text-blue-700', bg: 'bg-blue-50', bar: 'bg-blue-500', stroke: '#3b82f6', fill: '#dbeafe' }, emerald: { text: 'text-emerald-700', bg: 'bg-emerald-50', bar: 'bg-emerald-500', stroke: '#10b981', fill: '#d1fae5' }, amber: { text: 'text-amber-700', bg: 'bg-amber-50', bar: 'bg-amber-500', stroke: '#f59e0b', fill: '#fef3c7' }, violet: { text: 'text-violet-700', bg: 'bg-violet-50', bar: 'bg-violet-500', stroke: '#8b5cf6', fill: '#ede9fe' }, } const palette = colors[color] return (
{icon}
{title}
{value}
{detail}
) } function MiniChart({ points, stroke, fill }: { points: number[]; stroke: string; fill: string }) { const width = 320 const height = 76 const values = points.length > 1 ? points : [0, ...points] const polyline = values.map((value, index) => { const x = values.length === 1 ? 0 : (index / (values.length - 1)) * width const y = height - (clamp(value) / 100) * height return `${x},${y}` }).join(' ') const area = `0,${height} ${polyline} ${width},${height}` return ( ) } function Panel({ title, children, extra }: { title: string; children: ReactNode; extra?: ReactNode }) { return (

{title}

{extra}
{children}
) } function PlainRow({ label, value, mono = false, copyValue, onCopy, children }: { label: string; value: string; mono?: boolean; copyValue?: string; onCopy?: (value: string) => void; children?: ReactNode }) { return (
{label}
{value} {children} {copyValue && onCopy && ( )}
) } function SnapshotTable({ snapshots, busy, onRestore, onDelete }: { snapshots: Snapshot[] busy: string onRestore: (snapshot: Snapshot) => void onDelete: (snapshot: Snapshot) => void }) { if (snapshots.length === 0) { return

暂无快照

} return (
快照时间类型创建者大小 {snapshots.map((snapshot) => ( ))}
操作
{snapshot.created_at} {snapshot.scheduled ? '定时' : '手动'} {snapshot.created_by || '-'} {formatBytes(snapshot.size_bytes || 0)}
) } function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false, isSubUser = false }: { mappings: PortMapping[]; publicHost: string; onEdit: (pm: PortMapping, index: number) => void; onDelete: (index: number) => void; compact?: boolean; isSubUser?: boolean }) { if (mappings.length === 0) { return

暂无端口映射

} return (
名称协议外部端口内部端口 {!compact && } {mappings.map((pm, index) => { const isSSH = pm.description === 'SSH' || pm.container_port === 22 || pm.description === 'RDP' || pm.container_port === 3389 return ( {!compact && ( )} ) })}
操作
{pm.description} {isSSH && 默认} {pm.protocol.toUpperCase()} {publicHost}:{pm.host_port} {pm.container_port}
{!isSubUser && ( )}
) } function TableHead({ children }: { children: ReactNode }) { return {children} } function Field({ label, children }: { label: string; children: ReactNode }) { return ( ) } function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) { return (

{title}

{extra}
{children}
) } function filterHistory(history: MetricPoint[], range: StatsRangeKey) { const cutoff = Date.now() - statsRanges[range] return history.filter((point) => point.ts >= cutoff) } function readHistory(containerName: string): MetricPoint[] { if (!containerName) return [] try { const raw = localStorage.getItem(historyKey(containerName)) if (!raw) return [] const parsed = JSON.parse(raw) as MetricPoint[] const cutoff = Date.now() - statsRanges['1w'] return parsed.filter((point) => point.ts >= cutoff) } catch { return [] } } function historyKey(containerName: string) { return `clicd_container_metric_history:${containerName}` } function clamp(value: number) { if (!Number.isFinite(value)) return 0 return Math.max(0, Math.min(value, 100)) } function clampVCPU(value: number, max: number) { const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4 return Number(Math.min(Math.max(rounded, 0.25), max).toFixed(2)) } function clampResourceInt(value: number, min: number, max?: number, fallback = min) { const next = Math.round(Number.isFinite(value) ? value : fallback) return Math.min(Math.max(next, min), max ?? next) } function toChartPoints>(history: MetricPoint[], key: T): ChartPoint[] { return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 })) } function formatPercent(value: number): string { return `${value.toFixed(1)}%` } function formatMB(bytes: number): string { if (bytes === 0) return '0 B' const mb = bytes / (1024 * 1024) if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB` return `${Math.round(mb)} MB` } function formatGB(bytes: number): string { if (bytes === 0) return '0 B' const gb = bytes / (1024 * 1024 * 1024) if (gb >= 1024) return `${(gb / 1024).toFixed(2)} TB` return `${gb.toFixed(2)} GB` } function formatBytes(bytes: number): string { if (bytes === 0) return '0 B' if (bytes < 1024) return `${bytes.toFixed(1)} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` } function formatCPU(usec: number): string { return `${(usec / 1000000).toFixed(2)}s` } function formatExpiration(value?: string): string { if (!value) return '长期有效' return value.length >= 10 ? value.slice(0, 10) : value } function formatDateTime(value?: string): string { if (!value) return '-' const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) return value return parsed.toLocaleString() } function formatScheduleInterval(hours: number): string { if (hours === 24) return '1 天' if (hours % 24 === 0) return `${hours / 24} 天` return `${hours} 小时` } function formatRate(bytesPerSecond: number): string { if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s` } function TrafficBar({ container }: { container: Container }) { const rx = container.traffic_used_rx || 0 const tx = container.traffic_used_tx || 0 const total = rx + tx const mode = container.traffic_mode || 'total' const totalLimit = mode === 'in_out' ? (container.traffic_in_gb || 0) + (container.traffic_out_gb || 0) : container.monthly_traffic_gb || 0 const rxLimit = mode === 'in_out' ? (container.traffic_in_gb || 0) * 1073741824 : 0 const txLimit = mode === 'in_out' ? (container.traffic_out_gb || 0) * 1073741824 : 0 const totalPct = totalLimit > 0 ? Math.min((total / (totalLimit * 1073741824)) * 100, 100) : 0 const rxPct = rxLimit > 0 ? Math.min((rx / rxLimit) * 100, 100) : 0 const txPct = txLimit > 0 ? Math.min((tx / txLimit) * 100, 100) : 0 if (totalLimit === 0 && rxLimit === 0 && txLimit === 0) { return
未设置流量限制
} if (mode === 'in_out') { return (
入站 (RX) {formatBytes(rx)} {rxLimit > 0 ? `/ ${formatBytes(rxLimit)}` : '(不限制)'}
{rxLimit > 0 && (
)}
出站 (TX) {formatBytes(tx)} {txLimit > 0 ? `/ ${formatBytes(txLimit)}` : '(不限制)'}
{txLimit > 0 && (
)}
) } return (
已用
{formatBytes(total)} / {totalLimit} GB {(container.traffic_used_rx > 0 || container.traffic_used_tx > 0) && ( )}
) } function getTemplateIcon(id: string): ReactNode { const size = 'w-6 h-6' id = id.startsWith('kvm-') ? id.slice(4) : id if (id.startsWith('debian')) return if (id.startsWith('ubuntu')) return if (id.startsWith('alpine')) return if (id.startsWith('centos')) return if (id.startsWith('archlinux')) return if (id.startsWith('fedora')) return if (id.startsWith('nixos')) return if (id.startsWith('kali')) return if (id.startsWith('rockylinux')) return if (id.startsWith('windows')) return return null }