- 后端: 新增 ExportResourcesAllNS handler (GET /:clusterCode/export),遍历所有NS收集资源 - 前端: KubernetesClusters 导出弹窗去掉命名空间输入框,一键导出全部NS - API: exportResources → exportAllResources,无需传 namespace 参数
487 lines
20 KiB
TypeScript
487 lines
20 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,
|
|
Download,
|
|
} 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 [exportCluster, setExportCluster] = useState<any>(null);
|
|
const [exporting, setExporting] = 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 handleExport = async () => {
|
|
if (!exportCluster) return;
|
|
const code = exportCluster.code || String(exportCluster.id);
|
|
try {
|
|
setExporting(true);
|
|
toast.info(`正在导出 ${exportCluster.name} 所有命名空间的资源...`);
|
|
await kubernetesApi.exportAllResources(code);
|
|
toast.success('导出完成,已开始下载');
|
|
setExportCluster(null);
|
|
} catch (e: any) {
|
|
toast.error(e?.message || '导出失败');
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
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>
|
|
<DropdownMenuItem
|
|
className="text-blue-400 focus:bg-slate-700 focus:text-blue-300"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setExportCluster(cluster);
|
|
}}
|
|
>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
导出资源
|
|
</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}
|
|
/>
|
|
)}
|
|
|
|
{/* 导出资源弹窗 */}
|
|
{exportCluster && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setExportCluster(null)}>
|
|
<div className="bg-slate-900 border border-slate-700 rounded-lg p-5 w-96 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
|
<h3 className="text-base font-semibold text-slate-100 mb-1">导出集群资源</h3>
|
|
<p className="text-xs text-slate-400 mb-4">
|
|
导出 {exportCluster.name} 所有命名空间的 Deployment、Service、Ingress、ConfigMap、Secret 为 tar.gz
|
|
</p>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setExportCluster(null)}>取消</Button>
|
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleExport} disabled={exporting}>
|
|
{exporting ? '导出中...' : '确认导出'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|