- Dockerfile: ARG VERSION/BUILD_TIME 通过 ldflags 注入 agent+backend 二进制 - run.sh: 自动从 git tag 获取版本号 - docker-compose: build args 传递 VERSION/BUILD_TIME - backend: BatchUpgradeAgent SSH下载新二进制+重启, /agent/bin 静态文件服务 - frontend: 监控大屏添加批量升级按钮 (闪电图标) - frontend: 服务器编辑/添加表单改为凭证下拉选择器 - frontend: ServerForm 加载 /credentials 列表, 选择后自动填充用户名/认证方式
608 lines
22 KiB
TypeScript
608 lines
22 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
||
import { cn } from '@/lib/utils';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Badge } from '@/components/ui/badge';
|
||
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 { serverApi } from '@/services/api';
|
||
import { ServerStatsCards } from './ServerStatsCards';
|
||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||
import { toast } from 'sonner';
|
||
import { useUserStore } from '@/store';
|
||
import { CloudServerImport } from './CloudServerImport';
|
||
import { TerminalDialog } from './TerminalDialog';
|
||
import { ExcelServerImport } from './ExcelServerImport';
|
||
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
||
import { getTags } from './serverHelpers';
|
||
import { ServerTableRow } from './ServerTableRow';
|
||
|
||
export function ServerManager() {
|
||
const confirm = useConfirm();
|
||
const [servers, setServers] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||
const [cloudImportOpen, setCloudImportOpen] = useState(false);
|
||
const [excelImportOpen, setExcelImportOpen] = useState(false);
|
||
|
||
// 创建/编辑表单状态
|
||
const [formOpen, setFormOpen] = useState(false);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [formData, setFormData] = useState<ServerFormData>(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);
|
||
|
||
// 分页状态
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(10);
|
||
const [total, setTotal] = useState(0);
|
||
|
||
const openTerminal = (server: any) => {
|
||
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip, hasAuth: server.has_auth ?? true });
|
||
setTerminalOpen(true);
|
||
};
|
||
|
||
const loadData = async (kw?: string, st?: string) => {
|
||
try {
|
||
setLoading(true);
|
||
const q = kw ?? searchQuery;
|
||
const s = st ?? statusFilter;
|
||
const params: any = { page, page_size: pageSize };
|
||
if (q) params.keyword = q;
|
||
if (s && s !== 'all') params.status = s;
|
||
const res: any = await serverApi.getServers(params);
|
||
setServers(res?.list || []);
|
||
setTotal(res?.total || 0);
|
||
} catch (e) {
|
||
console.error('加载服务器列表失败:', e);
|
||
setServers([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 搜索/筛选变化时重置到第1页
|
||
const handleSearch = (val: string) => {
|
||
setSearchQuery(val);
|
||
setPage(1);
|
||
};
|
||
const handleStatusFilter = (val: string) => {
|
||
setStatusFilter(val);
|
||
setPage(1);
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [page, pageSize]);
|
||
|
||
// WebSocket 实时接收 Agent 指标推送 // Logan.Li
|
||
const wsRef = useRef<WebSocket | null>(null);
|
||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | 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 = () => {
|
||
console.log('[MetricsWS] 已连接,实时接收指标推送');
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const msg = JSON.parse(event.data);
|
||
if (msg.type === 'metrics_update') {
|
||
setServers((prev) =>
|
||
prev.map((s) => {
|
||
// 通过 hostname 匹配(Agent 上报时使用 hostname)
|
||
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 (e) {
|
||
// 忽略非 JSON 消息
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
console.log('[MetricsWS] 连接断开,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;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 导出 CSV
|
||
const toggleSelectAll = () => {
|
||
if (selectedServers.length === servers.length) {
|
||
setSelectedServers([]);
|
||
} else {
|
||
setSelectedServers(servers.map((s) => 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: '删除服务器',
|
||
description: `确定删除「${server?.hostname || id}」吗?此操作不可撤销。`,
|
||
confirmText: '删除',
|
||
variant: 'destructive',
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await serverApi.deleteServer(id);
|
||
toast.success('服务器已删除');
|
||
loadData();
|
||
} catch (e) {
|
||
console.error('删除服务器失败:', e);
|
||
toast.error('删除失败');
|
||
}
|
||
};
|
||
|
||
// 批量删除
|
||
const handleBatchDelete = async () => {
|
||
if (selectedServers.length === 0) return;
|
||
const ok = await confirm({
|
||
title: '批量删除服务器',
|
||
description: `确定删除选中的 ${selectedServers.length} 台服务器吗?此操作不可撤销。`,
|
||
confirmText: '批量删除',
|
||
variant: 'destructive',
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await serverApi.batchDelete(selectedServers.map((id) => Number(id)));
|
||
toast.success(`已删除 ${selectedServers.length} 台服务器`);
|
||
setSelectedServers([]);
|
||
loadData();
|
||
} catch (e) {
|
||
console.error('批量删除失败:', e);
|
||
toast.error('批量删除失败');
|
||
}
|
||
};
|
||
|
||
// 打开创建表单
|
||
const openCreateForm = () => {
|
||
setEditingId(null);
|
||
setFormData(EMPTY_FORM);
|
||
setDetailsOpen(false);
|
||
setFormOpen(true);
|
||
};
|
||
|
||
// 打开编辑表单
|
||
const openEditForm = (server: any) => {
|
||
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: '',
|
||
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<string, any> = {
|
||
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 serverApi.updateServer(editingId, payload);
|
||
} else {
|
||
await serverApi.createServer(payload);
|
||
}
|
||
toast.success(editingId ? '服务器已更新' : '服务器已创建');
|
||
setFormOpen(false);
|
||
loadData();
|
||
} catch (e) {
|
||
console.error('保存服务器失败:', e);
|
||
toast.error('保存失败,请重试');
|
||
} finally {
|
||
setFormSaving(false);
|
||
}
|
||
};
|
||
|
||
// 导出 CSV — 将当前服务器列表导出为 CSV 文件 // Logan.Li
|
||
const handleExport = () => {
|
||
if (servers.length === 0) {
|
||
toast.error('没有可导出的数据');
|
||
return;
|
||
}
|
||
const headers = ['主机名', 'IP地址', '公网IP', '端口', '操作系统', 'CPU(核)', '内存(GB)', '磁盘(GB)', '状态', '标签'];
|
||
const rows = servers.map((s) => {
|
||
const memGB = s.memory ? Math.round(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(`已导出 ${servers.length} 台服务器`);
|
||
};
|
||
|
||
const colSpanCount = 8;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Page Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">服务器管理</h1>
|
||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理云平台服务器资产</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||
onClick={() => loadData()}
|
||
>
|
||
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
|
||
刷新
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||
onClick={handleExport}
|
||
>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
导出
|
||
</Button>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
|
||
<Upload className="h-4 w-4 mr-2" />
|
||
批量导入
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||
<DropdownMenuLabel>选择导入方式</DropdownMenuLabel>
|
||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||
<DropdownMenuItem
|
||
className="hover:bg-[var(--dg-bg-hover)] cursor-pointer focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]"
|
||
onClick={() => setCloudImportOpen(true)}
|
||
>
|
||
<Cloud className="h-4 w-4 mr-2 text-[var(--dg-blue)]" />
|
||
云主机导入
|
||
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">从云平台同步</span>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
className="hover:bg-[var(--dg-bg-hover)] cursor-pointer focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]"
|
||
onClick={() => setExcelImportOpen(true)}
|
||
>
|
||
<FileSpreadsheet className="h-4 w-4 mr-2 text-emerald-400" />
|
||
CSV导入
|
||
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">hostname + IP 即可</span>
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
<Button
|
||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||
onClick={openCreateForm}
|
||
>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
添加服务器
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats Cards */}
|
||
<ServerStatsCards servers={servers} />
|
||
|
||
{/* Filters & Table */}
|
||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||
<CardContent className="p-0">
|
||
{/* Toolbar */}
|
||
<div className="flex items-center justify-between flex-wrap gap-4 p-4">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<div className="relative w-64">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||
<Input
|
||
placeholder="搜索主机名或IP..."
|
||
value={searchQuery}
|
||
onChange={(e) => handleSearch(e.target.value)}
|
||
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||
/>
|
||
</div>
|
||
<Select value={statusFilter} onValueChange={handleStatusFilter}>
|
||
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||
<SelectValue placeholder="状态" />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||
<SelectItem value="all">全部状态</SelectItem>
|
||
<SelectItem value="running">运行中</SelectItem>
|
||
<SelectItem value="stopped">已停止</SelectItem>
|
||
<SelectItem value="maintenance">维护中</SelectItem>
|
||
<SelectItem value="offline">离线</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{selectedServers.length > 0 && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||
onClick={handleBatchDelete}
|
||
>
|
||
<Trash2 className="h-4 w-4 mr-2" />
|
||
批量删除
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="border-t border-[var(--dg-border)]">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||
<TableHead className="w-12">
|
||
<Checkbox
|
||
checked={
|
||
selectedServers.length === servers.length && servers.length > 0
|
||
}
|
||
onCheckedChange={toggleSelectAll}
|
||
className="border-[var(--dg-border)]"
|
||
/>
|
||
</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)]">主机名</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)]">IP地址</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)]">公网IP</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)]">配置</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)]">使用率</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)]">标签</TableHead>
|
||
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{loading ? (
|
||
<TableRow className="border-[var(--dg-border)]">
|
||
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
|
||
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||
加载中...
|
||
</TableCell>
|
||
</TableRow>
|
||
) : servers.length === 0 ? (
|
||
<TableRow className="border-[var(--dg-border)]">
|
||
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
|
||
暂无服务器数据
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
servers.map((server) => (
|
||
<ServerTableRow
|
||
key={server.id}
|
||
server={server}
|
||
selected={selectedServers.includes(server.id)}
|
||
onToggleSelect={toggleSelect}
|
||
onOpenTerminal={openTerminal}
|
||
onOpenEdit={openEditForm}
|
||
onDelete={handleDelete}
|
||
/>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{/* 分页 */}
|
||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
|
||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-muted)]">
|
||
<span>每页</span>
|
||
<Select value={String(pageSize)} onValueChange={(v) => { setPageSize(Number(v)); setPage(1); }}>
|
||
<SelectTrigger className="w-16 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||
<SelectItem value="10">10</SelectItem>
|
||
<SelectItem value="20">20</SelectItem>
|
||
<SelectItem value="30">30</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<span>条</span>
|
||
<span className="ml-4">共 {total} 条</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||
disabled={page <= 1}
|
||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
|
||
{page} / {Math.max(1, Math.ceil(total / pageSize))}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||
disabled={page >= Math.ceil(total / pageSize)}
|
||
onClick={() => setPage(p => p + 1)}
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 创建/编辑服务器 Dialog */}
|
||
<ServerForm
|
||
open={formOpen}
|
||
onOpenChange={setFormOpen}
|
||
editingId={editingId}
|
||
formData={formData}
|
||
setFormData={setFormData}
|
||
detailsOpen={detailsOpen}
|
||
setDetailsOpen={setDetailsOpen}
|
||
onSave={handleSave}
|
||
formSaving={formSaving}
|
||
/>
|
||
|
||
{/* Import Dialogs */}
|
||
<CloudServerImport
|
||
open={cloudImportOpen}
|
||
onClose={() => {
|
||
setCloudImportOpen(false);
|
||
loadData();
|
||
}}
|
||
/>
|
||
<ExcelServerImport
|
||
open={excelImportOpen}
|
||
onClose={() => {
|
||
setExcelImportOpen(false);
|
||
loadData();
|
||
}}
|
||
/>
|
||
|
||
{/* 终端 Dialog */}
|
||
<TerminalDialog
|
||
open={terminalOpen}
|
||
onOpenChange={setTerminalOpen}
|
||
serverId={terminalServer?.id ?? null}
|
||
serverName={terminalServer?.name ?? ''}
|
||
serverIp={terminalServer?.ip ?? ''}
|
||
hasAuth={terminalServer?.hasAuth ?? true}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|