refactor: 全局重命名 k8s → kubernetes (前后端+路由+表名+文件)
后端: - pkg/k8sclient → pkg/kubernetesclient - models/k8s.go → models/kubernetes.go - handlers/k8s.go → handlers/kubernetes.go - K8sCluster/K8sNode/.. → KubernetesCluster/KubernetesNode/.. - K8sHandler → KubernetesHandler - 路由: /k8s-clusters → /kubernetes-clusters - 路由: /k8s/:code/namespace/:ns/... → /kubernetes/:code/namespace/:ns/... (Kuboard风格) - 表前缀: container_k8s_ → container_kubernetes_ 前端: - components/k8s/ → components/kubernetes/ - K8sClusters → KubernetesClusters - K8sWorkloads → KubernetesWorkloads - K8sNodes → KubernetesNodes - k8sApi → kubernetesApi - 路由前缀: /k8s/ → /kubernetes/ 测试验证: ✅ /kubernetes/k8s-cluster-liren/namespace/kube-system/deployments → 3 items ✅ /kubernetes/k8s-cluster-liren/namespace/kube-system/pods → 7 items ✅ /kubernetes/k8s-cluster-liren/namespace/kube-system/services → 4 items ✅ /kubernetes/k8s-cluster-liren/namespace/kube-system/configmaps → 6 items ✅ /kubernetes/k8s-cluster-liren/namespace/kube-system/secrets → 1 item ✅ /kubernetes/k8s-cluster-liren/namespaces → 4 items
This commit is contained in:
1 parent
f7b667b878
commit
cac1414917
27 files changed
+779
-284
No files matched your search
@@ -0,0 +1,611 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
ArrowLeft, Minus, Plus, RotateCw, Upload, FileText, Terminal,
|
||||
Gauge, Clock, Play, Pause, Trash2, RefreshCw, Loader2,
|
||||
CheckCircle, AlertTriangle, XCircle, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface DeploymentDetailProps {
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
deploymentName: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) {
|
||||
const { toast } = useToast();
|
||||
const [deployment, setDeployment] = useState<any>(null);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
const [hpas, setHpas] = useState<any[]>([]);
|
||||
const [logs, setLogs] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState('');
|
||||
|
||||
// Scale
|
||||
const [scaleValue, setScaleValue] = useState(0);
|
||||
// Image update
|
||||
const [newImage, setNewImage] = useState('');
|
||||
const [showImageUpdate, setShowImageUpdate] = useState(false);
|
||||
// Logs
|
||||
const [logPod, setLogPod] = useState('');
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [logTail, setLogTail] = useState(200);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [depRes, podRes, hpaRes] = await Promise.all([
|
||||
kubernetesApi.getNamespaceDeployments(clusterCode, namespace).catch(() => []),
|
||||
kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []),
|
||||
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => ({ items: [] })),
|
||||
]);
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || []));
|
||||
const allDeps = toArray(depRes);
|
||||
const allPods = toArray(podRes);
|
||||
const allHpas = toArray(hpaRes);
|
||||
|
||||
const dep = allDeps.find((d: any) => d.name === deploymentName && d.namespace === namespace);
|
||||
setDeployment(dep || null);
|
||||
if (dep) {
|
||||
setScaleValue(dep.replicas || 0);
|
||||
setNewImage(dep.image || '');
|
||||
}
|
||||
|
||||
// Filter pods by deployment label (prefix match on pod name)
|
||||
const depPods = allPods.filter((p: any) =>
|
||||
p.namespace === namespace && p.name.startsWith(deploymentName)
|
||||
);
|
||||
setPods(depPods);
|
||||
|
||||
// Filter HPAs targeting this deployment
|
||||
const depHpas = allHpas.filter((h: any) => {
|
||||
const ref = h.spec?.scaleTargetRef;
|
||||
return ref?.name === deploymentName && h.metadata?.namespace === namespace;
|
||||
});
|
||||
setHpas(depHpas);
|
||||
} catch (e) {
|
||||
console.error('加载详情失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clusterCode, namespace, deploymentName]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleScale = async (delta: number) => {
|
||||
const newReplicas = (deployment?.replicas || 0) + delta;
|
||||
if (newReplicas < 0) return;
|
||||
setActionLoading('scale');
|
||||
try {
|
||||
await kubernetesApi.scaleDeployment(clusterCode, namespace, deploymentName, newReplicas);
|
||||
toast({ title: '扩缩容成功', description: `副本数已设置为 ${newReplicas}` });
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setActionLoading('restart');
|
||||
try {
|
||||
await kubernetesApi.restartDeployment(clusterCode, namespace, deploymentName);
|
||||
toast({ title: '重启指令已下发', description: 'Deployment 正在滚动重启' });
|
||||
} catch (e: any) {
|
||||
toast({ title: '重启失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleUpdateImage = async () => {
|
||||
if (!newImage.trim()) return;
|
||||
setActionLoading('image');
|
||||
try {
|
||||
await kubernetesApi.updateDeploymentImage(clusterCode, namespace, deploymentName, newImage);
|
||||
toast({ title: '镜像更新成功', description: `正在滚动更新为 ${newImage}` });
|
||||
setShowImageUpdate(false);
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '更新失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setActionLoading(''); }
|
||||
};
|
||||
|
||||
const handleLoadLogs = async (podName: string) => {
|
||||
setLogPod(podName);
|
||||
setShowLogs(true);
|
||||
setLogs('');
|
||||
try {
|
||||
const res: any = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, logTail);
|
||||
setLogs(res?.data?.logs || res?.logs || '无日志');
|
||||
} catch {
|
||||
setLogs('获取日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHpaToggle = async (hpa: any) => {
|
||||
// Not direct toggle — delete HPA
|
||||
if (!confirm(`确定删除 HPA "${hpa.metadata?.name}"?`)) return;
|
||||
try {
|
||||
await kubernetesApi.deleteHPA(clusterCode, namespace, hpa.metadata?.name);
|
||||
toast({ title: 'HPA 已删除' });
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '删除失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto text-slate-500" />
|
||||
<p className="text-slate-500 mt-3">加载中...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deployment) {
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-12 text-center">
|
||||
<XCircle className="h-8 w-8 mx-auto text-red-400" />
|
||||
<p className="text-slate-400 mt-3">未找到 Deployment: {deploymentName}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={onBack}>返回</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const ready = deployment.ready_replicas || deployment.readyReplicas || 0;
|
||||
const total = deployment.replicas || 0;
|
||||
const available = deployment.available_replicas || deployment.availableReplicas || 0;
|
||||
const isHealthy = ready === total;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />返回
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold text-slate-100">{deploymentName}</h1>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">{namespace}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-0.5">镜像: {deployment.image || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400" onClick={loadData}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">副本状态</p>
|
||||
<p className={`text-lg font-bold ${isHealthy ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{ready}/{total}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">可用副本</p>
|
||||
<p className="text-lg font-bold text-blue-400">{available}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">镜像</p>
|
||||
<p className="text-sm font-mono text-slate-300 truncate max-w-[200px]" title={deployment.image}>
|
||||
{deployment.image?.split('/').pop() || '-'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-slate-500">HPA</p>
|
||||
{hpas.length > 0 ? (
|
||||
<p className="text-lg font-bold text-purple-400">已启用</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-lg font-bold text-slate-500">未配置</p>
|
||||
<CreateHPADialog
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
onCreated={loadData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base text-slate-200">操作</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Scale controls */}
|
||||
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-1">
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'scale' || total <= 0}
|
||||
onClick={() => handleScale(-1)}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-mono text-slate-200 w-8 text-center">
|
||||
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin inline" /> : total}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-8 w-8 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'scale'}
|
||||
onClick={() => handleScale(1)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline" size="sm" className="border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
disabled={actionLoading === 'restart'}
|
||||
onClick={handleRestart}
|
||||
>
|
||||
{actionLoading === 'restart' ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <RotateCw className="h-4 w-4 mr-1" />}
|
||||
滚动重启
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline" size="sm" className="border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
onClick={() => setShowImageUpdate(!showImageUpdate)}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
更新镜像
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Inline image update */}
|
||||
{showImageUpdate && (
|
||||
<div className="mt-3 flex items-center gap-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<Input
|
||||
value={newImage}
|
||||
onChange={(e) => setNewImage(e.target.value)}
|
||||
placeholder="输入新镜像地址,如 harbor.example.com/app:v2.0"
|
||||
className="flex-1 h-9 bg-slate-900 border-slate-700 text-slate-200 text-sm font-mono"
|
||||
/>
|
||||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={actionLoading === 'image' || !newImage.trim()}
|
||||
onClick={handleUpdateImage}
|
||||
>
|
||||
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-slate-400" onClick={() => setShowImageUpdate(false)}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs: Pods / Logs / HPA */}
|
||||
<Tabs defaultValue="pods">
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
|
||||
Pod 列表 ({pods.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="data-[state=active]:bg-slate-700">
|
||||
<FileText className="h-4 w-4 mr-1" />日志 {showLogs && `(${logPod})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="hpa" className="data-[state=active]:bg-slate-700">
|
||||
<Gauge className="h-4 w-4 mr-1" />HPA ({hpas.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Pods Tab */}
|
||||
<TabsContent value="pods">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Pod 名称</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">节点</TableHead>
|
||||
<TableHead className="text-slate-400">IP</TableHead>
|
||||
<TableHead className="text-slate-400">重启</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pods.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
|
||||
暂无 Pod
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pods.map((pod: any) => (
|
||||
<TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={
|
||||
pod.status === 'Running' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||
pod.status === 'Pending' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}>{pod.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{pod.node_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-500 font-mono">{pod.ip || '-'}</TableCell>
|
||||
<TableCell className={pod.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500'}>
|
||||
{pod.restart_count || 0}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
|
||||
title="查看日志" onClick={() => handleLoadLogs(pod.name)}>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-emerald-400"
|
||||
title="终端" onClick={() => toast({ title: '终端功能开发中', description: '需要 SPDY/WebSocket 支持' })}>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Logs Tab */}
|
||||
<TabsContent value="logs">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-400">Pod 日志</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">最近</span>
|
||||
<Input
|
||||
type="number" value={logTail} onChange={(e) => setLogTail(Number(e.target.value))}
|
||||
className="w-20 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">行</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!showLogs ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>点击 Pod 列表中的「日志」按钮查看</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300 text-xs h-7"
|
||||
onClick={async () => {
|
||||
setLogs('');
|
||||
// Stream mode
|
||||
const token = localStorage.getItem('token') || '';
|
||||
const url = `${window.location.protocol}//${window.location.host}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${logPod}/logs?tail=${logTail}&stream=true`;
|
||||
try {
|
||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) return;
|
||||
const decoder = new TextDecoder();
|
||||
let streamLogs = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
streamLogs += decoder.decode(value, { stream: true });
|
||||
setLogs(streamLogs);
|
||||
}
|
||||
} catch (e) {
|
||||
setLogs('流式连接中断');
|
||||
}
|
||||
}}>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />实时流
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">{logPod}</span>
|
||||
</div>
|
||||
<pre className="bg-slate-950 rounded-lg p-4 text-xs text-slate-300 font-mono overflow-auto max-h-[500px] whitespace-pre-wrap">
|
||||
{logs || '无日志内容'}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* HPA Tab */}
|
||||
<TabsContent value="hpa">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm text-slate-400">HPA 自动扩缩容</CardTitle>
|
||||
<CreateHPADialog
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
deploymentName={deploymentName}
|
||||
onCreated={loadData}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hpas.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Gauge className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>未配置 HPA</p>
|
||||
<p className="text-xs mt-1">点击右上角「创建 HPA」设置自动扩缩容</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">名称</TableHead>
|
||||
<TableHead className="text-slate-400">最小/最大</TableHead>
|
||||
<TableHead className="text-slate-400">指标</TableHead>
|
||||
<TableHead className="text-slate-400">当前/目标</TableHead>
|
||||
<TableHead className="text-slate-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.map((hpa: any) => {
|
||||
const spec = hpa.spec || {};
|
||||
const status = hpa.status || {};
|
||||
const metrics = (spec.metrics || []).map((m: any) => {
|
||||
if (m.resource) return `${m.resource.name}: ${m.resource.target.averageUtilization}%`;
|
||||
return m.type;
|
||||
}).join(', ');
|
||||
return (
|
||||
<TableRow key={hpa.metadata?.name} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{hpa.metadata?.name}</TableCell>
|
||||
<TableCell className="text-slate-300">{spec.minReplicas} / {spec.maxReplicas}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{metrics || '-'}</TableCell>
|
||||
<TableCell className="text-slate-300">{status.currentReplicas || 0} / {status.desiredReplicas || 0}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300"
|
||||
onClick={() => handleHpaToggle(hpa)} title="删除 HPA">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Create HPA Dialog (inline)
|
||||
// ============================================================================
|
||||
function CreateHPADialog({ clusterCode, namespace, deploymentName, onCreated }: {
|
||||
clusterCode: string; namespace: string; deploymentName: string; onCreated: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
minReplicas: 1,
|
||||
maxReplicas: 10,
|
||||
cpuPercent: 80,
|
||||
memPercent: 0,
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast({ title: '名称不能为空', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await kubernetesApi.createHPA(clusterCode, {
|
||||
name: form.name,
|
||||
namespace,
|
||||
targetKind: 'Deployment',
|
||||
targetName: deploymentName,
|
||||
minReplicas: form.minReplicas,
|
||||
maxReplicas: form.maxReplicas,
|
||||
cpuPercent: form.cpuPercent,
|
||||
memPercent: form.memPercent,
|
||||
});
|
||||
toast({ title: 'HPA 创建成功' });
|
||||
setOpen(false);
|
||||
onCreated();
|
||||
setForm({ name: '', minReplicas: 1, maxReplicas: 10, cpuPercent: 80, memPercent: 0 });
|
||||
} catch (e: any) {
|
||||
toast({ title: '创建失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => setOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />创建 HPA
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setOpen(false)}>
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[480px] max-h-[80vh] overflow-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-bold text-slate-100 mb-4">创建 HPA</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">名称</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={`hpa-${deploymentName}`}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">目标</Label>
|
||||
<p className="text-slate-300 text-sm mt-1 bg-slate-800 rounded px-3 py-2">
|
||||
Deployment: {deploymentName} · 命名空间: {namespace}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">最小副本数</Label>
|
||||
<Input type="number" value={form.minReplicas} min={1}
|
||||
onChange={(e) => setForm({ ...form, minReplicas: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">最大副本数</Label>
|
||||
<Input type="number" value={form.maxReplicas} min={1}
|
||||
onChange={(e) => setForm({ ...form, maxReplicas: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">CPU 目标 (%)</Label>
|
||||
<Input type="number" value={form.cpuPercent} min={0} max={100}
|
||||
onChange={(e) => setForm({ ...form, cpuPercent: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-400 text-sm">内存目标 (%) <span className="text-slate-600">可选</span></Label>
|
||||
<Input type="number" value={form.memPercent} min={0} max={100}
|
||||
onChange={(e) => setForm({ ...form, memPercent: Number(e.target.value) })}
|
||||
className="bg-slate-800 border-slate-700 text-slate-200 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled={loading} onClick={handleCreate}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Cloud, XCircle } from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
// 模拟资源使用趋势数据
|
||||
const resourceData = [
|
||||
{ time: '00:00', cpu: 45, memory: 52 },
|
||||
{ time: '04:00', cpu: 38, memory: 48 },
|
||||
{ time: '08:00', cpu: 62, memory: 68 },
|
||||
{ time: '12:00', cpu: 78, memory: 82 },
|
||||
{ time: '16:00', cpu: 72, memory: 76 },
|
||||
{ time: '20:00', cpu: 58, memory: 64 },
|
||||
{ time: '24:00', cpu: 48, memory: 56 },
|
||||
];
|
||||
|
||||
export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; onClose: () => void }) {
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
const [deployments, setDeployments] = useState<any[]>([]);
|
||||
const [services, setServices] = useState<any[]>([]);
|
||||
const [ingresses, setIngresses] = useState<any[]>([]);
|
||||
|
||||
const loadClusterDetail = async (clusterId: string) => {
|
||||
try {
|
||||
// 后端 ListNodes/ListPods/etc 返回 data 直接为数组(非 {list,total})
|
||||
const [nodesRes, podsRes, depRes, svcRes, ingRes] = await Promise.all([
|
||||
kubernetesApi.getNodes(clusterId).catch(() => []),
|
||||
kubernetesApi.getPods(clusterId).catch(() => []),
|
||||
kubernetesApi.getDeployments(clusterId).catch(() => []),
|
||||
kubernetesApi.getServices(clusterId).catch(() => []),
|
||||
kubernetesApi.getIngresses(clusterId).catch(() => []),
|
||||
]);
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
setNodes(toArray(nodesRes));
|
||||
setPods(toArray(podsRes));
|
||||
setDeployments(toArray(depRes));
|
||||
setServices(toArray(svcRes));
|
||||
setIngresses(toArray(ingRes));
|
||||
} catch (e) {
|
||||
console.error('加载集群详情失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (cluster?.id) {
|
||||
loadClusterDetail(cluster.id);
|
||||
}
|
||||
}, [cluster?.id]);
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cloud className="h-6 w-6 text-blue-400" />
|
||||
<div>
|
||||
<CardTitle className="text-slate-100">{cluster.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">
|
||||
{cluster.version} · {cluster.node_count || 0} 节点 · 创建于{' '}
|
||||
{cluster.created_at ? new Date(cluster.created_at).toLocaleDateString() : '-'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-slate-100"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="bg-slate-800 border border-slate-700 flex-wrap h-auto">
|
||||
<TabsTrigger value="overview" className="data-[state=active]:bg-slate-700">
|
||||
概览
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="nodes" className="data-[state=active]:bg-slate-700">
|
||||
节点 ({nodes.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
|
||||
Pod ({pods.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700">
|
||||
Deployment ({deployments.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="services" className="data-[state=active]:bg-slate-700">
|
||||
Service ({services.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ingresses" className="data-[state=active]:bg-slate-700">
|
||||
Ingress ({ingresses.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
|
||||
事件
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-slate-400">资源使用趋势</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={resourceData}>
|
||||
<defs>
|
||||
<linearGradient id="colorCpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorMemory" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="time" stroke="#475569" fontSize={12} />
|
||||
<YAxis stroke="#475569" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0f172a',
|
||||
border: '1px solid #1e293b',
|
||||
borderRadius: '6px',
|
||||
color: '#f8fafc',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#3b82f6"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCpu)"
|
||||
name="CPU"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
stroke="#10b981"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorMemory)"
|
||||
name="内存"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-slate-400">资源分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-blue-400">{cluster.pod_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Pod</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-emerald-400">{cluster.service_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Service</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-purple-400">{cluster.deployment_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Deployment</div>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-slate-800">
|
||||
<div className="text-3xl font-bold text-amber-400">{cluster.node_count || 0}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">节点</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="nodes" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">角色</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">状态</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">CPU</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">内存</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">Pod</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">操作系统</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{nodes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">暂无节点数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
nodes.map((node) => (
|
||||
<tr key={node.id || node.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{node.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
node.role === 'master'
|
||||
? 'border-purple-500/30 text-purple-400 bg-purple-500/10'
|
||||
: 'border-blue-500/30 text-blue-400 bg-blue-500/10'
|
||||
}
|
||||
>
|
||||
{node.role === 'master' ? 'Master' : 'Worker'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
(node.status || '').toLowerCase() === 'ready'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
}
|
||||
>
|
||||
{(node.status || '').toLowerCase() === 'ready' ? 'Ready' : 'NotReady'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
{Number(node.cpu_usage || 0).toFixed(1)}% / {node.cpu}核
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
{Number(node.memory_usage || 0).toFixed(1)}% / {node.memory}GB
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{node.pods ?? node.pod_count ?? 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{node.os}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pods" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">状态</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">重启次数</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">CPU</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">内存</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">节点</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{pods.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">暂无Pod数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
pods.map((pod) => (
|
||||
<tr key={pod.id || pod.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{pod.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{pod.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
(pod.status || '').toLowerCase() === 'running'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: (pod.status || '').toLowerCase() === 'pending'
|
||||
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
: 'border-red-500/30 text-red-400 bg-red-500/10'
|
||||
}
|
||||
>
|
||||
{pod.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{pod.restart_count ?? 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{Number(pod.cpu_usage || 0).toFixed(1)}m</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{Number(pod.memory_usage || 0).toFixed(0)}Mi</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{pod.node_name}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deployments" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">副本数</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">可用副本</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">镜像</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{deployments.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">暂无Deployment数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
deployments.map((deployment) => (
|
||||
<tr key={deployment.id || deployment.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{deployment.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{deployment.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{deployment.replicas}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-sm ${(deployment.available_replicas ?? deployment.availableReplicas) === deployment.replicas ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{deployment.available_replicas ?? deployment.availableReplicas} / {deployment.replicas}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400 truncate max-w-[200px]">{deployment.image}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{deployment.created_at || deployment.createdAt}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="services" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">类型</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">ClusterIP</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">端口</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">Selector</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{services.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">暂无Service数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
services.map((service) => (
|
||||
<tr key={service.id || service.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{service.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{service.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
service.type === 'ClusterIP'
|
||||
? 'border-blue-500/30 text-blue-400 bg-blue-500/10'
|
||||
: service.type === 'NodePort'
|
||||
? 'border-purple-500/30 text-purple-400 bg-purple-500/10'
|
||||
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
}
|
||||
>
|
||||
{service.type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{service.cluster_ip ?? service.clusterIP}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{service.ports}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{service.selector}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ingresses" className="mt-4">
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">Hosts</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">后端服务</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">TLS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{ingresses.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">暂无Ingress数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
ingresses.map((ingress) => (
|
||||
<tr key={ingress.id || ingress.name} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-sm text-slate-200">{ingress.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{ingress.namespace}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{ingress.hosts}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">{ingress.backend}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
ingress.tls
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-slate-700 text-slate-400'
|
||||
}
|
||||
>
|
||||
{ingress.tls ? '已启用' : '未启用'}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="events" className="mt-4">
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ type: 'Normal', reason: 'Started', message: 'Started container nginx', time: '2分钟前' },
|
||||
{ type: 'Normal', reason: 'Created', message: 'Created pod nginx-deployment-xxx', time: '5分钟前' },
|
||||
{ type: 'Warning', reason: 'FailedScheduling', message: '0/3 nodes are available', time: '10分钟前' },
|
||||
{ type: 'Normal', reason: 'Pulling', message: 'Pulling image "nginx:latest"', time: '15分钟前' },
|
||||
{ type: 'Normal', reason: 'Pulled', message: 'Successfully pulled image', time: '12分钟前' },
|
||||
].map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
event.type === 'Normal'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
}
|
||||
>
|
||||
{event.type}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-slate-300 min-w-[120px]">{event.reason}</span>
|
||||
<span className="text-sm text-slate-400 flex-1">{event.message}</span>
|
||||
<span className="text-xs text-slate-500">{event.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
version: '',
|
||||
description: '',
|
||||
config_type: 1, // 0=Token, 1=Kubeconfig
|
||||
api_server: '',
|
||||
token: '',
|
||||
kube_config: '',
|
||||
};
|
||||
|
||||
export function KubernetesClusterDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingCluster,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingCluster: any;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [formData, setFormData] = useState({ ...emptyForm });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [hasToken, setHasToken] = useState(false); // 已配置 Token 标记 // Logan.Li
|
||||
const [hasKubeConfig, setHasKubeConfig] = useState(false); // 已配置 Kubeconfig 标记
|
||||
|
||||
// 打开时同步表单(编辑时拉取详情,新建时重置)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editingCluster && editingCluster.id) {
|
||||
// 编辑模式:拉取详情获取 has_token/has_kube_config 标记 // Logan.Li
|
||||
kubernetesApi.getClusterDetail(editingCluster.id).then((res: any) => {
|
||||
const d = res || {};
|
||||
setHasToken(!!d.has_token);
|
||||
setHasKubeConfig(!!d.has_kube_config);
|
||||
setFormData({
|
||||
name: d.name || '',
|
||||
version: d.version || '',
|
||||
description: d.description || '',
|
||||
config_type: Number(d.config_type ?? 1),
|
||||
api_server: d.api_server || '',
|
||||
token: '', // 不回填原文,留空不修改
|
||||
kube_config: '', // 不回填原文,留空不修改
|
||||
});
|
||||
}).catch(() => {
|
||||
// 降级:用列表数据
|
||||
setFormData({
|
||||
name: editingCluster.name || '',
|
||||
version: editingCluster.version || '',
|
||||
description: editingCluster.description || '',
|
||||
config_type: Number(editingCluster.config_type ?? 1),
|
||||
api_server: editingCluster.api_server || '',
|
||||
token: '',
|
||||
kube_config: '',
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setHasToken(false);
|
||||
setHasKubeConfig(false);
|
||||
setFormData({ ...emptyForm });
|
||||
}
|
||||
}, [open, editingCluster]);
|
||||
|
||||
// 从 kubeconfig YAML 中解析 server 地址
|
||||
const parseApiServer = (yaml: string): string => {
|
||||
const match = yaml.match(/^\s*server:\s*(\S+)\s*$/m);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
|
||||
const handleKubeConfigChange = (value: string) => {
|
||||
const server = parseApiServer(value);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
kube_config: value,
|
||||
api_server: server || prev.api_server,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
version: formData.version,
|
||||
description: formData.description,
|
||||
config_type: formData.config_type,
|
||||
api_server: formData.api_server,
|
||||
token: formData.config_type === 0 ? formData.token : '',
|
||||
kube_config: formData.config_type === 1 ? formData.kube_config : '',
|
||||
};
|
||||
if (editingCluster) {
|
||||
await kubernetesApi.updateCluster(editingCluster.id, payload);
|
||||
} else {
|
||||
await kubernetesApi.createCluster(payload);
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
console.error('保存集群失败:', e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">
|
||||
{editingCluster ? '编辑集群' : '添加集群'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
{editingCluster ? '修改集群接入配置' : '配置新的 Kubernetes 集群接入信息'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2 max-h-[60vh] overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">集群名称</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="例如:prod-cluster"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">版本(可选)</Label>
|
||||
<Input
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
||||
placeholder="例如:1.28.0"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="集群用途描述"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">接入方式</Label>
|
||||
<RadioGroup
|
||||
value={String(formData.config_type)}
|
||||
onValueChange={(v) => setFormData({ ...formData, config_type: Number(v) })}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-hover)]">
|
||||
<RadioGroupItem value="0" id="ct-0" />
|
||||
<Label htmlFor="ct-0" className="cursor-pointer text-[var(--dg-text-primary)] font-normal">Token</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-[var(--dg-border)] cursor-pointer hover:bg-[var(--dg-bg-hover)]">
|
||||
<RadioGroupItem value="1" id="ct-1" />
|
||||
<Label htmlFor="ct-1" className="cursor-pointer text-[var(--dg-text-primary)] font-normal">Kubeconfig</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">API Server</Label>
|
||||
<Input
|
||||
value={formData.api_server}
|
||||
onChange={(e) => setFormData({ ...formData, api_server: e.target.value })}
|
||||
placeholder="https://10.0.0.1:6443"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.config_type === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">
|
||||
Token {hasToken && <span className="text-emerald-400 text-xs ml-1">✓ 已配置(留空不修改)</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={formData.token}
|
||||
onChange={(e) => setFormData({ ...formData, token: e.target.value })}
|
||||
placeholder={hasToken ? '已配置,输入新值覆盖...' : '粘贴 Service Account Token...'}
|
||||
rows={4}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">
|
||||
Kubeconfig {hasKubeConfig && <span className="text-emerald-400 text-xs ml-1">✓ 已配置(留空不修改)</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={formData.kube_config}
|
||||
onChange={(e) => handleKubeConfigChange(e.target.value)}
|
||||
placeholder={hasKubeConfig ? '已配置,输入新值覆盖...' : 'apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://10.0.0.1:6443\n ...'}
|
||||
rows={10}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-xs"
|
||||
/>
|
||||
{!hasKubeConfig && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
粘贴 kubeconfig 内容后将自动解析 API Server 地址
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { onOpenChange(false); }}
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !formData.name.trim()}
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
>
|
||||
{submitting ? '保存中...' : editingCluster ? '保存' : '添加'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import type { KubernetesCluster } from '@/types';
|
||||
import { KubernetesClusterDetail } from './KubernetesClusterDetail';
|
||||
import { KubernetesClusterDialog } from './KubernetesClusterDialog';
|
||||
|
||||
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);
|
||||
|
||||
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 handleDelete = async (id: string) => {
|
||||
try {
|
||||
await kubernetesApi.deleteCluster(id);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error('删除集群失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
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>
|
||||
<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">
|
||||
<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: `${cpuUsage}%` }}
|
||||
/>
|
||||
</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: `${memoryUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 w-10 text-right">{memoryUsage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cluster Detail */}
|
||||
{clusterDetail && (
|
||||
<KubernetesClusterDetail cluster={clusterDetail} onClose={() => setSelectedCluster(null)} />
|
||||
)}
|
||||
|
||||
{/* 创建/编辑集群 Dialog */}
|
||||
<KubernetesClusterDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editingCluster={editingCluster}
|
||||
onSaved={loadData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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 {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { RefreshCw, Search, Server, Loader2 } from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
|
||||
export function KubernetesNodes() {
|
||||
const [clusters, setClusters] = useState<any[]>([]);
|
||||
const [selectedCluster, setSelectedCluster] = useState('');
|
||||
const [nodes, setNodes] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await kubernetesApi.getClusters();
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (list.length > 0 && !selectedCluster) setSelectedCluster(String(list[0].id));
|
||||
} catch { setClusters([]); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadClusters(); }, [loadClusters]);
|
||||
|
||||
const loadNodes = useCallback(async () => {
|
||||
if (!selectedCluster) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await kubernetesApi.getNodes(selectedCluster);
|
||||
setNodes(Array.isArray(res) ? res : (res?.list || []));
|
||||
} catch { setNodes([]); } finally { setLoading(false); }
|
||||
}, [selectedCluster]);
|
||||
|
||||
useEffect(() => { loadNodes(); }, [loadNodes]);
|
||||
|
||||
const filtered = nodes.filter((n) =>
|
||||
(n.name || '').toLowerCase().includes(search.toLowerCase()) &&
|
||||
(roleFilter === 'all' || n.role === roleFilter),
|
||||
);
|
||||
|
||||
const getRoleBadge = (role: string) =>
|
||||
role === 'master'
|
||||
? <Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30">Master</Badge>
|
||||
: <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">Worker</Badge>;
|
||||
|
||||
const getStatusBadge = (status: string) =>
|
||||
status === 'Ready'
|
||||
? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">{status}</Badge>
|
||||
: <Badge variant="destructive">{status || 'Unknown'}</Badge>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">K8s 集群工作节点状态与资源</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedCluster} onValueChange={setSelectedCluster}>
|
||||
<SelectTrigger className="w-48 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="选择集群" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{clusters.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadNodes} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '总节点', value: nodes.length, color: 'text-blue-400' },
|
||||
{ label: 'Master', value: nodes.filter((n) => n.role === 'master').length, color: 'text-purple-400' },
|
||||
{ label: 'Worker', value: nodes.filter((n) => n.role === 'worker').length, color: 'text-emerald-400' },
|
||||
{ label: '就绪', value: nodes.filter((n) => n.status === 'Ready').length, color: 'text-cyan-400' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-slate-500">{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input placeholder="搜索节点..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all">全部角色</SelectItem>
|
||||
<SelectItem value="master">Master</SelectItem>
|
||||
<SelectItem value="worker">Worker</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">节点名称</TableHead>
|
||||
<TableHead className="text-slate-400">IP</TableHead>
|
||||
<TableHead className="text-slate-400">角色</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">配置</TableHead>
|
||||
<TableHead className="text-slate-400">使用率</TableHead>
|
||||
<TableHead className="text-slate-400">Pods</TableHead>
|
||||
<TableHead className="text-slate-400">Kubelet</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Server className="h-8 w-8 mx-auto mb-2 opacity-50" />{selectedCluster ? '暂无节点数据' : '请选择集群'}</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
<TableRow key={n.id} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{n.name}</TableCell>
|
||||
<TableCell><code className="text-sm text-slate-300 bg-slate-800 px-1.5 py-0.5 rounded">{n.ip || '-'}</code></TableCell>
|
||||
<TableCell>{getRoleBadge(n.role)}</TableCell>
|
||||
<TableCell>{getStatusBadge(n.status)}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">
|
||||
{n.cpu ? `${n.cpu}核` : '-'} / {n.memory ? `${Math.round(n.memory / 1024)}GB` : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const cpu = n.cpu_usage || 0;
|
||||
const mem = n.memory_usage || 0;
|
||||
const bar = (val: number) => val >= 85 ? 'bg-red-500' : val >= 60 ? 'bg-yellow-500' : 'bg-emerald-500';
|
||||
const txt = (val: number) => val >= 85 ? 'text-red-400' : val >= 60 ? 'text-yellow-400' : 'text-emerald-400';
|
||||
return (
|
||||
<div className="flex flex-col gap-1 min-w-[120px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-7">CPU</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${bar(cpu)}`} style={{ width: `${Math.min(cpu, 100)}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs w-9 text-right ${txt(cpu)}`}>{cpu.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-7">Mem</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${bar(mem)}`} style={{ width: `${Math.min(mem, 100)}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs w-9 text-right ${txt(mem)}`}>{mem.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{n.pod_count || 0} / {n.max_pods || '-'}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500">{n.kubelet_version || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
RefreshCw, Search, Boxes, Loader2, Container,
|
||||
Plus, Minus, RotateCw, Upload, FileText, Eye,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { DeploymentDetail } from './DeploymentDetail';
|
||||
|
||||
export function KubernetesWorkloads() {
|
||||
const { toast } = useToast();
|
||||
const [clusters, setClusters] = useState<any[]>([]);
|
||||
const [selectedCluster, setSelectedCluster] = useState('');
|
||||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||||
const [selectedNamespace, setSelectedNamespace] = useState('all');
|
||||
const [deployments, setDeployments] = useState<any[]>([]);
|
||||
const [pods, setPods] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('deployments');
|
||||
const [actionLoading, setActionLoading] = useState<Record<string, string>>({});
|
||||
|
||||
// Detail view
|
||||
const [selectedDeployment, setSelectedDeployment] = useState<{ name: string; namespace: string } | null>(null);
|
||||
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await kubernetesApi.getClusters();
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (list.length > 0 && !selectedCluster) setSelectedCluster(list[0].code || String(list[0].id));
|
||||
} catch { setClusters([]); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadClusters(); }, [loadClusters]);
|
||||
|
||||
// Load namespaces when cluster changes
|
||||
const loadNamespaces = useCallback(async () => {
|
||||
if (!selectedCluster) return;
|
||||
try {
|
||||
const res: any = await kubernetesApi.getNamespaces(selectedCluster);
|
||||
const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []);
|
||||
setNamespaces(nsList);
|
||||
} catch { setNamespaces([]); }
|
||||
}, [selectedCluster]);
|
||||
|
||||
useEffect(() => { loadNamespaces(); }, [loadNamespaces]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!selectedCluster) return;
|
||||
setLoading(true);
|
||||
const clusterCode = selectedCluster;
|
||||
try {
|
||||
if (selectedNamespace !== 'all') {
|
||||
const [depRes, podRes] = await Promise.all([
|
||||
kubernetesApi.getNamespaceDeployments(clusterCode, selectedNamespace).catch(() => []),
|
||||
kubernetesApi.getNamespacePods(clusterCode, selectedNamespace).catch(() => []),
|
||||
]);
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
setDeployments(toArray(depRes));
|
||||
setPods(toArray(podRes));
|
||||
} else {
|
||||
const [depRes, podRes] = await Promise.all([
|
||||
kubernetesApi.getDeployments(clusterCode).catch(() => []),
|
||||
kubernetesApi.getPods(clusterCode).catch(() => []),
|
||||
]);
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
setDeployments(toArray(depRes));
|
||||
setPods(toArray(podRes));
|
||||
}
|
||||
} catch { setDeployments([]); setPods([]); } finally { setLoading(false); }
|
||||
}, [selectedCluster, selectedNamespace]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const filteredDeps = deployments.filter((d) =>
|
||||
(d.name || '').toLowerCase().includes(search.toLowerCase()) &&
|
||||
(selectedNamespace === 'all' || d.namespace === selectedNamespace),
|
||||
);
|
||||
const filteredPods = pods.filter((p) =>
|
||||
(p.name || '').toLowerCase().includes(search.toLowerCase()) &&
|
||||
(selectedNamespace === 'all' || p.namespace === selectedNamespace),
|
||||
);
|
||||
|
||||
const getPodStatusBadge = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
Running: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
Pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
Failed: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
Succeeded: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
};
|
||||
return <Badge className={map[status] || 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{status || 'Unknown'}</Badge>;
|
||||
};
|
||||
|
||||
const handleQuickScale = async (dep: any, delta: number) => {
|
||||
const key = `scale-${dep.name}`;
|
||||
setActionLoading((prev) => ({ ...prev, [key]: 'loading' }));
|
||||
try {
|
||||
const newReplicas = (dep.replicas || 0) + delta;
|
||||
if (newReplicas < 0) return;
|
||||
await kubernetesApi.scaleDeployment(selectedCluster, dep.namespace, dep.name, newReplicas);
|
||||
toast({ title: '扩缩容成功', description: `${dep.name} → ${newReplicas} 副本` });
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast({ title: '操作失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
} finally {
|
||||
setActionLoading((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
}
|
||||
};
|
||||
|
||||
// Show detail view if a deployment is selected
|
||||
if (selectedDeployment) {
|
||||
return (
|
||||
<DeploymentDetail
|
||||
clusterCode={selectedCluster}
|
||||
namespace={selectedDeployment.namespace}
|
||||
deploymentName={selectedDeployment.name}
|
||||
onBack={() => { setSelectedDeployment(null); loadData(); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">Deployment 与 Pod 运行状态</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedCluster} onValueChange={(v) => { setSelectedCluster(v); setSelectedNamespace('all'); }}>
|
||||
<SelectTrigger className="w-48 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="选择集群" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{clusters.map((c) => (
|
||||
<SelectItem key={c.id} value={c.code || String(c.id)}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedNamespace} onValueChange={setSelectedNamespace}>
|
||||
<SelectTrigger className="w-40 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="命名空间" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700 max-h-[300px]">
|
||||
<SelectItem value="all">全部命名空间</SelectItem>
|
||||
{namespaces.map((ns) => (
|
||||
<SelectItem key={ns} value={ns}>{ns}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadData} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Deployment', value: deployments.length, icon: <Boxes className="h-5 w-5" />, color: 'text-rose-400' },
|
||||
{ label: 'Pod 总数', value: pods.length, icon: <Container className="h-5 w-5" />, color: 'text-blue-400' },
|
||||
{ label: 'Running', value: pods.filter((p) => p.status === 'Running').length, icon: <Container className="h-5 w-5" />, color: 'text-emerald-400' },
|
||||
{ label: '异常', value: pods.filter((p) => ['Failed', 'Pending'].includes(p.status)).length, icon: <Container className="h-5 w-5" />, color: 'text-red-400' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} 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">{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
<div className="opacity-60">{s.icon}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input placeholder="搜索..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
<TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700">Deployment ({filteredDeps.length})</TabsTrigger>
|
||||
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">Pod ({filteredPods.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="deployments">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">名称</TableHead>
|
||||
<TableHead className="text-slate-400">命名空间</TableHead>
|
||||
<TableHead className="text-slate-400">副本</TableHead>
|
||||
<TableHead className="text-slate-400">镜像</TableHead>
|
||||
<TableHead className="text-slate-400 w-[140px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
) : filteredDeps.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-12"><Boxes className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 Deployment</TableCell></TableRow>
|
||||
) : (
|
||||
filteredDeps.map((d) => (
|
||||
<TableRow key={d.id || d.name} className="border-slate-800 hover:bg-slate-800/30 cursor-pointer"
|
||||
onClick={() => setSelectedDeployment({ name: d.name, namespace: d.namespace })}>
|
||||
<TableCell className="font-medium text-slate-200">{d.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{d.namespace}</Badge></TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const ready = d.ready_replicas || 0;
|
||||
const total = d.replicas || 0;
|
||||
const pct = total > 0 ? (ready / total) * 100 : 0;
|
||||
const done = ready === total;
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[100px]">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${done ? 'bg-emerald-500' : 'bg-yellow-500'}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs ${done ? 'text-emerald-400' : 'text-yellow-400'}`}>{ready}/{total}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-slate-200"
|
||||
title="减少副本" disabled={(d.replicas || 0) <= 0}
|
||||
onClick={() => handleQuickScale(d, -1)}>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-slate-200"
|
||||
title="增加副本" onClick={() => handleQuickScale(d, 1)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
|
||||
title="查看详情" onClick={() => setSelectedDeployment({ name: d.name, namespace: d.namespace })}>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pods">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">名称</TableHead>
|
||||
<TableHead className="text-slate-400">命名空间</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">节点</TableHead>
|
||||
<TableHead className="text-slate-400">重启</TableHead>
|
||||
<TableHead className="text-slate-400">镜像</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
) : filteredPods.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-12"><Container className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 Pod</TableCell></TableRow>
|
||||
) : (
|
||||
filteredPods.map((p) => (
|
||||
<TableRow key={p.id} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<TableCell className="font-medium text-slate-200">{p.name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
|
||||
<TableCell>{getPodStatusBadge(p.status)}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{p.node_name || '-'}</TableCell>
|
||||
<TableCell className={p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500'}>{p.restart_count || 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Search, RefreshCw, Loader2, Cloud, Save, Plus, Trash2, Link2, Pencil, RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { TopologyNode, TopologyEdge } from '@/types';
|
||||
import {
|
||||
buildTopology, getNodeIcon, getNodeColor, NODE_TYPE_LABELS, CUSTOM_NODE_TYPES,
|
||||
isCustomNode, type TopologyLayoutData,
|
||||
} from './topology-helpers';
|
||||
|
||||
export function TopologyView() {
|
||||
const _confirm = useConfirm();
|
||||
const [clusters, setClusters] = useState<any[]>([]);
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
|
||||
const [topology, setTopology] = useState<{ nodes: TopologyNode[]; edges: TopologyEdge[] }>({ nodes: [], edges: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewType, setViewType] = useState<string>('all');
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
// 编辑模式状态 // Logan.Li
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [connectMode, setConnectMode] = useState(false);
|
||||
const [connectSource, setConnectSource] = useState<string | null>(null);
|
||||
const [savedLayout, setSavedLayout] = useState<TopologyLayoutData | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [customIdCounter, setCustomIdCounter] = useState(1);
|
||||
// 编辑节点 Dialog
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [editingNode, setEditingNode] = useState<TopologyNode | null>(null);
|
||||
const [editForm, setEditForm] = useState<{ name: string; type: TopologyNode['type']; note: string }>({ name: '', type: 'database', note: '' });
|
||||
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await kubernetesApi.getClusters();
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (list.length > 0 && !selectedClusterId) setSelectedClusterId(String(list[0].id));
|
||||
} catch { setClusters([]); }
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { loadClusters(); }, [loadClusters]);
|
||||
|
||||
// 加载已保存的布局 // Logan.Li
|
||||
const loadSavedLayout = useCallback(async (clusterId: string) => {
|
||||
try {
|
||||
const res: any = await kubernetesApi.getTopology(clusterId);
|
||||
const layout = res as any;
|
||||
if (layout?.layout_data) {
|
||||
const data = JSON.parse(layout.layout_data) as TopologyLayoutData;
|
||||
setSavedLayout(data);
|
||||
if (data.customNodes?.length > 0) {
|
||||
const maxId = Math.max(...data.customNodes.map((n) => {
|
||||
const m = n.id.match(/custom-(\d+)/); return m ? parseInt(m[1]) : 0;
|
||||
}));
|
||||
setCustomIdCounter(maxId + 1);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
} catch { /* 无保存布局 */ }
|
||||
setSavedLayout(null);
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const loadTopology = useCallback(async () => {
|
||||
if (!selectedClusterId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
const [nodesRes, svcRes, depRes, ingRes] = await Promise.all([
|
||||
kubernetesApi.getNodes(selectedClusterId).catch(() => []),
|
||||
kubernetesApi.getServices(selectedClusterId).catch(() => []),
|
||||
kubernetesApi.getDeployments(selectedClusterId).catch(() => []),
|
||||
kubernetesApi.getIngresses(selectedClusterId).catch(() => []),
|
||||
]);
|
||||
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {};
|
||||
const built = buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes));
|
||||
|
||||
// 合并已保存布局
|
||||
const layout = await loadSavedLayout(selectedClusterId);
|
||||
if (layout) {
|
||||
// 应用保存的节点位置
|
||||
if (layout.positions) {
|
||||
built.nodes = built.nodes.map((n) => {
|
||||
const saved = layout.positions[n.id];
|
||||
return saved ? { ...n, x: saved.x, y: saved.y } : n;
|
||||
});
|
||||
}
|
||||
// 追加自定义节点
|
||||
if (layout.customNodes) built.nodes = [...built.nodes, ...layout.customNodes];
|
||||
// 追加自定义连线
|
||||
if (layout.customEdges) built.edges = [...built.edges, ...layout.customEdges];
|
||||
}
|
||||
setTopology(built);
|
||||
} catch { setTopology({ nodes: [], edges: [] }); }
|
||||
finally { setLoading(false); }
|
||||
}, [selectedClusterId, clusters, loadSavedLayout]);
|
||||
|
||||
useEffect(() => { loadTopology(); }, [loadTopology]);
|
||||
|
||||
// --- 过滤 ---
|
||||
const filteredNodes = topology.nodes.filter(
|
||||
(n) => n.name.toLowerCase().includes(searchQuery.toLowerCase()) && (viewType === 'all' || n.type === viewType),
|
||||
);
|
||||
const visibleIds = new Set(filteredNodes.map((n) => n.id));
|
||||
|
||||
// --- 拖拽 ---
|
||||
const getSvgPoint = (e: React.MouseEvent): { x: number; y: number } => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return { x: 0, y: 0 };
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX; pt.y = e.clientY;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return { x: 0, y: 0 };
|
||||
return { x: pt.matrixTransform(ctm.inverse()).x, y: pt.matrixTransform(ctm.inverse()).y };
|
||||
};
|
||||
|
||||
const handleNodeMouseDown = (e: React.MouseEvent, node: TopologyNode) => {
|
||||
if (connectMode) return; // 连线模式下不拖拽
|
||||
e.stopPropagation();
|
||||
const pt = getSvgPoint(e);
|
||||
setDragOffset({ x: pt.x - node.x, y: pt.y - node.y });
|
||||
setDraggingId(node.id);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!draggingId) return;
|
||||
const pt = getSvgPoint(e);
|
||||
setTopology((prev) => ({
|
||||
...prev,
|
||||
nodes: prev.nodes.map((n) => n.id === draggingId ? { ...n, x: pt.x - dragOffset.x, y: pt.y - dragOffset.y } : n),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => setDraggingId(null);
|
||||
|
||||
// --- 节点点击(区分普通/编辑/连线模式)---
|
||||
const handleNodeClick = (node: TopologyNode) => {
|
||||
if (draggingId) return;
|
||||
if (connectMode) {
|
||||
if (!connectSource) {
|
||||
setConnectSource(node.id);
|
||||
toast.info(`已选择源节点: ${node.name},请点击目标节点`);
|
||||
} else if (connectSource === node.id) {
|
||||
toast.info('不能连接自身');
|
||||
} else {
|
||||
// 创建自定义连线
|
||||
const newEdge: TopologyEdge = {
|
||||
id: `custom-edge-${Date.now()}`,
|
||||
source: connectSource, target: node.id, label: 'connect',
|
||||
};
|
||||
setTopology((prev) => ({ ...prev, edges: [...prev.edges, newEdge] }));
|
||||
toast.success('连线已添加');
|
||||
setConnectSource(null);
|
||||
setConnectMode(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSelectedNode(node);
|
||||
};
|
||||
|
||||
// --- 编辑模式操作 ---
|
||||
const handleAddNode = () => {
|
||||
setEditingNode(null);
|
||||
setEditForm({ name: '', type: 'database', note: '' });
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditNode = (node: TopologyNode) => {
|
||||
setEditingNode(node);
|
||||
setEditForm({ name: node.name, type: node.type, note: (node.data as any)?.note || '' });
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveNode = () => {
|
||||
if (!editForm.name.trim()) { toast.error('请输入节点名称'); return; }
|
||||
if (editingNode) {
|
||||
// 编辑现有节点
|
||||
setTopology((prev) => ({
|
||||
...prev,
|
||||
nodes: prev.nodes.map((n) => n.id === editingNode.id
|
||||
? { ...n, name: editForm.name, type: editForm.type, data: { ...n.data, note: editForm.note } }
|
||||
: n),
|
||||
}));
|
||||
toast.success('节点已更新');
|
||||
} else {
|
||||
// 新增自定义节点
|
||||
const id = `custom-${customIdCounter}`;
|
||||
const newNode: TopologyNode = {
|
||||
id, name: editForm.name, type: editForm.type,
|
||||
x: canvasW / 2 + (Math.random() - 0.5) * 100,
|
||||
y: canvasH / 2 + (Math.random() - 0.5) * 100,
|
||||
data: { kind: 'custom', note: editForm.note },
|
||||
};
|
||||
setTopology((prev) => ({ ...prev, nodes: [...prev.nodes, newNode] }));
|
||||
setCustomIdCounter((c) => c + 1);
|
||||
toast.success('节点已添加');
|
||||
}
|
||||
setEditDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDeleteNode = async (node: TopologyNode) => {
|
||||
const ok = await _confirm({ title: '删除节点', description: `确定删除「${node.name}」?相关连线也会一并删除`, variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
setTopology((prev) => ({
|
||||
nodes: prev.nodes.filter((n) => n.id !== node.id),
|
||||
edges: prev.edges.filter((e) => e.source !== node.id && e.target !== node.id),
|
||||
}));
|
||||
setSelectedNode(null);
|
||||
toast.success('节点已删除');
|
||||
};
|
||||
|
||||
const handleDeleteEdge = async (edge: TopologyEdge) => {
|
||||
if (!edge.id.startsWith('custom-edge')) { toast.info('K8s 原生关系不可删除'); return; }
|
||||
const ok = await _confirm({ title: '删除连线', description: '确定删除此自定义连线?', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
setTopology((prev) => ({ ...prev, edges: prev.edges.filter((e) => e.id !== edge.id) }));
|
||||
toast.success('连线已删除');
|
||||
};
|
||||
|
||||
// --- 保存/重置布局 ---
|
||||
const handleSaveLayout = async () => {
|
||||
if (!selectedClusterId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const layoutData: TopologyLayoutData = {
|
||||
positions: Object.fromEntries(topology.nodes.map((n) => [n.id, { x: n.x, y: n.y }])),
|
||||
customNodes: topology.nodes.filter((n) => isCustomNode(n.id)),
|
||||
customEdges: topology.edges.filter((e) => e.id.startsWith('custom-edge')),
|
||||
};
|
||||
await kubernetesApi.saveTopology(selectedClusterId, JSON.stringify(layoutData));
|
||||
toast.success('布局已保存');
|
||||
} catch { toast.error('保存失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleResetLayout = async () => {
|
||||
const ok = await _confirm({ title: '重置布局', description: '将清除已保存的布局并重新自动排列', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await kubernetesApi.saveTopology(selectedClusterId, ''); } catch { /* */ }
|
||||
setSavedLayout(null);
|
||||
setEditMode(false);
|
||||
setConnectMode(false);
|
||||
setConnectSource(null);
|
||||
await loadTopology();
|
||||
toast.success('布局已重置');
|
||||
};
|
||||
|
||||
// --- 画布尺寸 ---
|
||||
const maxX = topology.nodes.length > 0 ? Math.max(...topology.nodes.map((n) => n.x)) + 80 : 900;
|
||||
const maxY = topology.nodes.length > 0 ? Math.max(...topology.nodes.map((n) => n.y)) + 80 : 700;
|
||||
const canvasW = Math.max(maxX, 900);
|
||||
const canvasH = Math.max(maxY, 700);
|
||||
|
||||
const countByType = (type: string) => topology.nodes.filter((n) => n.type === type).length;
|
||||
|
||||
// --- 渲染 SVG 拓扑 ---
|
||||
const renderTopology = () => {
|
||||
if (topology.nodes.length === 0) return null;
|
||||
const ys = [60, 180, 320, 480, 640];
|
||||
const labels = ['集群', 'Ingress', 'Service', 'Deployment', 'Node'];
|
||||
return (
|
||||
<svg ref={svgRef} width="100%" height="100%" viewBox={`0 0 ${canvasW} ${canvasH}`} className="bg-slate-950 rounded-lg" preserveAspectRatio="xMidYMin meet"
|
||||
onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
|
||||
<defs>
|
||||
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" strokeWidth="0.5" />
|
||||
</pattern>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill={connectMode ? '#f59e0b' : '#475569'} />
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
{labels.map((label, i) => (
|
||||
<text key={label} x={14} y={ys[i] + 4} fill="#475569" fontSize="11" className="font-medium select-none">{label}</text>
|
||||
))}
|
||||
{topology.edges.map((edge) => {
|
||||
const source = topology.nodes.find((n) => n.id === edge.source);
|
||||
const target = topology.nodes.find((n) => n.id === edge.target);
|
||||
if (!source || !target || !visibleIds.has(source.id) || !visibleIds.has(target.id)) return null;
|
||||
const isCustom = edge.id.startsWith('custom-edge');
|
||||
return (
|
||||
<g key={edge.id} className={editMode && isCustom ? 'cursor-pointer' : ''} onClick={() => { if (editMode && isCustom) handleDeleteEdge(edge); }}>
|
||||
<line x1={source.x} y1={source.y} x2={target.x} y2={target.y}
|
||||
stroke={isCustom ? '#f59e0b' : '#475569'} strokeWidth={isCustom ? 2 : 1.5}
|
||||
strokeDasharray={isCustom ? '6 3' : undefined}
|
||||
markerEnd="url(#arrowhead)" opacity={isCustom ? 0.8 : 0.6} />
|
||||
{editMode && isCustom && (
|
||||
<text x={(source.x + target.x) / 2} y={(source.y + target.y) / 2}
|
||||
fill="#f59e0b" fontSize="10" textAnchor="middle" className="select-none pointer-events-none">✕ 删除</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{filteredNodes.map((node) => {
|
||||
const isCustom = isCustomNode(node.id);
|
||||
const isConnectSource = connectSource === node.id;
|
||||
return (
|
||||
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}
|
||||
className={connectMode ? 'cursor-crosshair' : draggingId === node.id ? 'cursor-grabbing' : 'cursor-grab'}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node)}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
onDoubleClick={() => { if (editMode) handleEditNode(node); }}>
|
||||
<circle r="28" fill="#0f172a"
|
||||
stroke={isConnectSource ? '#f59e0b' : selectedNode?.id === node.id ? '#3b82f6' : isCustom ? '#f59e0b' : '#334155'}
|
||||
strokeWidth={isConnectSource || selectedNode?.id === node.id ? 3 : 2}
|
||||
strokeDasharray={isCustom ? '4 2' : undefined}
|
||||
className="transition-all hover:stroke-blue-500" />
|
||||
<foreignObject x="-12" y="-12" width="24" height="24">
|
||||
<div className={`flex items-center justify-center w-full h-full ${getNodeColor(node.type).split(' ')[1]}`}>{getNodeIcon(node.type)}</div>
|
||||
</foreignObject>
|
||||
<text y="45" fill={isCustom ? '#f59e0b' : '#94a3b8'} fontSize="12" textAnchor="middle" className="font-medium select-none">
|
||||
{node.name.length > 20 ? node.name.slice(0, 18) + '…' : node.name}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNodeDetailFields = (node: TopologyNode) => {
|
||||
const data = node.data || {};
|
||||
const entries = Object.entries(data).filter(([k]) => k !== 'kind');
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{entries.map(([key, val]) => (
|
||||
<div key={key} className="p-2 rounded-lg bg-slate-800/50">
|
||||
<p className="text-xs text-slate-500 mb-0.5">{key}</p>
|
||||
<p className="text-sm text-slate-200 truncate" title={String(val)}>{val === null || val === undefined ? '—' : String(val)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 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">
|
||||
K8s 集群资源拓扑可视化 · 拖拽节点调整布局
|
||||
{editMode && <span className="text-amber-400 ml-2">· 编辑模式</span>}
|
||||
{connectMode && <span className="text-amber-400 ml-1">· 连线中{connectSource ? '(选择目标节点)' : '(选择源节点)'}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedClusterId} onValueChange={(v) => { setSelectedClusterId(v); setSelectedNode(null); setEditMode(false); }}>
|
||||
<SelectTrigger className="w-44 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder="选择集群" /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{clusters.map((c) => <SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant={editMode ? 'default' : 'outline'} size="sm" className="h-9"
|
||||
onClick={() => { setEditMode(!editMode); setConnectMode(false); setConnectSource(null); }}>
|
||||
<Pencil className="h-4 w-4 mr-1" />{editMode ? '退出编辑' : '编辑'}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadTopology} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑模式工具栏 */}
|
||||
{editMode && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
||||
<Button size="sm" variant="outline" className="border-slate-600 text-slate-200" onClick={handleAddNode}>
|
||||
<Plus className="h-4 w-4 mr-1" />添加节点
|
||||
</Button>
|
||||
<Button size="sm" variant={connectMode ? 'default' : 'outline'} className={connectMode ? 'bg-amber-600 hover:bg-amber-700' : 'border-slate-600 text-slate-200'}
|
||||
onClick={() => { setConnectMode(!connectMode); setConnectSource(null); }}>
|
||||
<Link2 className="h-4 w-4 mr-1" />{connectMode ? '取消连线' : '添加连线'}
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-slate-600" />
|
||||
<Button size="sm" variant="outline" className="border-emerald-600/30 text-emerald-400 hover:bg-emerald-600/10" onClick={handleSaveLayout} disabled={saving}>
|
||||
<Save className="h-4 w-4 mr-1" />{saving ? '保存中...' : '保存布局'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleResetLayout}>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />重置布局
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500 ml-auto">双击节点编辑 · 点击虚线删除 · 拖拽调整位置</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{[
|
||||
{ label: '总节点数', value: topology.nodes.length, icon: <Cloud className="h-5 w-5" />, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: '连接数', value: topology.edges.length, icon: <Link2 className="h-5 w-5" />, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ label: 'Service', value: countByType('service'), icon: getNodeIcon('service'), color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
|
||||
{ label: 'Deployment', value: countByType('app'), icon: getNodeIcon('app'), color: 'text-rose-400', bg: 'bg-rose-500/10' },
|
||||
{ label: '自定义', value: topology.nodes.filter((n) => isCustomNode(n.id)).length, icon: getNodeIcon('custom'), color: 'text-amber-400', bg: 'bg-amber-500/10' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} 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">{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${s.bg} ${s.color}`}>{s.icon}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-48">
|
||||
<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" />
|
||||
</div>
|
||||
<Select value={viewType} onValueChange={setViewType}>
|
||||
<SelectTrigger className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder="节点类型" /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
{Object.entries(NODE_TYPE_LABELS).map(([v, l]) => <SelectItem key={v} value={v}>{l}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Canvas + Node List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-3">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className={`h-[600px] overflow-auto ${connectMode ? 'ring-2 ring-amber-500/30 rounded-lg' : ''}`}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="h-8 w-8 animate-spin text-slate-600" /></div>
|
||||
) : topology.nodes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-600">
|
||||
<Cloud className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">{selectedClusterId ? '该集群暂无拓扑数据' : '请选择集群查看拓扑'}</p>
|
||||
</div>
|
||||
) : renderTopology()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base text-slate-100">节点列表</CardTitle>
|
||||
<CardDescription className="text-slate-500">点击节点查看详情</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 max-h-[520px] overflow-auto">
|
||||
{filteredNodes.length === 0 ? (
|
||||
<p className="text-sm text-slate-600 text-center py-8">暂无节点</p>
|
||||
) : (
|
||||
filteredNodes.map((node) => (
|
||||
<div key={node.id} className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${selectedNode?.id === node.id ? 'bg-blue-500/10 border border-blue-500/30' : 'hover:bg-slate-800/50'}`} onClick={() => setSelectedNode(node)}>
|
||||
<div className={`p-2 rounded-lg ${getNodeColor(node.type)}`}>{getNodeIcon(node.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-200 truncate">{node.name}</p>
|
||||
<p className="text-xs text-slate-500">{NODE_TYPE_LABELS[node.type] || node.type}{isCustomNode(node.id) ? ' · 自定义' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node Detail Dialog */}
|
||||
<Dialog open={!!selectedNode} onOpenChange={() => setSelectedNode(null)}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800 max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<div className={`p-2 rounded-lg ${getNodeColor(selectedNode?.type || 'server')}`}>{selectedNode && getNodeIcon(selectedNode.type)}</div>
|
||||
{selectedNode?.name}
|
||||
{selectedNode && isCustomNode(selectedNode.id) && <Badge variant="outline" className="border-amber-500/30 text-amber-400 text-xs">自定义</Badge>}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">类型: {NODE_TYPE_LABELS[selectedNode?.type || ''] || selectedNode?.type}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedNode && (
|
||||
<div className="space-y-4">
|
||||
{renderNodeDetailFields(selectedNode)}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">关联连接</h4>
|
||||
<div className="space-y-2">
|
||||
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((edge) => {
|
||||
const isSource = edge.source === selectedNode.id;
|
||||
const related = topology.nodes.find((n) => n.id === (isSource ? edge.target : edge.source));
|
||||
if (!related) return null;
|
||||
return (
|
||||
<div key={edge.id} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
|
||||
<span className="text-sm text-slate-400">{isSource ? '→' : '←'}</span>
|
||||
<div className={`p-1 rounded ${getNodeColor(related.type)}`}>{getNodeIcon(related.type, 'h-4 w-4')}</div>
|
||||
<span className="text-sm text-slate-300">{related.name}</span>
|
||||
<Badge variant="outline" className="ml-auto border-slate-700 text-slate-400 text-xs">{edge.label}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length === 0 && <p className="text-xs text-slate-600">无关联连接</p>}
|
||||
</div>
|
||||
</div>
|
||||
{/* 编辑模式操作按钮 */}
|
||||
{editMode && selectedNode && (
|
||||
<div className="flex gap-2 pt-2 border-t border-slate-700">
|
||||
<Button size="sm" variant="outline" className="border-slate-600 text-slate-200" onClick={() => handleEditNode(selectedNode)}>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1" />编辑
|
||||
</Button>
|
||||
{isCustomNode(selectedNode.id) && (
|
||||
<Button size="sm" variant="outline" className="border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={() => handleDeleteNode(selectedNode)}>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit/Add Node Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800 max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">{editingNode ? '编辑节点' : '添加自定义节点'}</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">{editingNode ? '修改节点属性' : '添加数据库、外部服务等非 K8s 资源节点'}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">名称</Label>
|
||||
<Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
placeholder="如 MySQL 主库、Redis 集群" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">类型</Label>
|
||||
<Select value={editForm.type} onValueChange={(v) => setEditForm({ ...editForm, type: v as TopologyNode['type'] })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{CUSTOM_NODE_TYPES.map((t) => <SelectItem key={t.value} value={t.value}>{t.label} — {t.desc}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">备注</Label>
|
||||
<Textarea value={editForm.note} onChange={(e) => setEditForm({ ...editForm, note: e.target.value })}
|
||||
placeholder="连接信息、用途说明等" rows={2} className="bg-slate-800/50 border-slate-700 text-slate-200 resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setEditDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSaveNode}>{editingNode ? '保存' : '添加'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
Network, Server, Globe, Cloud, Boxes, Database, Zap, Box,
|
||||
} from 'lucide-react';
|
||||
import type { TopologyNode, TopologyEdge } from '@/types';
|
||||
|
||||
// K8s 资源类型 → 拓扑节点类型标签 // Logan.Li
|
||||
export const NODE_TYPE_LABELS: Record<string, string> = {
|
||||
cluster: '集群',
|
||||
loadbalancer: 'Ingress',
|
||||
service: 'Service',
|
||||
app: 'Deployment',
|
||||
server: 'Node',
|
||||
database: '数据库',
|
||||
external: '外部服务',
|
||||
custom: '自定义',
|
||||
};
|
||||
|
||||
// 自定义节点类型选项(编辑模式用)
|
||||
export const CUSTOM_NODE_TYPES = [
|
||||
{ value: 'database', label: '数据库', desc: 'MySQL / Redis / PostgreSQL 等' },
|
||||
{ value: 'external', label: '外部服务', desc: '外部 API / CDN / DNS 等' },
|
||||
{ value: 'custom', label: '自定义', desc: '其他自定义节点' },
|
||||
];
|
||||
|
||||
// 判断是否为自定义节点(非 K8s 原生资源)
|
||||
export const isCustomNode = (id: string) => id.startsWith('custom-');
|
||||
|
||||
// 获取节点图标
|
||||
export function getNodeIcon(type: string, className = 'h-5 w-5') {
|
||||
switch (type) {
|
||||
case 'server': return <Server className={className} />;
|
||||
case 'loadbalancer': return <Globe className={className} />;
|
||||
case 'cluster': return <Cloud className={className} />;
|
||||
case 'app': return <Boxes className={className} />;
|
||||
case 'service': return <Network className={className} />;
|
||||
case 'database': return <Database className={className} />;
|
||||
case 'external': return <Zap className={className} />;
|
||||
case 'custom': return <Box className={className} />;
|
||||
default: return <Server className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取节点颜色样式
|
||||
export function getNodeColor(type: string) {
|
||||
switch (type) {
|
||||
case 'server': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
case 'loadbalancer': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
|
||||
case 'cluster': return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30';
|
||||
case 'app': return 'bg-rose-500/20 text-rose-400 border-rose-500/30';
|
||||
case 'service': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
|
||||
case 'database': return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
|
||||
case 'external': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||
case 'custom': return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
default: return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* buildTopology — 从真实 K8s 资源构建分层拓扑图
|
||||
* 层级: Cluster → Ingress → Service → Deployment → Node
|
||||
*/
|
||||
export function buildTopology(
|
||||
cluster: any,
|
||||
ingresses: any[],
|
||||
services: any[],
|
||||
deployments: any[],
|
||||
nodes: any[],
|
||||
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
|
||||
const tNodes: TopologyNode[] = [];
|
||||
const tEdges: TopologyEdge[] = [];
|
||||
const MAX_PER_LAYER = 12;
|
||||
|
||||
const maxLayerCount = Math.max(1, ingresses.length, services.length, deployments.length, nodes.length);
|
||||
const canvasW = Math.max(900, Math.min(maxLayerCount * 130, 2400));
|
||||
const layerY = [60, 180, 320, 480, 640];
|
||||
|
||||
const distributeX = (count: number): number[] => {
|
||||
const cap = Math.min(count, MAX_PER_LAYER);
|
||||
if (cap === 0) return [];
|
||||
if (cap === 1) return [canvasW / 2];
|
||||
const step = canvasW / (cap + 1);
|
||||
return Array.from({ length: cap }, (_, i) => step * (i + 1));
|
||||
};
|
||||
|
||||
// Layer 0: Cluster
|
||||
const clusterId = `cluster-${cluster.id}`;
|
||||
tNodes.push({
|
||||
id: clusterId, name: cluster.name || '集群', type: 'cluster',
|
||||
x: canvasW / 2, y: layerY[0],
|
||||
data: { kind: 'cluster', version: cluster.version, status: cluster.status, api_server: cluster.api_server, node_count: cluster.node_count, pod_count: cluster.pod_count },
|
||||
});
|
||||
|
||||
// Layer 1: Ingress
|
||||
const ingCap = ingresses.slice(0, MAX_PER_LAYER);
|
||||
const ingXs = distributeX(ingCap.length);
|
||||
ingCap.forEach((ing, i) => {
|
||||
const id = `ingress-${ing.id}`;
|
||||
tNodes.push({ id, name: ing.name, type: 'loadbalancer', x: ingXs[i], y: layerY[1], data: { kind: 'ingress', namespace: ing.namespace, backend: ing.backend, tls: ing.tls, hosts: ing.hosts } });
|
||||
tEdges.push({ id: `e-${clusterId}-${id}`, source: clusterId, target: id, label: 'route' });
|
||||
});
|
||||
|
||||
// Layer 2: Service
|
||||
const svcCap = services.slice(0, MAX_PER_LAYER);
|
||||
const svcXs = distributeX(svcCap.length);
|
||||
svcCap.forEach((svc, i) => {
|
||||
const id = `service-${svc.id}`;
|
||||
tNodes.push({ id, name: svc.name, type: 'service', x: svcXs[i], y: layerY[2], data: { kind: 'service', namespace: svc.namespace, type: svc.type, cluster_ip: svc.cluster_ip, external_ip: svc.external_ip, selector: svc.selector } });
|
||||
});
|
||||
|
||||
// Ingress → Service edges
|
||||
if (ingCap.length > 0 && svcCap.length > 0) {
|
||||
ingCap.forEach((ing) => {
|
||||
const ingNodeId = `ingress-${ing.id}`;
|
||||
const backendSvc = (ing.backend || '').split(':')[0].trim();
|
||||
let matched = svcCap.filter((s) => s.name === backendSvc);
|
||||
if (matched.length === 0) matched = svcCap.filter((s) => s.namespace === ing.namespace);
|
||||
if (matched.length === 0) matched = [svcCap[0]];
|
||||
matched.forEach((svc) => tEdges.push({ id: `e-${ingNodeId}-service-${svc.id}`, source: ingNodeId, target: `service-${svc.id}`, label: '→' }));
|
||||
});
|
||||
} else if (svcCap.length > 0) {
|
||||
svcCap.forEach((svc) => tEdges.push({ id: `e-${clusterId}-service-${svc.id}`, source: clusterId, target: `service-${svc.id}`, label: 'expose' }));
|
||||
}
|
||||
|
||||
// Layer 3: Deployment
|
||||
const depCap = deployments.slice(0, MAX_PER_LAYER);
|
||||
const depXs = distributeX(depCap.length);
|
||||
depCap.forEach((dep, i) => {
|
||||
const id = `deploy-${dep.id}`;
|
||||
tNodes.push({ id, name: dep.name, type: 'app', x: depXs[i], y: layerY[3], data: { kind: 'deployment', namespace: dep.namespace, replicas: dep.replicas, ready_replicas: dep.ready_replicas, available_replicas: dep.available_replicas, image: dep.image } });
|
||||
});
|
||||
|
||||
// Service → Deployment edges
|
||||
if (svcCap.length > 0 && depCap.length > 0) {
|
||||
svcCap.forEach((svc) => {
|
||||
const svcNodeId = `service-${svc.id}`;
|
||||
let matched: any[] = [];
|
||||
try {
|
||||
const selector = typeof svc.selector === 'string' ? JSON.parse(svc.selector || '{}') : (svc.selector || {});
|
||||
const selectorEntries = Object.entries(selector);
|
||||
if (selectorEntries.length > 0) {
|
||||
matched = depCap.filter((d) => {
|
||||
let labels: Record<string, string> = {};
|
||||
try { labels = typeof d.labels === 'string' ? JSON.parse(d.labels || '{}') : (d.labels || {}); } catch { /* */ }
|
||||
return selectorEntries.every(([k, v]) => labels[k] === v);
|
||||
});
|
||||
}
|
||||
} catch { /* */ }
|
||||
if (matched.length === 0) matched = depCap.filter((d) => d.name === svc.name);
|
||||
if (matched.length === 0) matched = depCap.filter((d) => d.namespace === svc.namespace);
|
||||
if (matched.length === 0) matched = [depCap[0]];
|
||||
matched.slice(0, 4).forEach((dep) => tEdges.push({ id: `e-${svcNodeId}-deploy-${dep.id}`, source: svcNodeId, target: `deploy-${dep.id}`, label: 'select' }));
|
||||
});
|
||||
}
|
||||
|
||||
// Layer 4: Node
|
||||
const nodeCap = nodes.slice(0, MAX_PER_LAYER);
|
||||
const nodeXs = distributeX(nodeCap.length);
|
||||
nodeCap.forEach((n, i) => {
|
||||
const id = `k8snode-${n.id}`;
|
||||
tNodes.push({ id, name: n.name, type: 'server', x: nodeXs[i], y: layerY[4], data: { kind: 'node', ip: n.ip, role: n.role, status: n.status, os: n.os, cpu: n.cpu, memory: n.memory, cpu_usage: n.cpu_usage, memory_usage: n.memory_usage, pod_count: n.pod_count } });
|
||||
});
|
||||
|
||||
// Deployment → Node edges
|
||||
const workerNodes = nodeCap.filter((n) => (n.role || '').toLowerCase() === 'worker');
|
||||
const schedulableNodes = workerNodes.length > 0 ? workerNodes : nodeCap;
|
||||
if (depCap.length > 0 && schedulableNodes.length > 0) {
|
||||
depCap.forEach((dep) => {
|
||||
const depNodeId = `deploy-${dep.id}`;
|
||||
schedulableNodes.slice(0, 3).forEach((n) => tEdges.push({ id: `e-${depNodeId}-k8snode-${n.id}`, source: depNodeId, target: `k8snode-${n.id}`, label: 'schedule' }));
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes: tNodes, edges: tEdges };
|
||||
}
|
||||
|
||||
// 布局数据结构
|
||||
export interface TopologyLayoutData {
|
||||
positions: Record<string, { x: number; y: number }>;
|
||||
customNodes: TopologyNode[];
|
||||
customEdges: TopologyEdge[];
|
||||
}
|
||||
Reference in new issue
Block a user