import { useCallback, useEffect, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router-dom' import { ArrowDown, ArrowUp, Cpu, Eye, HardDrive, MemoryStick, Network, Play, Plus, RefreshCw, RotateCcw, Server, Square, Trash2, ListTodo, X, } from 'lucide-react' import CreateContainerModal from '../components/CreateContainerModal' import { useAuth } from '../contexts/AuthContext' import { Container, CreateContainerRequest, ContainerUsage, getContainerUsage, getContainers, batchAction, Task, getTasks, deleteTask, } from '../services/api' import { actionLabel, taskStatusClass, taskStatusLabel } from '../utils/labels' export default function Containers() { const navigate = useNavigate() const { isSubUser } = useAuth() const [containers, setContainers] = useState([]) const [usageByName, setUsageByName] = useState>({}) const [loading, setLoading] = useState(true) const [showCreate, setShowCreate] = useState(false) const [selected, setSelected] = useState>(new Set()) const [batchLoading, setBatchLoading] = useState(false) const [refreshing, setRefreshing] = useState(false) const [showTasks, setShowTasks] = useState(false) const [tasks, setTasks] = useState([]) const [queuedCreates, setQueuedCreates] = useState>({}) const refreshUsage = useCallback(async (items: Container[]) => { const targets = items.filter((container) => container.status === 'running') if (targets.length === 0) { setUsageByName({}) return } const results = await Promise.allSettled( targets.map(async (container) => { const res = await getContainerUsage(container.uuid || container.id) return [container.name, res.data.data] as const }) ) setUsageByName((current) => { const next: Record = {} const activeNames = new Set(items.map((container) => container.name)) for (const [name, usage] of Object.entries(current)) { if (activeNames.has(name)) next[name] = usage } for (const result of results) { if (result.status === 'fulfilled' && result.value[1]) { next[result.value[0]] = result.value[1] } } return next }) }, []) const fetchData = useCallback(async () => { try { const res = await getContainers() const nextContainers = res.data.data || [] setContainers(nextContainers) await refreshUsage(nextContainers) } catch (err) { console.error(err) } finally { setLoading(false) } }, [refreshUsage]) useEffect(() => { fetchData() const interval = window.setInterval(fetchData, 5000) return () => window.clearInterval(interval) }, [fetchData]) const toggleSelect = (id: number) => { setSelected(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const toggleAll = () => { const selectableIDs = displayContainers .filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]) .map((container) => container.id) if (selected.size === selectableIDs.length) { setSelected(new Set()) } else { setSelected(new Set(selectableIDs)) } } // Map of container_id -> current task status. // For create tasks, container_id may be 0 initially but gets set after creation, // so we also index by container_name as fallback for placeholder items. const taskStatusMap: Record = {} const taskNameMap: Record = {} for (const t of tasks) { if (t.status === 'pending' || t.status === 'running') { if (t.container_id != null && t.container_id > 0) { taskStatusMap[t.container_id] = t } if (t.container_name) { taskNameMap[t.container_name] = t } } } const handleBatchAction = async (action: string) => { if (selected.size === 0) return setBatchLoading(true) try { await batchAction(action, [...selected]) setSelected(new Set()) await fetchTasks() } catch (err) { console.error(err) } finally { setBatchLoading(false) } } const fetchTasks = useCallback(async () => { try { const res = await getTasks() const nextTasks = res.data.data || [] setTasks(nextTasks) setQueuedCreates((current) => syncQueuedCreates(current, nextTasks, containers)) } catch { /* ignore */ } }, [containers]) useEffect(() => { fetchTasks(); const t = setInterval(fetchTasks, 2000); return () => clearInterval(t) }, [fetchTasks]) const handleRefreshList = useCallback(async () => { setRefreshing(true) try { await Promise.all([fetchData(), fetchTasks()]) } finally { setRefreshing(false) } }, [fetchData, fetchTasks]) const actionLabels: Record = { create: '正在初始化', start: '开机中', stop: '关机中', restart: '重启中', delete: '删除中', reinstall: '重装中', } const displayContainers = buildDisplayContainers(containers, queuedCreates, tasks) const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length const handleCreateQueued = async (items: CreateContainerRequest[]) => { setQueuedCreates((current) => { const next = { ...current } for (const item of items) { next[item.name] = item } return next }) fetchTasks() fetchData() } if (loading) { return (
) } return (

容器管理

共 {displayContainers.length} 个容器{selected.size > 0 && `,已选 ${selected.size} 个`}

{selected.size > 0 && (
{selected.size} 个
)} {!isSubUser && ( )}
{displayContainers.length === 0 ? (

暂无容器

点击"创建容器"开始

) : (
ID名称状态系统CPUMEMORYDISKNET配置剩余时间操作 {displayContainers.map((container) => { const isRunning = container.status === 'running' const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask const isPlaceholder = !!container.isPlaceholder const usage = usageByName[container.name] const cpuPct = isRunning ? clamp(usage?.cpu_usage_pct || 0) : 0 const ramPct = isRunning && 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 rx = isRunning ? usage?.network_rx_bps || 0 : 0 const tx = isRunning ? usage?.network_tx_bps || 0 : 0 return ( ) })}
{!isSubUser && ( 0 && selected.size === displayContainers.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]).length} onChange={toggleAll} className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black" /> )}
{!isSubUser && ( toggleSelect(container.id)} disabled={isPlaceholder || !!taskStatusMap[container.id] || !!taskNameMap[container.name]} className="w-3.5 h-3.5 rounded border-gray-300 text-black focus:ring-black accent-black disabled:opacity-30" /> )} #{container.id} {getTemplateIcon(container.template)} {getTemplateName(container.template)}
{formatRate(tx)}
{formatRate(rx)}
{container.vcpu}核/{formatRAM(container.ram_mb)}/{container.disk_gb}GB {container.expires_at ? getRemaining(container.expires_at) : 永久}
{task?.status === 'failed' ? ( ) : ( )}
)} setShowCreate(false)} onSuccess={handleCreateQueued} /> {showTasks && ( setShowTasks(false)} /> )}
) } function TableHead({ children, right, icon }: { children: ReactNode; right?: boolean; icon?: boolean }) { return ( {children} ) } type DisplayContainer = Container & { isPlaceholder?: boolean createTask?: Task } function StatusBadge({ running, task, placeholder }: { running: boolean; task?: Task; placeholder?: boolean }) { const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap" if (task?.status === 'failed') { return ( 初始化失败 ) } if (task?.type === 'create' && task.status === 'done') { return ( 初始化完成 ) } if (task?.type === 'create' && task.status === 'running') { return ( 正在初始化 ) } if (placeholder || task?.type === 'create') { return ( 排队等待 ) } if (task && task.status !== 'done' && task.status !== 'failed') { const taskLabels: Record = { start: '开机中', stop: '关机中', restart: '重启中', delete: '删除中', reinstall: '重装中', } return ( {taskLabels[task.type] || '处理中'} ) } return ( {running ? '在线' : '离线'} ) } function buildDisplayContainers( containers: Container[], queuedCreates: Record, tasks: Task[] ): DisplayContainer[] { const realNames = new Set(containers.map((container) => container.name)) const placeholders = new Map() for (const [name, cfg] of Object.entries(queuedCreates)) { if (!realNames.has(name)) { placeholders.set(name, toPlaceholder(cfg)) } } for (const task of tasks) { if (task.type !== 'create' || !task.config?.name || realNames.has(task.config.name)) { continue } if (task.status === 'pending' || task.status === 'running' || task.status === 'failed' || placeholders.has(task.config.name)) { placeholders.set(task.config.name, { ...toPlaceholder(task.config), createTask: task }) } } return [...containers, ...placeholders.values()] } function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer { return { id: 0, uuid: '', name: cfg.name, template: cfg.template_id, vcpu: cfg.vcpu, ram_mb: cfg.ram_mb, disk_gb: cfg.disk_gb, network_bw_mbps: cfg.network_bw_mbps, monthly_traffic_gb: cfg.monthly_traffic_gb, traffic_mode: cfg.traffic_mode || 'total', traffic_in_gb: cfg.traffic_in_gb || 0, traffic_out_gb: cfg.traffic_out_gb || 0, traffic_used_rx: 0, traffic_used_tx: 0, traffic_reset_date: '', io_speed_mbps: cfg.io_speed_mbps, status: 'creating', ip: '', ipv6: '', ipv6_prefix_len: 0, ipv6_interface: '', vnc_port: 0, ssh_port: 0, ssh_password: '', port_mappings: [], port_mapping_limit: 2, snapshot_limit: cfg.snapshot_limit || 3, created_at: '', expires_at: cfg.expires_at, snapshot_schedule_enabled: false, snapshot_schedule_interval_hours: 24, snapshot_schedule_time: '03:00', snapshot_schedule_last_run: '', snapshot_schedule_next_run: '', snapshot_schedule_created_by: '', isPlaceholder: true, } } function syncQueuedCreates( current: Record, tasks: Task[], containers: Container[] ): Record { const realNames = new Set(containers.map((container) => container.name)) const activeOrFailedCreateNames = new Set( tasks .filter((task) => task.type === 'create' && (task.status === 'pending' || task.status === 'running' || task.status === 'failed' || task.status === 'done')) .map((task) => task.config?.name || task.container_name) ) const next: Record = {} for (const [name, cfg] of Object.entries(current)) { if (!realNames.has(name)) { next[name] = cfg } } for (const task of tasks) { if (task.type === 'create' && task.config?.name && !realNames.has(task.config.name)) { if (task.status === 'pending' || task.status === 'running' || task.status === 'failed') { next[task.config.name] = task.config } } } return next } function hasActiveTasks(tasks: Task[]) { return tasks.some((task) => task.status === 'pending' || task.status === 'running') } function taskLineLabel(task: Task, actionLabels: Record) { if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败' if (task.type === 'create' && task.status === 'done') return '初始化完成' return actionLabels[task.type] || '处理中...' } function TaskQueueModal({ tasks, onRefresh, onClose }: { tasks: Task[] onRefresh: () => void | Promise onClose: () => void }) { return (

任务队列

共 {tasks.length} 个任务

{tasks.length === 0 ? (
暂无任务
) : (
{tasks.map((task) => ( ))}
状态 操作 容器 创建时间 错误
{taskStatusLabel(task.status)} {actionLabel(task.type)} {task.container_name} {task.created_at} {task.error || '-'} {task.status === 'pending' && ( )}
)}
) } function ProgressCell({ pct }: { pct: number }) { return (
{pct.toFixed(1)}%
0 ? 2 : 0)}%` }} />
) } function getTemplateName(id: string) { const map: Record = { 'ubuntu-noble': 'Ubuntu 24.04', 'ubuntu-jammy': 'Ubuntu 22.04', 'debian-bookworm': 'Debian 12', 'debian-bullseye': 'Debian 11', 'alpine-3.21': 'Alpine 3.21', 'centos-9-stream': 'CentOS 9', 'archlinux-current': 'Arch Linux', 'fedora-44': 'Fedora 44', 'rockylinux-10': 'Rocky 10', } return map[id] || id } function getTemplateIcon(id: string): ReactNode { const size = 'w-4 h-4' 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('rockylinux')) return return null } function clamp(value: number) { if (!Number.isFinite(value)) return 0 return Math.max(0, Math.min(value, 100)) } function formatRAM(mb: number): string { if (mb >= 1024) return `${(mb / 1024).toFixed(0)} GB` return `${mb} MB` } function getRemaining(expires: string): ReactNode { const end = new Date(expires).getTime() const now = Date.now() const diff = end - now if (diff <= 0) return 已到期 const days = Math.floor(diff / 86400000) if (days > 30) return `${Math.floor(days / 30)}个月` if (days > 0) return `${days}天` const hours = Math.floor(diff / 3600000) if (hours > 0) return `${hours}小时` return `${Math.floor(diff / 60000)}分钟` } function formatRate(value: number) { if (value < 1024) return `${value.toFixed(0)} B/s` if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB/s` return `${(value / 1024 / 1024).toFixed(2)} MB/s` }