import { useState, useEffect, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Search, Plus, Trash2, Download, Upload, Cloud, FileSpreadsheet, RefreshCw, ChevronLeft, ChevronRight, } from 'lucide-react'; import { useServers, useServerMutations, queryKeys } from '@/hooks/api'; import { ServerStatsStrip } from './ServerStatsStrip'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { toast } from 'sonner'; import { useUserStore } from '@/store'; import { CloudServerImport } from './CloudServerImport'; import { TerminalDialog } from './TerminalDialog'; import { ServerFileBrowser } from './ServerFileBrowser'; import type { JsonData } from '@/types/common'; import { ExcelServerImport } from './ExcelServerImport'; import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm'; import { getTags } from './serverHelpers'; import { ServerTableRow } from './ServerTableRow'; import { usePermission } from '@/hooks/usePermission'; import { PageShell } from '@/components/layout/PageShell'; import { ContentPanel } from '@/components/layout/ContentPanel'; import { Server } from 'lucide-react'; export function ServerManager() { const { t } = useTranslation(); const confirm = useConfirm(); const queryClient = useQueryClient(); const { hasPermission } = usePermission(); const [selectedServers, setSelectedServers] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [cloudImportOpen, setCloudImportOpen] = useState(false); const [excelImportOpen, setExcelImportOpen] = useState(false); // 创建/编辑表单状态 const [formOpen, setFormOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [formData, setFormData] = useState(EMPTY_FORM); const [formSaving, setFormSaving] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); // 终端 Dialog 状态 const [terminalOpen, setTerminalOpen] = useState(false); const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null); // 文件浏览 Dialog 状态 const [fileBrowserOpen, setFileBrowserOpen] = useState(false); const [fileBrowserServer, setFileBrowserServer] = useState<{ id: number; name: string } | null>(null); // 分页状态 const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const queryParams = useMemo(() => { const params: Record = { page, page_size: pageSize }; if (searchQuery) params.keyword = searchQuery; if (statusFilter && statusFilter !== 'all') params.status = statusFilter; return params; }, [page, pageSize, searchQuery, statusFilter]); const { data, isLoading: loading, isFetching, refetch } = useServers(queryParams); const { createMutation, updateMutation, deleteMutation, batchDeleteMutation } = useServerMutations(); const servers = data?.list ?? []; const total = data?.total ?? 0; const openTerminal = (server: JsonData) => { setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip, hasAuth: server.has_auth ?? true }); setTerminalOpen(true); }; const openFileBrowser = (server: JsonData) => { setFileBrowserServer({ id: server.id, name: server.hostname }); setFileBrowserOpen(true); }; // 搜索/筛选变化时重置到第1页 const handleSearch = (val: string) => { setSearchQuery(val); setPage(1); }; const handleStatusFilter = (val: string) => { setStatusFilter(val); setPage(1); }; // WebSocket 实时接收 Agent 指标推送 // Logan.Li const wsRef = useRef(null); const reconnectTimerRef = useRef | null>(null); useEffect(() => { const token = useUserStore.getState().token; if (!token) return; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/metrics?token=${token}`; const connect = () => { if (wsRef.current?.readyState === WebSocket.OPEN) return; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { // WebSocket 已连接,静默接收指标推送 }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === 'metrics_update') { queryClient.setQueryData(queryKeys.servers(queryParams), (old: typeof data) => { if (!old) return old; return { ...old, list: old.list.map((s) => { if (s.hostname === msg.hostname) { return { ...s, cpu_usage: msg.cpu_usage, memory_usage: msg.memory_usage, disk_usage: msg.disk_usage, status: msg.status, tcp_established: msg.tcp_established, tcp_time_wait: msg.tcp_time_wait, net_rx_bps: msg.net_rx_bps, net_tx_bps: msg.net_tx_bps, disk_read_bps: msg.disk_read_bps, disk_write_bps: msg.disk_write_bps, }; } return s; }), }; }); } } catch { // 忽略非 JSON 消息 } }; ws.onclose = () => { // 连接断开,3 秒后重连 wsRef.current = null; reconnectTimerRef.current = setTimeout(connect, 3000); }; ws.onerror = (err) => { console.error('[MetricsWS] 连接错误:', err); ws.close(); }; }; connect(); return () => { if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); if (wsRef.current) { wsRef.current.onclose = null; // 阻止自动重连 wsRef.current.close(); wsRef.current = null; } }; }, [queryClient, queryParams]); // 导出 CSV const toggleSelectAll = () => { if (selectedServers.length === servers.length) { setSelectedServers([]); } else { setSelectedServers(servers.map((s) => String(s.id))); } }; const toggleSelect = (id: string) => { if (selectedServers.includes(id)) { setSelectedServers(selectedServers.filter((s) => s !== id)); } else { setSelectedServers([...selectedServers, id]); } }; const handleDelete = async (id: string) => { const server = servers.find((s) => String(s.id) === String(id)); const ok = await confirm({ title: t('server.deleteTitle'), description: `${server?.hostname || id}`, confirmText: t('common.delete'), variant: 'destructive', }); if (!ok) return; try { await deleteMutation.mutateAsync(id); toast.success(t('server.deleted')); } catch (e) { console.error('[ServerManager] delete failed:', e); toast.error(t('server.deleteFailed')); } }; // 批量删除 const handleBatchDelete = async () => { if (selectedServers.length === 0) return; const ok = await confirm({ title: t('server.batchDeleteTitle'), description: `${selectedServers.length}`, confirmText: t('server.batchDelete'), variant: 'destructive', }); if (!ok) return; try { await batchDeleteMutation.mutateAsync(selectedServers.map((id) => Number(id))); toast.success(t('server.deletedCount', { count: selectedServers.length })); setSelectedServers([]); } catch (e) { console.error('[ServerManager] batch delete failed:', e); toast.error(t('server.batchDeleteFailed')); } }; // 打开创建表单 const openCreateForm = () => { setEditingId(null); setFormData(EMPTY_FORM); setDetailsOpen(false); setFormOpen(true); }; // 打开编辑表单 const openEditForm = (server: JsonData) => { setEditingId(server.id); setFormData({ hostname: server.hostname || '', ip: server.ip || '', public_ip: server.public_ip || '', port: String(server.port || 22), username: server.username || 'root', auth_type: server.auth_type || 'password', password: '', ssh_key: '', credential_id: null as number | null, os: server.os || '', cpu: String(server.cpu || ''), memory: server.memory ? String(Math.round(server.memory / 1024)) : '', // MB → GB disk: String(server.disk || ''), status: server.status || 'running', tags: server.tags || '', description: server.description || '', }); setDetailsOpen(false); setFormOpen(true); }; // 保存表单 const handleSave = async () => { if (!formData.hostname || !formData.ip) return; try { setFormSaving(true); const payload: Record = { hostname: formData.hostname, ip: formData.ip, public_ip: formData.public_ip || undefined, port: Number(formData.port) || 22, username: formData.username || 'root', auth_type: formData.auth_type || 'password', os: formData.os, cpu: Number(formData.cpu) || 0, memory: (Number(formData.memory) || 0) * 1024, // GB → MB disk: Number(formData.disk) || 0, status: formData.status, tags: formData.tags || undefined, description: formData.description || undefined, }; // 凭证关联 — 下拉选择后自动关联,无需手输密码 if (formData.credential_id) { payload.credential_id = formData.credential_id; } else { // 手动输入模式:仅填了密码才提交 if (formData.password) { payload.password = formData.password; } if (formData.ssh_key) { payload.ssh_key = formData.ssh_key; } } if (editingId) { await updateMutation.mutateAsync({ id: editingId, data: payload }); } else { await createMutation.mutateAsync(payload); } toast.success(editingId ? t('server.updated') : t('server.created')); setFormOpen(false); } catch (e) { console.error('[ServerManager] save failed:', e); toast.error(t('server.saveFailed')); } finally { setFormSaving(false); } }; // 导出 CSV — 将当前服务器列表导出为 CSV 文件 // Logan.Li const handleExport = () => { if (servers.length === 0) { toast.error(t('server.noExportData')); return; } const headers = [ t('server.csv.hostname'), t('server.csv.ip'), t('server.csv.publicIp'), t('server.csv.port'), t('server.csv.os'), t('server.csv.cpu'), t('server.csv.memory'), t('server.csv.disk'), t('server.csv.status'), t('server.csv.tags'), ]; const rows = servers.map((s) => { const memGB = s.memory ? Math.round(Number(s.memory) / 1024) : 0; const tags = getTags(s).join(';'); return [s.hostname || '', s.ip || '', s.public_ip || '', s.port || 22, s.os || '', s.cpu || 0, memGB, s.disk || 0, s.status || '', tags]; }); const csv = [headers, ...rows] .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')) .join('\n'); const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `servers_${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); toast.success(t('server.exported', { count: servers.length })); }; const colSpanCount = 9; const tableToolbar = ( <>
handleSearch(e.target.value)} className="pl-9 h-9 dg-input" />
{selectedServers.length > 0 && (
{t('server.selected', { count: selectedServers.length })} {hasPermission('server:delete') && ( )}
)} ); return ( } metrics={} actions={( <> {hasPermission('server:create') && ( {t('server.importMethod')} setCloudImportOpen(true)}> {t('server.cloudImport')} {t('server.cloudImportHint')} setExcelImportOpen(true)}> {t('server.csvImport')} {t('server.csvImportHint')} )} {hasPermission('server:create') && ( )} )} >
0 } onCheckedChange={toggleSelectAll} /> {t('server.hostname')} {t('server.ipAddress')} {t('server.publicIp')} {t('common.status')} {t('server.config')} {t('server.usage')} {t('server.tags')} {t('common.actions')} {loading ? ( {t('common.loading')} ) : servers.length === 0 ? ( {t('server.noData')} ) : ( servers.map((server) => ( )) )}
{/* 分页 */}
{t('server.perPage')} {t('server.items')} {t('server.totalItems', { count: total })}
{page} / {Math.max(1, Math.ceil(total / pageSize))}
{/* 创建/编辑服务器 Dialog */} {/* Import Dialogs */} { setCloudImportOpen(false); refetch(); }} /> { setExcelImportOpen(false); refetch(); }} /> {/* 终端 Dialog */} {/* 文件浏览 Dialog */}
); }