Files
aiops/frontend/src/components/server/ServerManager.tsx
T
attacker 3e9352f690 feat(i18n): ServerManager 服务器管理页国际化 — 27 处硬编码中文改翻译键 + server 命名空间(48 键)
- ServerManager.tsx: 页面标题/按钮/搜索/状态筛选/表头/分页/toast/CSV导出/确认弹框 全量 t() 化
- 复用 common.* 通用键(refresh/export/delete/status/actions/loading/running/stopped/maintenance/offline)
- 新增 server.* 命名空间: title/subtitle/batchImport/cloudImport/csvImport/addServer/searchPlaceholder/tableHeaders/pagination/toast/csv 子组
- zh-CN/en-US 键总数 242→290 (server 48 键, 中英完全对齐)
- tsc -b=0, npm build=✓(19.13s), 17/17 集成测试通过
2026-06-29 13:25:18 +08:00

631 lines
24 KiB
TypeScript

import { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
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 { 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';
export function ServerManager() {
const { t } = useTranslation();
const confirm = useConfirm();
const queryClient = useQueryClient();
const { hasPermission } = usePermission();
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);
// 文件浏览 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<string, unknown> = { 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, 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<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 = () => {
// 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<string, unknown> = {
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 = 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)]">{t('server.title')}</h1>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">{t('server.subtitle')}</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={() => refetch()}
>
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
{t('common.refresh')}
</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" />
{t('common.export')}
</Button>
{hasPermission('server:create') && (
<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" />
{t('server.batchImport')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DropdownMenuLabel>{t('server.importMethod')}</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)]" />
{t('server.cloudImport')}
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">{t('server.cloudImportHint')}</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" />
{t('server.csvImport')}
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">{t('server.csvImportHint')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{hasPermission('server:create') && (
<Button
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
onClick={openCreateForm}
>
<Plus className="h-4 w-4 mr-2" />
{t('server.addServer')}
</Button>
)}
</div>
</div>
{/* Stats Cards */}
<ServerStatsStrip 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={t('server.searchPlaceholder')}
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={t('common.status')} />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="all">{t('server.allStatus')}</SelectItem>
<SelectItem value="running">{t('common.running')}</SelectItem>
<SelectItem value="stopped">{t('common.stopped')}</SelectItem>
<SelectItem value="maintenance">{t('common.maintenance')}</SelectItem>
<SelectItem value="offline">{t('common.offline')}</SelectItem>
</SelectContent>
</Select>
</div>
{selectedServers.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--dg-text-secondary)]">{t('server.selected', { count: selectedServers.length })}</span>
{hasPermission('server:delete') && (
<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" />
{t('server.batchDelete')}
</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)]">{t('server.hostname')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.ipAddress')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.publicIp')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('common.status')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.config')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.usage')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('server.tags')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] text-right">{t('common.actions')}</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" />
{t('common.loading')}
</TableCell>
</TableRow>
) : servers.length === 0 ? (
<TableRow className="border-[var(--dg-border)]">
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
{t('server.noData')}
</TableCell>
</TableRow>
) : (
servers.map((server) => (
<ServerTableRow
key={server.id}
server={server}
selected={selectedServers.includes(String(server.id))}
onToggleSelect={toggleSelect}
onOpenTerminal={openTerminal}
onOpenFiles={openFileBrowser}
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>{t('server.perPage')}</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>{t('server.items')}</span>
<span className="ml-4">{t('server.totalItems', { count: 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);
refetch();
}}
/>
<ExcelServerImport
open={excelImportOpen}
onClose={() => {
setExcelImportOpen(false);
refetch();
}}
/>
{/* 终端 Dialog */}
<TerminalDialog
open={terminalOpen}
onOpenChange={setTerminalOpen}
serverId={terminalServer?.id ?? null}
serverName={terminalServer?.name ?? ''}
serverIp={terminalServer?.ip ?? ''}
hasAuth={terminalServer?.hasAuth ?? true}
/>
{/* 文件浏览 Dialog */}
<ServerFileBrowser
open={fileBrowserOpen}
onOpenChange={setFileBrowserOpen}
serverId={fileBrowserServer?.id ?? null}
serverName={fileBrowserServer?.name ?? ''}
/>
</div>
);
}