Files
aiops/frontend/src/components/server/ServerManager.tsx
T
attacker ed08ea255e feat: Agent批量升级 + 版本号注入 + 服务器凭证下拉关联
- 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 列表, 选择后自动填充用户名/认证方式
2026-06-19 01:24:47 +00:00

608 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}