feat: K8s节点管理+工作负载独立组件,架构拓扑可拖拽编辑
- 新建 K8sNodes.tsx: 集群选择→节点表格(角色/状态/CPU/Mem/Pods/Kubelet版本) - 新建 K8sWorkloads.tsx: Deployment+Pod双Tab,副本/镜像/重启次数/状态 - TopologyView: SVG节点支持鼠标拖拽,实时更新坐标,cursor grab/grabbing - 节点/工作负载路由从K8sClusters占位改为独立组件 - Sidebar: Kubernetes管理→Kubernetes
This commit is contained in:
1 parent
674fe5b17f
commit
b770ec025b
5 files changed
+418
-7
No files matched your search
@@ -0,0 +1,164 @@
|
||||
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 { k8sApi } from '@/services/api';
|
||||
|
||||
export function K8sNodes() {
|
||||
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 k8sApi.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 k8sApi.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>
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
<span className="text-blue-400">CPU {(n.cpu_usage || 0).toFixed(1)}%</span>
|
||||
<span className="text-emerald-400">Mem {(n.memory_usage || 0).toFixed(1)}%</span>
|
||||
</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,203 @@
|
||||
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 } from 'lucide-react';
|
||||
import { k8sApi } from '@/services/api';
|
||||
|
||||
export function K8sWorkloads() {
|
||||
const [clusters, setClusters] = useState<any[]>([]);
|
||||
const [selectedCluster, setSelectedCluster] = useState('');
|
||||
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 loadClusters = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await k8sApi.getClusters();
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (list.length > 0 && !selectedCluster) setSelectedCluster(String(list[0].id));
|
||||
} catch { setClusters([]); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadClusters(); }, [loadClusters]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!selectedCluster) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [depRes, podRes] = await Promise.all([
|
||||
k8sApi.getDeployments(selectedCluster).catch(() => []),
|
||||
k8sApi.getPods(selectedCluster).catch(() => []),
|
||||
]);
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
setDeployments(toArray(depRes));
|
||||
setPods(toArray(podRes));
|
||||
} catch { setDeployments([]); setPods([]); } finally { setLoading(false); }
|
||||
}, [selectedCluster]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const filteredDeps = deployments.filter((d) =>
|
||||
(d.name || '').toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
const filteredPods = pods.filter((p) =>
|
||||
(p.name || '').toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
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={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={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">策略</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} className="border-slate-800 hover:bg-slate-800/30">
|
||||
<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>
|
||||
<span className={d.ready_replicas === d.replicas ? 'text-emerald-400' : 'text-yellow-400'}>
|
||||
{d.ready_replicas || 0} / {d.replicas || 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{d.image || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{d.strategy || 'RollingUpdate'}</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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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';
|
||||
@@ -178,6 +178,9 @@ export function TopologyView() {
|
||||
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);
|
||||
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
@@ -256,12 +259,48 @@ export function TopologyView() {
|
||||
const canvasW = Math.max(maxX, 900);
|
||||
const canvasH = Math.max(maxY, 700);
|
||||
|
||||
// --- 拖拽逻辑 ---
|
||||
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 };
|
||||
const transformed = pt.matrixTransform(ctm.inverse());
|
||||
return { x: transformed.x, y: transformed.y };
|
||||
};
|
||||
|
||||
const handleNodeMouseDown = (e: React.MouseEvent, node: TopologyNode) => {
|
||||
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 renderTopology = () => {
|
||||
if (topology.nodes.length === 0) return null;
|
||||
const ys = [60, 180, 320, 480, 640];
|
||||
const labels = ['集群', 'Ingress', 'Service', 'Deployment', 'Node'];
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${canvasW} ${canvasH}`} className="bg-slate-950 rounded-lg" preserveAspectRatio="xMidYMin meet">
|
||||
<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" />
|
||||
@@ -281,7 +320,10 @@ export function TopologyView() {
|
||||
return <line key={edge.id} x1={source.x} y1={source.y} x2={target.x} y2={target.y} stroke="#475569" strokeWidth="1.5" markerEnd="url(#arrowhead)" opacity={0.6} />;
|
||||
})}
|
||||
{filteredNodes.map((node) => (
|
||||
<g key={node.id} transform={`translate(${node.x}, ${node.y})`} className="cursor-pointer" onClick={() => setSelectedNode(node)}>
|
||||
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}
|
||||
className={draggingId === node.id ? 'cursor-grabbing' : 'cursor-grab'}
|
||||
onMouseDown={(e) => handleNodeMouseDown(e, node)}
|
||||
onClick={() => { if (!draggingId) setSelectedNode(node); }}>
|
||||
<circle r="28" fill="#0f172a" stroke={selectedNode?.id === node.id ? '#3b82f6' : '#334155'} strokeWidth={selectedNode?.id === node.id ? 3 : 2} 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>
|
||||
@@ -319,7 +361,7 @@ export function TopologyView() {
|
||||
<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>
|
||||
<p className="text-sm text-slate-500 mt-1">K8s 集群资源拓扑可视化 · 拖拽节点可调整布局</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedClusterId} onValueChange={(v) => { setSelectedClusterId(v); setSelectedNode(null); }}>
|
||||
|
||||
@@ -64,7 +64,7 @@ const menuItems: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'k8s',
|
||||
name: 'Kubernetes管理',
|
||||
name: 'Kubernetes',
|
||||
icon: Cloud,
|
||||
children: [
|
||||
{ id: 'clusters', name: '集群列表', icon: Cloud, path: '/clusters' },
|
||||
|
||||
Reference in new issue
Block a user