feat: K8s集群拓扑图接入真实后端数据 — 分层可视化Cluster→Ingress→Service→Deployment→Node + http.ts类型修复

This commit is contained in:
attacker committed 2026-06-14 20:12:42 +08:00
1 parent 4b094f4645
commit 9f65faff6f
17 files changed
+3021 -1205

No files matched your search

+346 -330
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react';
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';
@@ -20,161 +20,274 @@ import {
} from '@/components/ui/dialog';
import {
Network,
Plus,
Search,
Server,
Database,
Layers,
Share2,
Shield,
HardDrive,
Cloud,
RefreshCw,
Download,
ZoomIn,
ZoomOut,
Move,
Edit,
Trash2,
Save,
Loader2,
Globe,
Boxes,
} from 'lucide-react';
import { topologyNodes, topologyEdges } from '@/mocks/data';
import { k8sApi } from '@/services/api';
import type { TopologyNode, TopologyEdge } from '@/types';
// K8s resource type → topology node type label
const NODE_TYPE_LABELS: Record<string, string> = {
cluster: '集群',
loadbalancer: 'Ingress',
service: 'Service',
app: 'Deployment',
server: 'Node',
};
/**
* buildTopology — 从真实 K8s 资源构建分层拓扑图
*
* 层级 (自上而下): Cluster → Ingress → Service → Deployment → Node
*/
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 (selector match or namespace fallback)
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 (Pods scheduled on worker nodes)
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 function TopologyView() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
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 filteredNodes = topologyNodes.filter(
const loadClusters = useCallback(async () => {
try {
const res: any = await k8sApi.getClusters();
const list = res?.list || [];
setClusters(list);
if (list.length > 0 && !selectedClusterId) {
setSelectedClusterId(String(list[0].id));
}
} catch (e) {
console.error('加载集群列表失败:', e);
setClusters([]);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
loadClusters();
}, [loadClusters]);
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([
k8sApi.getNodes(selectedClusterId).catch(() => []),
k8sApi.getServices(selectedClusterId).catch(() => []),
k8sApi.getDeployments(selectedClusterId).catch(() => []),
k8sApi.getIngresses(selectedClusterId).catch(() => []),
]);
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {};
setTopology(buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes)));
} catch (e) {
console.error('加载拓扑数据失败:', e);
setTopology({ nodes: [], edges: [] });
} finally {
setLoading(false);
}
}, [selectedClusterId, clusters]);
useEffect(() => {
loadTopology();
}, [loadTopology]);
const filteredNodes = topology.nodes.filter(
(node) =>
node.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
(viewType === 'all' || node.type === viewType)
(viewType === 'all' || node.type === viewType),
);
const visibleIds = new Set(filteredNodes.map((n) => n.id));
const getNodeIcon = (type: TopologyNode['type']) => {
const icons = {
server: <Server className="h-5 w-5" />,
database: <Database className="h-5 w-5" />,
cache: <Layers className="h-5 w-5" />,
loadbalancer: <Share2 className="h-5 w-5" />,
cluster: <Cloud className="h-5 w-5" />,
app: <HardDrive className="h-5 w-5" />,
service: <Network className="h-5 w-5" />,
};
return icons[type] || <Server className="h-5 w-5" />;
const getNodeIcon = (type: string) => {
switch (type) {
case 'server': return <Server className="h-5 w-5" />;
case 'loadbalancer': return <Globe className="h-5 w-5" />;
case 'cluster': return <Cloud className="h-5 w-5" />;
case 'app': return <Boxes className="h-5 w-5" />;
case 'service': return <Network className="h-5 w-5" />;
default: return <Server className="h-5 w-5" />;
}
};
const getNodeColor = (type: TopologyNode['type']) => {
const colors = {
server: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
database: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
cache: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
loadbalancer: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
cluster: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
app: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
service: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
};
return colors[type] || 'bg-slate-500/20 text-slate-400 border-slate-500/30';
const 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';
default: return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
}
};
// 简化的SVG拓扑图渲染
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 renderTopology = () => {
const width = 800;
const height = 500;
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 ${width} ${height}`}
className="bg-slate-950 rounded-lg"
>
{/* Grid background */}
<svg width="100%" height="100%" viewBox={`0 0 ${canvasW} ${canvasH}`} className="bg-slate-950 rounded-lg" preserveAspectRatio="xMidYMin meet">
<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>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Edges */}
{topologyEdges.map((edge) => {
const source = topologyNodes.find((n) => n.id === edge.source);
const target = topologyNodes.find((n) => n.id === edge.target);
if (!source || !target) return null;
return (
<g key={edge.id}>
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#475569"
strokeWidth="2"
markerEnd="url(#arrowhead)"
/>
<text
x={(source.x + target.x) / 2}
y={(source.y + target.y) / 2 - 5}
fill="#64748b"
fontSize="10"
textAnchor="middle"
>
{edge.label}
</text>
</g>
);
})}
{/* Arrow marker */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="28"
refY="3.5"
orient="auto"
>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#475569" />
</marker>
</defs>
{/* Nodes */}
<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;
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)}
>
<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"
/>
<g key={node.id} transform={`translate(${node.x}, ${node.y})`} className="cursor-pointer" onClick={() => 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>
<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="#94a3b8"
fontSize="12"
textAnchor="middle"
className="font-medium"
>
{node.name}
<text y="45" fill="#94a3b8" fontSize="12" textAnchor="middle" className="font-medium select-none">
{node.name.length > 20 ? node.name.slice(0, 18) + '…' : node.name}
</text>
</g>
))}
@@ -182,153 +295,111 @@ export function TopologyView() {
);
};
const countByType = (type: string) => topology.nodes.filter((n) => n.type === type).length;
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">{val === null || val === undefined ? '—' : String(val)}</p>
</div>
))}
</div>
);
};
return (
<div className="space-y-6">
{/* Page Header */}
{/* 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">系统架构可视化与依赖关系管理</p>
<p className="text-sm text-slate-500 mt-1">K8s 集群资源拓扑可视化</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">
<Download className="h-4 w-4 mr-2" />
导出
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
添加节点
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 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">{topologyNodes.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-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">连接数</p>
<p className="text-2xl font-bold text-slate-100">{topologyEdges.length}</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
<Network 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">
{topologyNodes.filter((n) => n.type === 'database').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Database 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-amber-400">
{topologyNodes.filter((n) => n.type === 'cache').length}
</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Layers 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-cyan-400">
{topologyNodes.filter((n) => n.type === 'loadbalancer').length}
</p>
</div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
<Share2 className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters & Toolbar */}
<div className="flex items-center justify-between">
<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 placeholder:text-slate-500"
/>
</div>
<Select value={viewType} onValueChange={setViewType}>
<SelectTrigger className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="节点类型" />
<Select value={selectedClusterId} onValueChange={(v) => { setSelectedClusterId(v); setSelectedNode(null); }}>
<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">
<SelectItem value="all">全部类型</SelectItem>
<SelectItem value="server">服务器</SelectItem>
<SelectItem value="database">数据库</SelectItem>
<SelectItem value="cache">缓存</SelectItem>
<SelectItem value="loadbalancer">负载均衡</SelectItem>
<SelectItem value="cluster">集群</SelectItem>
{clusters.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
<RefreshCw className="h-4 w-4" />
<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>
{/* Topology Canvas */}
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[
{ label: '总节点数', value: topology.nodes.length, icon: <Server className="h-5 w-5" />, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '连接数', value: topology.edges.length, icon: <Network className="h-5 w-5" />, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: 'Ingress', value: countByType('loadbalancer'), icon: <Globe className="h-5 w-5" />, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: 'Service', value: countByType('service'), icon: <Network className="h-5 w-5" />, color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
{ label: 'Deployment', value: countByType('app'), icon: <Boxes className="h-5 w-5" />, color: 'text-rose-400', bg: 'bg-rose-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 placeholder:text-slate-500" />
</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>
<SelectItem value="cluster">集群</SelectItem>
<SelectItem value="loadbalancer">Ingress</SelectItem>
<SelectItem value="service">Service</SelectItem>
<SelectItem value="app">Deployment</SelectItem>
<SelectItem value="server">Node</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-[500px]">
{renderTopology()}
<div className="h-[600px] overflow-auto">
{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>
{/* Node List */}
<div>
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
@@ -336,24 +407,20 @@ export function TopologyView() {
<CardDescription className="text-slate-500">点击节点查看详情</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-[420px] overflow-auto">
{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 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}</p>
</div>
</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}</p>
</div>
</div>
))}
))
)}
</div>
</CardContent>
</Card>
@@ -365,87 +432,36 @@ export function TopologyView() {
<DialogContent className="bg-slate-900 border-slate-800">
<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>
<div className={`p-2 rounded-lg ${getNodeColor(selectedNode?.type || 'server')}`}>{selectedNode && getNodeIcon(selectedNode.type)}</div>
{selectedNode?.name}
</DialogTitle>
<DialogDescription className="text-slate-500">
类型: {selectedNode?.type}
</DialogDescription>
<DialogDescription className="text-slate-500">类型: {NODE_TYPE_LABELS[selectedNode?.type || ''] || selectedNode?.type}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-slate-800/50">
{selectedNode && (
<div className="space-y-4">
{renderNodeDetailFields(selectedNode)}
<div>
<p className="text-xs text-slate-500 mb-1">节点ID</p>
<p className="text-sm text-slate-200">{selectedNode?.id}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">节点名称</p>
<p className="text-sm text-slate-200">{selectedNode?.name}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">坐标位置</p>
<p className="text-sm text-slate-200">X: {selectedNode?.x}, Y: {selectedNode?.y}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">关联连接</p>
<p className="text-sm text-slate-200">
{topologyEdges.filter((e) => e.source === selectedNode?.id || e.target === selectedNode?.id).length} 条
</p>
</div>
</div>
{selectedNode?.data && (
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">扩展数据</h4>
<div className="p-3 rounded-lg bg-slate-800/50">
<pre className="text-xs text-slate-400 overflow-auto">
{JSON.stringify(selectedNode.data, null, 2)}
</pre>
</div>
</div>
)}
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">关联节点</h4>
<div className="space-y-2">
{topologyEdges
.filter((e) => e.source === selectedNode?.id || e.target === selectedNode?.id)
.map((edge) => {
const isSource = edge.source === selectedNode?.id;
<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 relatedNodeId = isSource ? edge.target : edge.source;
const relatedNode = topologyNodes.find((n) => n.id === relatedNodeId);
const relatedNode = topology.nodes.find((n) => n.id === relatedNodeId);
if (!relatedNode) 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(relatedNode.type)}`}>
{getNodeIcon(relatedNode.type)}
</div>
<div className={`p-1 rounded ${getNodeColor(relatedNode.type)}`}>{getNodeIcon(relatedNode.type)}</div>
<span className="text-sm text-slate-300">{relatedNode.name}</span>
<Badge variant="outline" className="ml-auto border-slate-700 text-slate-400 text-xs">
{edge.label}
</Badge>
<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>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Edit className="h-4 w-4 mr-2" />
编辑
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Server className="h-4 w-4 mr-2" />
查看资源
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</div>