snapshot: AlertCenter拆分前快照

This commit is contained in:
attacker committed 2026-06-15 13:01:56 +08:00
1 parent 6a40956afa
commit 805cb767a8
23 files changed
+1560 -404

No files matched your search

+334 -269
View File
@@ -2,175 +2,29 @@ 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,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import {
Network,
Search,
Server,
Share2,
Cloud,
RefreshCw,
Loader2,
Globe,
Boxes,
Search, RefreshCw, Loader2, Cloud, Save, Plus, Trash2, Link2, Pencil, RotateCcw,
} from 'lucide-react';
import { k8sApi } from '@/services/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
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 };
}
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: [] });
@@ -182,23 +36,49 @@ export function TopologyView() {
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: '', type: 'database', note: '' });
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([]);
}
if (list.length > 0 && !selectedClusterId) setSelectedClusterId(String(list[0].id));
} catch { setClusters([]); }
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
loadClusters();
}, [loadClusters]);
useEffect(() => { loadClusters(); }, [loadClusters]);
// 加载已保存的布局 // Logan.Li
const loadSavedLayout = useCallback(async (clusterId: string) => {
try {
const res: any = await k8sApi.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;
@@ -212,67 +92,49 @@ export function TopologyView() {
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]);
const built = buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes));
useEffect(() => {
loadTopology();
}, [loadTopology]);
// 合并已保存布局
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(
(node) =>
node.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
(viewType === 'all' || node.type === viewType),
(n) => n.name.toLowerCase().includes(searchQuery.toLowerCase()) && (viewType === 'all' || n.type === viewType),
);
const visibleIds = new Set(filteredNodes.map((n) => n.id));
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: 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';
}
};
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 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;
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 };
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 });
@@ -284,16 +146,133 @@ export function TopologyView() {
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,
),
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 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 k8sApi.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 k8sApi.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];
@@ -306,7 +285,7 @@ export function TopologyView() {
<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="#475569" />
<polygon points="0 0, 10 3.5, 0 7" fill={connectMode ? '#f59e0b' : '#475569'} />
</marker>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
@@ -317,28 +296,47 @@ export function TopologyView() {
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} />;
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>
);
})}
{filteredNodes.map((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>
</foreignObject>
<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>
))}
</svg>
);
};
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');
@@ -348,7 +346,7 @@ export function TopologyView() {
{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>
<p className="text-sm text-slate-200 truncate" title={String(val)}>{val === null || val === undefined ? '—' : String(val)}</p>
</div>
))}
</div>
@@ -361,33 +359,58 @@ 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 集群资源拓扑可视化 · 拖拽节点调整布局
{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); }}>
<SelectTrigger className="w-44 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="选择集群" />
</SelectTrigger>
<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>
))}
{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: <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' },
{ 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">
@@ -407,19 +430,13 @@ export function TopologyView() {
<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" />
<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>
<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>
{Object.entries(NODE_TYPE_LABELS).map(([v, l]) => <SelectItem key={v} value={v}>{l}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -429,7 +446,7 @@ export function TopologyView() {
<div className="lg:col-span-3">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="h-[600px] overflow-auto">
<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 ? (
@@ -458,7 +475,7 @@ export function TopologyView() {
<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>
<p className="text-xs text-slate-500">{NODE_TYPE_LABELS[node.type] || node.type}{isCustomNode(node.id) ? ' · 自定义' : ''}</p>
</div>
</div>
))
@@ -471,11 +488,12 @@ export function TopologyView() {
{/* Node Detail Dialog */}
<Dialog open={!!selectedNode} onOpenChange={() => setSelectedNode(null)}>
<DialogContent className="bg-slate-900 border-slate-800">
<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>
@@ -487,25 +505,72 @@ export function TopologyView() {
<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 = topology.nodes.find((n) => n.id === relatedNodeId);
if (!relatedNode) return null;
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(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>
<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 })}>
<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[];
}