import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router-dom' import { ArrowDown, ArrowUp, Cpu, Eye, HardDrive, MemoryStick, Network, Play, Plus, RefreshCw, RotateCcw, Search, 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 [searchText, setSearchText] = useState('') const [typeFilter, setTypeFilter] = useState('all') const [systemFilter, setSystemFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) 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 }) } // 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 systemOptions = useMemo(() => buildSystemOptions(displayContainers), [displayContainers]) const filteredContainers = useMemo(() => { return filterContainers(displayContainers, { search: searchText, type: typeFilter, system: systemFilter, status: statusFilter, taskStatusMap, taskNameMap, }) }, [displayContainers, searchText, typeFilter, systemFilter, statusFilter, tasks]) const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize)) const currentPage = Math.min(page, totalPages) const pageStart = (currentPage - 1) * pageSize const pageContainers = filteredContainers.slice(pageStart, pageStart + pageSize) const selectableIDs = filteredContainers .filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]) .map((container) => container.id) const allFilteredSelected = selectableIDs.length > 0 && selectableIDs.every((id) => selected.has(id)) useEffect(() => { setPage(1) }, [searchText, typeFilter, systemFilter, statusFilter, pageSize]) const toggleAll = () => { if (allFilteredSelected) { setSelected((prev) => { const next = new Set(prev) selectableIDs.forEach((id) => next.delete(id)) return next }) } else { setSelected((prev) => { const next = new Set(prev) selectableIDs.forEach((id) => next.add(id)) return next }) } } 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} 个容器 {filteredContainers.length !== displayContainers.length && `,筛选后 ${filteredContainers.length} 个`} {selected.size > 0 && `,已选 ${selected.size} 个`}

{!isSubUser && ( )}
{displayContainers.length > 0 && (
setSearchText(event.target.value)} className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black" placeholder="搜索名称、ID、UUID、IP" />
{selected.size > 0 && (
{selected.size} 个
)}
)} {displayContainers.length === 0 ? (

暂无容器

点击"创建容器"开始

) : (
ID名称状态系统类型CPUMEMORYDISKNET配置剩余时间操作 {pageContainers.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 isPolicyBlocked = !!container.policy_blocked const usage = usageByName[container.name] const isKVM = (container.virtualization || 'lxc') === 'kvm' const cpuPct = isRunning ? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1)) : 0 const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0 ? usage.memory_total_bytes : container.ram_mb * 1024 * 1024 const ramPct = isRunning && ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 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 && ( )}
{!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' ? ( ) : ( )}
{filteredContainers.length === 0 ? (
没有匹配的容器
) : (
显示 {pageStart + 1}-{Math.min(pageStart + pageSize, filteredContainers.length)} / {filteredContainers.length}
{currentPage} / {totalPages}
)}
)} setShowCreate(false)} onSuccess={handleCreateQueued} existingNames={containers.map(c => c.name)} /> {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, policyBlocked }: { running: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) { const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap" if (policyBlocked) { return ( 策略封禁 ) } 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 RuntimeBadge({ runtime }: { runtime: string }) { const normalized = runtime === 'kvm' ? 'kvm' : 'lxc' return ( {normalized.toUpperCase()} ) } 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, virtualization: cfg.virtualization || 'lxc', 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') } type ContainerFilters = { search: string type: string system: string status: string taskStatusMap: Record taskNameMap: Record } function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] { const keyword = filters.search.trim().toLowerCase() return containers.filter((container) => { const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) { return false } if (filters.type !== 'all' && (container.virtualization || 'lxc') !== filters.type) { return false } if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) { return false } if (!keyword) return true const fields = [ String(container.id), container.name, container.uuid, container.ip, container.ipv6, container.template, container.virtualization || 'lxc', getTemplateName(container.template), getSystemFilterLabel(getSystemFilterValue(container.template)), String(container.ssh_port || ''), ] return fields.some((field) => field.toLowerCase().includes(keyword)) }) } function buildSystemOptions(containers: DisplayContainer[]) { const systems = new Map() for (const container of containers) { const value = getSystemFilterValue(container.template) systems.set(value, getSystemFilterLabel(value)) } return Array.from(systems.entries()) .map(([value, label]) => ({ value, label })) .sort((a, b) => a.label.localeCompare(b.label)) } function getSystemFilterValue(template: string) { const normalized = template.startsWith('kvm-') ? template.slice(4) : template if (normalized.startsWith('ubuntu')) return 'ubuntu' if (normalized.startsWith('debian')) return 'debian' if (normalized.startsWith('alpine')) return 'alpine' if (normalized.startsWith('centos')) return 'centos' if (normalized.startsWith('archlinux')) return 'archlinux' if (normalized.startsWith('fedora')) return 'fedora' if (normalized.startsWith('rockylinux')) return 'rockylinux' if (normalized.startsWith('windows')) return 'windows' return normalized || 'unknown' } function getSystemFilterLabel(system: string) { const labels: Record = { ubuntu: 'Ubuntu', debian: 'Debian', alpine: 'Alpine', centos: 'CentOS', archlinux: 'Arch Linux', fedora: 'Fedora', rockylinux: 'Rocky Linux', windows: 'Windows', unknown: '未知系统', } return labels[system] || system } function getContainerStatusFilterValue(container: DisplayContainer, task?: Task) { if (task?.status === 'failed') return 'failed' if (container.isPlaceholder || task?.type === 'create') return 'creating' if (task && task.status !== 'done' && task.status !== 'failed') return 'task' return container.status === 'running' ? 'running' : 'stopped' } 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', 'kvm-ubuntu-noble': 'Ubuntu 24.04', 'kvm-ubuntu-jammy': 'Ubuntu 22.04', 'kvm-debian-bookworm': 'Debian 12', 'kvm-debian-bullseye': 'Debian 11', 'kvm-rockylinux-9': 'Rocky 9', 'kvm-windows-10': 'Windows 10', } return map[id] || id } function getTemplateIcon(id: string): ReactNode { const size = 'w-4 h-4' 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('rockylinux')) return if (id.startsWith('windows')) 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` }