Files
aiops/frontend/src/components/server/ServerManager.tsx
T

641 lines
27 KiB
TypeScript

import { useState, useEffect } 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,
MoreHorizontal,
Terminal,
Trash2,
Edit,
Download,
Upload,
Server as ServerIcon,
Cloud,
FileSpreadsheet,
RefreshCw,
} from 'lucide-react';
import { serverApi } from '@/services/api';
import { ServerStatsCards } from './ServerStatsCards';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import type { Server as ServerType } from '@/types';
import { CloudServerImport } from './CloudServerImport';
import { TerminalDialog } from './TerminalDialog';
import { ExcelServerImport } from './ExcelServerImport';
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
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 } | null>(null);
const openTerminal = (server: any) => {
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip });
setTerminalOpen(true);
};
const loadData = async () => {
try {
setLoading(true);
const res: any = await serverApi.getServers();
setServers(res?.list || []);
} catch (e) {
console.error('加载服务器列表失败:', e);
setServers([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
// 解析标签:后端 tags 可能是 JSON 字符串、逗号分隔字符串或数组
const getTags = (server: any): string[] => {
const tags = server.tags;
if (!tags) return [];
if (Array.isArray(tags)) return tags;
try {
const parsed = JSON.parse(tags);
return Array.isArray(parsed) ? parsed : [String(tags)];
} catch {
return String(tags)
.split(',')
.map((t: string) => t.trim())
.filter(Boolean);
}
};
const filteredServers = servers.filter((server) => {
const matchesSearch =
(server.hostname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(server.ip || '').includes(searchQuery) ||
(server.public_ip || '').includes(searchQuery);
const matchesStatus = statusFilter === 'all' || server.status === statusFilter;
return matchesSearch && matchesStatus;
});
const toggleSelectAll = () => {
if (selectedServers.length === filteredServers.length) {
setSelectedServers([]);
} else {
setSelectedServers(filteredServers.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.password) {
payload.password = formData.password;
}
if (editingId) {
await serverApi.updateServer(editingId, payload);
} else {
await serverApi.createServer(payload);
}
setFormOpen(false);
loadData();
} catch (e) {
console.error('保存服务器失败:', e);
} finally {
setFormSaving(false);
}
};
// 导出 CSV — 将当前服务器列表导出为 CSV 文件 // Logan.Li
const handleExport = () => {
if (filteredServers.length === 0) {
toast.error('没有可导出的数据');
return;
}
const headers = ['主机名', 'IP地址', '公网IP', '端口', '操作系统', 'CPU(核)', '内存(GB)', '磁盘(GB)', '状态', '标签'];
const rows = filteredServers.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(`已导出 ${filteredServers.length} 台服务器`);
};
const getStatusBadge = (status: ServerType['status']) => {
switch (status) {
case 'running':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
运行中
</Badge>
);
case 'stopped':
return (
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] bg-[var(--dg-bg-tertiary)]">
已停止
</Badge>
);
case 'maintenance':
return (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 mr-1.5" />
维护中
</Badge>
);
case 'offline':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
离线
</Badge>
);
default:
return null;
}
};
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" />
Excel导入
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">批量导入服务器</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) => setSearchQuery(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={setStatusFilter}>
<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 === filteredServers.length && filteredServers.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>
) : filteredServers.length === 0 ? (
<TableRow className="border-[var(--dg-border)]">
<TableCell colSpan={colSpanCount} className="text-center text-[var(--dg-text-muted)] py-12">
暂无服务器数据
</TableCell>
</TableRow>
) : (
filteredServers.map((server) => {
const cpuUsage = Number(server.cpu_usage || 0);
const memoryUsage = Number(server.memory_usage || 0);
const tags = getTags(server);
const memGB = server.memory ? Math.round(server.memory / 1024) : 0;
return (
<TableRow key={server.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<TableCell>
<Checkbox
checked={selectedServers.includes(server.id)}
onCheckedChange={() => toggleSelect(server.id)}
className="border-[var(--dg-border)]"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<ServerIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
<div>
<p className="font-medium text-[var(--dg-text-primary)]">{server.hostname}</p>
<p className="text-xs text-[var(--dg-text-muted)]">{server.os}</p>
</div>
</div>
</TableCell>
<TableCell>
<code className="text-sm text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">
{server.ip}
</code>
</TableCell>
<TableCell>
{server.public_ip ? (
<code className="text-sm text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10 px-2 py-0.5 rounded">
{server.public_ip}
</code>
) : (
<span className="text-sm text-[var(--dg-text-muted)]">-</span>
)}
</TableCell>
<TableCell>{getStatusBadge(server.status)}</TableCell>
<TableCell>
<div className="text-sm text-[var(--dg-text-secondary)]">
<span className="text-[var(--dg-text-muted)]">{server.cpu}核</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{memGB}GB</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{server.disk}GB</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--dg-text-muted)] w-8">CPU</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
cpuUsage > 80 ? 'bg-red-400' : cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${cpuUsage}%` }}
/>
</div>
<span className="text-xs text-[var(--dg-text-secondary)]">{cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--dg-text-muted)] w-8">内存</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
memoryUsage > 80 ? 'bg-red-400' : memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${memoryUsage}%` }}
/>
</div>
<span className="text-xs text-[var(--dg-text-secondary)]">{memoryUsage.toFixed(1)}%</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag) => (
<Badge
key={tag}
variant="outline"
className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]/50"
>
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
+{tags.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => openTerminal(server)}>
<Terminal className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => openEditForm(server)}
>
<Edit className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => openTerminal(server)}
>
<Terminal className="h-4 w-4 mr-2" />
终端
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
onClick={() => handleDelete(server.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</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 ?? ''}
/>
</div>
);
}