From b770ec025b33796e42c438ef5ba83310a74c2ce2 Mon Sep 17 00:00:00 2001 From: attacker Date: Mon, 15 Jun 2026 02:46:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20K8s=E8=8A=82=E7=82=B9=E7=AE=A1=E7=90=86?= =?UTF-8?q?+=E5=B7=A5=E4=BD=9C=E8=B4=9F=E8=BD=BD=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=9E=B6=E6=9E=84=E6=8B=93=E6=89=91?= =?UTF-8?q?=E5=8F=AF=E6=8B=96=E6=8B=BD=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 K8sNodes.tsx: 集群选择→节点表格(角色/状态/CPU/Mem/Pods/Kubelet版本) - 新建 K8sWorkloads.tsx: Deployment+Pod双Tab,副本/镜像/重启次数/状态 - TopologyView: SVG节点支持鼠标拖拽,实时更新坐标,cursor grab/grabbing - 节点/工作负载路由从K8sClusters占位改为独立组件 - Sidebar: Kubernetes管理→Kubernetes --- frontend/src/App.tsx | 6 +- frontend/src/components/k8s/K8sNodes.tsx | 164 +++++++++++++++ frontend/src/components/k8s/K8sWorkloads.tsx | 203 +++++++++++++++++++ frontend/src/components/k8s/TopologyView.tsx | 50 ++++- frontend/src/components/layout/Sidebar.tsx | 2 +- 5 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/k8s/K8sNodes.tsx create mode 100644 frontend/src/components/k8s/K8sWorkloads.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fb96599..db3db97 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,8 @@ import { RegionManager } from '@/components/cloud/RegionManager'; import { PlatformManager } from '@/components/cloud/PlatformManager'; import { CloudBilling } from '@/components/cloud/CloudBilling'; import { K8sClusters } from '@/components/k8s/K8sClusters'; +import { K8sNodes } from '@/components/k8s/K8sNodes'; +import { K8sWorkloads } from '@/components/k8s/K8sWorkloads'; import { TopologyView } from '@/components/k8s/TopologyView'; import { AlertManager } from '@/components/monitor/AlertManager'; import { AppReleaseManager } from '@/components/deploy/AppRelease'; @@ -59,8 +61,8 @@ function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> {/* 应用发布 */} } /> diff --git a/frontend/src/components/k8s/K8sNodes.tsx b/frontend/src/components/k8s/K8sNodes.tsx new file mode 100644 index 0000000..62a03e7 --- /dev/null +++ b/frontend/src/components/k8s/K8sNodes.tsx @@ -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([]); + const [selectedCluster, setSelectedCluster] = useState(''); + const [nodes, setNodes] = useState([]); + 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' + ? Master + : Worker; + + const getStatusBadge = (status: string) => + status === 'Ready' + ? {status} + : {status || 'Unknown'}; + + return ( +
+
+
+

节点管理

+

K8s 集群工作节点状态与资源

+
+
+ + +
+
+ +
+ {[ + { 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) => ( + + +

{s.label}

+

{s.value}

+
+
+ ))} +
+ +
+
+ + setSearch(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" /> +
+ +
+ + + + + + + 节点名称 + IP + 角色 + 状态 + 配置 + 使用率 + Pods + Kubelet + + + + {loading ? ( + 加载中... + ) : filtered.length === 0 ? ( + {selectedCluster ? '暂无节点数据' : '请选择集群'} + ) : ( + filtered.map((n) => ( + + {n.name} + {n.ip || '-'} + {getRoleBadge(n.role)} + {getStatusBadge(n.status)} + + {n.cpu ? `${n.cpu}核` : '-'} / {n.memory ? `${Math.round(n.memory / 1024)}GB` : '-'} + + +
+ CPU {(n.cpu_usage || 0).toFixed(1)}% + Mem {(n.memory_usage || 0).toFixed(1)}% +
+
+ {n.pod_count || 0} / {n.max_pods || '-'} + {n.kubelet_version || '-'} +
+ )) + )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/k8s/K8sWorkloads.tsx b/frontend/src/components/k8s/K8sWorkloads.tsx new file mode 100644 index 0000000..c01c7c9 --- /dev/null +++ b/frontend/src/components/k8s/K8sWorkloads.tsx @@ -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([]); + const [selectedCluster, setSelectedCluster] = useState(''); + const [deployments, setDeployments] = useState([]); + const [pods, setPods] = useState([]); + 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 = { + 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 {status || 'Unknown'}; + }; + + return ( +
+
+
+

工作负载

+

Deployment 与 Pod 运行状态

+
+
+ + +
+
+ +
+ {[ + { label: 'Deployment', value: deployments.length, icon: , color: 'text-rose-400' }, + { label: 'Pod 总数', value: pods.length, icon: , color: 'text-blue-400' }, + { label: 'Running', value: pods.filter((p) => p.status === 'Running').length, icon: , color: 'text-emerald-400' }, + { label: '异常', value: pods.filter((p) => ['Failed', 'Pending'].includes(p.status)).length, icon: , color: 'text-red-400' }, + ].map((s) => ( + + +
+
+

{s.label}

+

{s.value}

+
+
{s.icon}
+
+
+
+ ))} +
+ +
+ + setSearch(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" /> +
+ + + + Deployment ({filteredDeps.length}) + Pod ({filteredPods.length}) + + + + + + + + + 名称 + 命名空间 + 副本 + 镜像 + 策略 + + + + {loading ? ( + 加载中... + ) : filteredDeps.length === 0 ? ( + 暂无 Deployment + ) : ( + filteredDeps.map((d) => ( + + {d.name} + {d.namespace} + + + {d.ready_replicas || 0} / {d.replicas || 0} + + + {d.image || '-'} + {d.strategy || 'RollingUpdate'} + + )) + )} + +
+
+
+
+ + + + + + + + 名称 + 命名空间 + 状态 + 节点 + 重启 + 镜像 + + + + {loading ? ( + 加载中... + ) : filteredPods.length === 0 ? ( + 暂无 Pod + ) : ( + filteredPods.map((p) => ( + + {p.name} + {p.namespace} + {getPodStatusBadge(p.status)} + {p.node_name || '-'} + 0 ? 'text-yellow-400' : 'text-slate-500'}>{p.restart_count || 0} + {p.image || '-'} + + )) + )} + +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/k8s/TopologyView.tsx b/frontend/src/components/k8s/TopologyView.tsx index ec7f209..4d3a339 100644 --- a/frontend/src/components/k8s/TopologyView.tsx +++ b/frontend/src/components/k8s/TopologyView.tsx @@ -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(null); const [searchQuery, setSearchQuery] = useState(''); const [viewType, setViewType] = useState('all'); + const [draggingId, setDraggingId] = useState(null); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const svgRef = useRef(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 ( - + @@ -281,7 +320,10 @@ export function TopologyView() { return ; })} {filteredNodes.map((node) => ( - setSelectedNode(node)}> + handleNodeMouseDown(e, node)} + onClick={() => { if (!draggingId) setSelectedNode(node); }}>
{getNodeIcon(node.type)}
@@ -319,7 +361,7 @@ export function TopologyView() {

架构拓扑

-

K8s 集群资源拓扑可视化

+

K8s 集群资源拓扑可视化 · 拖拽节点可调整布局