import { useState, useEffect, useCallback, type ReactNode } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { ArrowLeft, Camera, Clock, Copy, Cpu, HardDrive, Key, MemoryStick, 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 { 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 [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 [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 memoryPct = currentContainer.ram_mb > 0 ? (nextUsage.memory_usage_bytes / (currentContainer.ram_mb * 1024 * 1024)) * 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), 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(() => { 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 handleAction = async (action: string) => { if (!containerIdentifier) return setActionLoading(action) try { switch (action) { case 'start': await startContainer(containerIdentifier) break case 'stop': await stopContainer(containerIdentifier) setShowSSH(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() 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) await fetchContainer() } catch (err) { console.error('Reinstall failed:', err) dialog.alert('重装失败', '请稍后重试') } finally { setReinstalling(false) } } const handleResetPassword = async () => { if (!containerIdentifier || !(await dialog.confirm('重置密码', `确定要重置容器 ${container?.name} 的 SSH 密码吗?`))) return try { const res = await resetSSHPassword(containerIdentifier) if (res.data.success) { await dialog.alert('密码已重置', `新密码: ${(res.data.data as { password: string })?.password}`) await fetchContainer() } } catch (err) { console.error(err) dialog.alert('密码重置失败', '请稍后重试') } } 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 = () => { setDraft(emptyDraft) setShowNat(true) } const openEditMapping = (pm: PortMapping, index: number) => { 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 (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 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 (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 = () => { setSnapshotScheduleDraft({ intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24), time: snapshotSchedule?.time || '03:00', }) setShowSnapshotSchedule(true) } const saveSnapshotSchedule = async (enabled: boolean) => { if (!containerIdentifier) 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 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 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 isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false 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 ) const filtered = filterHistory(history, range) const cpuPct = clamp(usage?.cpu_usage_pct || 0) const ramPct = container.ram_mb > 0 ? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 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 : true const managementUrl = subUser?.access_code ? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}` : '' const charts: ResourceChartConfig[] = [ { title: 'CPU 使用率', icon: , current: usage?.cpu_usage_pct || 0, 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)} / ${container.ram_mb} MB`, }, { 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.ip || '-'} NAT {mappingCount} 条 {publicHost}:{container.ssh_port}
{!isRunning ? ( handleAction('start')}> {isExpired ? '已到期' : taskStatus === 'start' ? taskActionLabels['start'] : '开机'} ) : ( <> handleAction('stop')}> {isExpired ? '已到期' : taskStatus === 'stop' ? taskActionLabels['stop'] : '关机'} handleAction('restart')}> {isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'} setShowSSH(true)}> WebSSH )} {!isSubUser && ( 管理链接 )} <> setShowNat(true)}> NAT 管理 setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy}> 快照 {!isSubUser && ( {isExpired ? '已到期' : taskStatus === 'reinstall' ? taskActionLabels['reinstall'] : '重装'} )} {!isSubUser && ( handleAction('delete')}> {taskStatus === 'delete' ? taskActionLabels['delete'] : '删除'} )}
SSH 密码
setShowPassword(!showPassword)} title={showPassword ? '点击隐藏' : '点击显示'} > {showPassword ? (container.ssh_password || '-') : '••••••••'} {container.ssh_password && ( )}
{!isSubUser && ( )}
) : 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} /> {showSSH && ( setShowSSH(false)} wide>
{isRunning ? ( setShowSSH(false)} /> ) : (
容器未运行,请先开机
)}
)} {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'; 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', } 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 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 }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode }) { 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' 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 return null }