Files
aiops/frontend/src/components/kubernetes/KubernetesClusters.tsx
T
attacker 9dbb7e9fc4 feat: K8s 集群同步功能 + 表单简化
- 新增手动同步按钮(卡片下拉菜单「同步」)
- 新建集群后自动触发同步(延迟500ms等列表刷新)
- 同步后刷新集群列表,节点/Pod/Deployment 数据写入 DB
- 集群表单:名称即code,正则校验英文/数字/连字符
2026-06-21 10:53:30 +00:00

438 lines
18 KiB
TypeScript

import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Cloud,
Plus,
MoreHorizontal,
Server,
Database,
CheckCircle,
AlertTriangle,
XCircle,
RefreshCw,
Settings,
Eye,
Trash2,
Search,
Container,
} from 'lucide-react';
import { kubernetesApi } from '@/services/api';
import { toast } from 'sonner';
import type { KubernetesCluster } from '@/types';
import { KubernetesClusterDetail } from './KubernetesClusterDetail';
import { KubernetesClusterDialog } from './KubernetesClusterDialog';
import { KubectlTerminal } from './KubectlTerminal';
export function KubernetesClusters() {
const [clusters, setClusters] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// 创建/编辑集群弹窗
const [dialogOpen, setDialogOpen] = useState(false);
const [editingCluster, setEditingCluster] = useState<any>(null);
// kubectl 终端
const [kubectlCluster, setKubectlCluster] = useState<{id: number; name: string} | null>(null);
const [kubectlTerminalOpen, setKubectlTerminalOpen] = useState(false);
const loadData = async () => {
try {
setLoading(true);
const res: any = await kubernetesApi.getClusters();
setClusters(res?.list || []);
} catch (e) {
console.error('加载集群列表失败:', e);
setClusters([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
// 新建后自动同步
const [pendingSyncId, setPendingSyncId] = useState<number | null>(null);
const handleSaved = () => {
loadData().then(() => {
if (!editingCluster) {
// 新建集群后,等列表刷新完再取最新一条同步
setTimeout(async () => {
const res: any = await kubernetesApi.getClusters();
const list = res?.list || [];
if (list.length > 0) {
handleSync(list[list.length - 1].id, true);
}
}, 500);
}
});
};
const handleDelete = async (id: string) => {
try {
await kubernetesApi.deleteCluster(id);
loadData();
} catch (e) {
console.error('删除集群失败:', e);
}
};
const handleSync = async (id: number | string, silent?: boolean) => {
try {
if (!silent) toast.info('正在同步集群资源...');
const res: any = await kubernetesApi.syncCluster(id);
if (!silent) toast.success(res?.message || '同步完成');
loadData();
} catch (e: any) {
if (!silent) toast.error(e?.message || '同步失败');
}
};
const filteredClusters = clusters.filter(
(cluster) =>
(cluster.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(cluster.version || '').includes(searchQuery)
);
const getStatusBadge = (status: KubernetesCluster['status']) => {
switch (status) {
case 'healthy':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
健康
</Badge>
);
case 'warning':
return (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
<AlertTriangle className="h-3 w-3 mr-1" />
警告
</Badge>
);
case 'error':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<XCircle className="h-3 w-3 mr-1" />
异常
</Badge>
);
default:
return null;
}
};
const clusterDetail = selectedCluster ? clusters.find((c) => c.id === selectedCluster) : null;
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">集群列表</h1>
<p className="text-sm text-slate-500 mt-1">管理Kubernetes集群资源</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={loadData}
>
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
刷新
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => { setEditingCluster(null); setDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-2" />
添加集群
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">集群总数</p>
<p className="text-2xl font-bold text-slate-100">{clusters.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Cloud className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">健康集群</p>
<p className="text-2xl font-bold text-emerald-400">
{clusters.filter((c) => c.status === 'healthy').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">总节点数</p>
<p className="text-2xl font-bold text-slate-100">
{clusters.reduce((acc, c) => acc + (c.node_count || 0), 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
<Server className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">总Pod数</p>
<p className="text-2xl font-bold text-slate-100">
{clusters.reduce((acc, c) => acc + (c.pod_count || 0), 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
<Database className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Search */}
<div className="flex items-center gap-3">
<div className="relative w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索集群名称或版本..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
</div>
{/* Clusters Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{loading ? (
<Card className="bg-slate-900 border-slate-800 col-span-full">
<CardContent className="p-12 text-center text-slate-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
加载中...
</CardContent>
</Card>
) : filteredClusters.length === 0 ? (
<Card className="bg-slate-900 border-slate-800 col-span-full">
<CardContent className="p-12 text-center text-slate-500">暂无集群数据</CardContent>
</Card>
) : (
filteredClusters.map((cluster) => {
const cpuUsage = Number(cluster.cpu_usage || 0);
const memoryUsage = Number(cluster.memory_usage || 0);
const hasMetrics = cpuUsage > 0 || memoryUsage > 0;
return (
<Card
key={cluster.id}
className={cn(
'bg-slate-900 border-slate-800 cursor-pointer transition-all',
selectedCluster === cluster.id
? 'border-blue-500 ring-1 ring-blue-500/30'
: 'hover:border-slate-700'
)}
onClick={() => setSelectedCluster(cluster.id)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<Cloud className="h-5 w-5 text-blue-400" />
</div>
<div>
<CardTitle className="text-base text-slate-100">{cluster.name}</CardTitle>
<CardDescription className="text-slate-500">{cluster.version}</CardDescription>
</div>
</div>
<div className="flex items-center gap-1">
{getStatusBadge(cluster.status)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Eye className="h-4 w-4 mr-2" />
查看详情
</DropdownMenuItem>
<DropdownMenuItem
className="text-slate-300 focus:bg-slate-700 focus:text-slate-100"
onClick={(e) => {
e.stopPropagation();
setEditingCluster(cluster);
setDialogOpen(true);
}}
>
<Settings className="h-4 w-4 mr-2" />
配置
</DropdownMenuItem>
<DropdownMenuItem
className="text-cyan-400 focus:bg-slate-700 focus:text-cyan-300"
onClick={(e) => {
e.stopPropagation();
handleSync(cluster.id);
}}
>
<RefreshCw className="h-4 w-4 mr-2" />
同步
</DropdownMenuItem>
<DropdownMenuItem
className="text-emerald-400 focus:bg-slate-700 focus:text-emerald-300"
onClick={(e) => {
e.stopPropagation();
setKubectlCluster({ id: cluster.id, name: cluster.name });
setKubectlTerminalOpen(true);
}}
>
<Container className="h-4 w-4 mr-2" />
kubectl
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleDelete(cluster.id);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.node_count || 0}</div>
<div className="text-xs text-slate-500">节点</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.pod_count || 0}</div>
<div className="text-xs text-slate-500">Pod</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.service_count || 0}</div>
<div className="text-xs text-slate-500">Service</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.deployment_count || 0}</div>
<div className="text-xs text-slate-500">Deployment</div>
</div>
</div>
<div className="space-y-2">
{hasMetrics ? (
<>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-10">CPU</span>
<div className="flex-1 h-1.5 bg-slate-800 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: `${Math.min(cpuUsage, 100)}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-10 text-right">{cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-10">内存</span>
<div className="flex-1 h-1.5 bg-slate-800 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: `${Math.min(memoryUsage, 100)}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-10 text-right">{memoryUsage.toFixed(1)}%</span>
</div>
</>
) : (
<div className="text-xs text-slate-600 text-center">已接入 {cluster.node_count || 0} 节点 · {cluster.pod_count || 0} Pod · 资源使用率待采集</div>
)}
</div>
</CardContent>
</Card>
);
})
)}
</div>
{/* Cluster Detail */}
{clusterDetail && (
<KubernetesClusterDetail cluster={clusterDetail} onClose={() => setSelectedCluster(null)} />
)}
{/* 创建/编辑集群 Dialog */}
<KubernetesClusterDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingCluster={editingCluster}
onSaved={handleSaved}
/>
{/* kubectl 终端 */}
{kubectlCluster && (
<KubectlTerminal
open={kubectlTerminalOpen}
onOpenChange={(open) => {
setKubectlTerminalOpen(open);
if (!open) setKubectlCluster(null);
}}
clusterId={kubectlCluster.id}
clusterName={kubectlCluster.name}
/>
)}
</div>
);
}