refactor(quality): Q20 前端巨型组件拆分 — TopologyView 596L→472L (-21%), 提取 types + TopologyCanvas + NodeDetailDialog + EditNodeDialog 4 个独立文件, 移除文件级 eslint-disable + any→ClusterInfo 具体接口类型, 零逻辑变更, ESLint errors=0/warnings=65/any=0, tsc -b=0, npm build✓, 17/17 测试通过

This commit is contained in:
attacker committed 2026-07-08 18:21:41 +08:00
1 parent 42340edce2
commit a530d3898c
7 files changed
+392 -194

No files matched your search

@@ -1,38 +1,34 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import {
Search, RefreshCw, Loader2, Cloud, Save, Plus, Trash2, Link2, Pencil, RotateCcw,
Search, RefreshCw, Loader2, Cloud, Save, Plus, Link2, Pencil, RotateCcw,
} from 'lucide-react';
import { kubernetesApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import type { TopologyNode, TopologyEdge } from '@/types';
import {
buildTopology, getNodeIcon, getNodeColor, NODE_TYPE_LABELS, CUSTOM_NODE_TYPES,
isCustomNode, type TopologyLayoutData,
buildTopology, getNodeIcon, getNodeColor, NODE_TYPE_LABELS, isCustomNode, type TopologyLayoutData,
} from './topology-helpers';
import { PageShell } from '@/components/layout/PageShell';
import { StatStrip } from '@/components/ui/stat-strip';
import { TopologyCanvas } from './topology-view/TopologyCanvas';
import { NodeDetailDialog } from './topology-view/NodeDetailDialog';
import { EditNodeDialog } from './topology-view/EditNodeDialog';
import type { ClusterInfo, EditFormState, DragOffset, SvgPoint } from './topology-view/types';
export function TopologyView() {
const { t } = useTranslation();
const { clusterCode } = useParams<{ clusterCode?: string }>();
const _confirm = useConfirm();
const [clusters, setClusters] = useState<any[]>([]);
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [topology, setTopology] = useState<{ nodes: TopologyNode[]; edges: TopologyEdge[] }>({ nodes: [], edges: [] });
const [loading, setLoading] = useState(false);
@@ -40,7 +36,7 @@ export function TopologyView() {
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 [dragOffset, setDragOffset] = useState<DragOffset>({ x: 0, y: 0 });
const svgRef = useRef<SVGSVGElement>(null);
// 编辑模式状态 // Logan.Li
@@ -53,17 +49,17 @@ export function TopologyView() {
// 编辑节点 Dialog
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingNode, setEditingNode] = useState<TopologyNode | null>(null);
const [editForm, setEditForm] = useState<{ name: string; type: TopologyNode['type']; note: string }>({ name: '', type: 'database', note: '' });
const [editForm, setEditForm] = useState<EditFormState>({ name: '', type: 'database', note: '' });
const loadClusters = useCallback(async () => {
try {
const res: Record<string, any> = await kubernetesApi.getClusters();
const list = res?.list || [];
const res = await kubernetesApi.getClusters();
const list = ((res as { list?: ClusterInfo[] })?.list || []) as ClusterInfo[];
setClusters(list);
// Auto-select from URL param (clusterCode) if provided, otherwise first cluster
if (!selectedClusterId) {
if (clusterCode) {
const matched = list.find(( c: any) => (c.code || String(c.id)) === clusterCode);
const matched = list.find((c) => (c.code || String(c.id)) === clusterCode);
if (matched) setSelectedClusterId(String(matched.id));
} else if (list.length > 0) {
setSelectedClusterId(String(list[0].id));
@@ -77,10 +73,10 @@ export function TopologyView() {
// 加载已保存的布局 // Logan.Li
const loadSavedLayout = useCallback(async (clusterId: string) => {
try {
const res: Record<string, any> = await kubernetesApi.getTopology(clusterId);
const layout = res as Record<string, any>;
const res = await kubernetesApi.getTopology(clusterId) as Record<string, unknown>;
const layout = res as Record<string, unknown>;
if (layout?.layout_data) {
const data = JSON.parse(layout.layout_data) as TopologyLayoutData;
const data = JSON.parse(layout.layout_data as string) as TopologyLayoutData;
setSavedLayout(data);
if (data.customNodes?.length > 0) {
const maxId = Math.max(...data.customNodes.map((n) => {
@@ -99,16 +95,20 @@ export function TopologyView() {
if (!selectedClusterId) return;
setLoading(true);
try {
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || []));
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {};
const toArray = (v: unknown): unknown[] => {
if (Array.isArray(v)) return v;
const r = v as Record<string, unknown>;
return (r?.list || []) as unknown[];
};
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {} as ClusterInfo;
// 统一使用 Kuboard code 路由获取资源 (ROADMAP A1)
const clusterCode = (cluster as any).code || selectedClusterId;
const code = cluster.code || selectedClusterId;
const [nodesRes, svcRes, depRes, ingRes, podRes] = await Promise.all([
kubernetesApi.getNodes(clusterCode).catch(() => []),
kubernetesApi.getServices(clusterCode).catch(() => []),
kubernetesApi.getDeployments(clusterCode).catch(() => []),
kubernetesApi.getIngresses(clusterCode).catch(() => []),
kubernetesApi.getPods(clusterCode).catch(() => []),
kubernetesApi.getNodes(code).catch(() => []),
kubernetesApi.getServices(code).catch(() => []),
kubernetesApi.getDeployments(code).catch(() => []),
kubernetesApi.getIngresses(code).catch(() => []),
kubernetesApi.getPods(code).catch(() => []),
]);
const built = buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes), toArray(podRes));
@@ -141,7 +141,7 @@ export function TopologyView() {
const visibleIds = new Set(filteredNodes.map((n) => n.id));
// --- 拖拽 ---
const getSvgPoint = (e: React.MouseEvent): { x: number; y: number } => {
const getSvgPoint = (e: React.MouseEvent): SvgPoint => {
const svg = svgRef.current;
if (!svg) return { x: 0, y: 0 };
const pt = svg.createSVGPoint();
@@ -204,7 +204,8 @@ export function TopologyView() {
const handleEditNode = (node: TopologyNode) => {
setEditingNode(node);
setEditForm({ name: node.name, type: node.type, note: (node.data as Record<string, any>)?.note || '' });
const note = (node.data as Record<string, unknown>)?.note as string || '';
setEditForm({ name: node.name, type: node.type, note });
setEditDialogOpen(true);
};
@@ -290,87 +291,6 @@ export function TopologyView() {
const countByType = (type: string) => topology.nodes.filter((n) => n.type === type).length;
// --- 渲染 SVG 拓扑 ---
const renderTopology = () => {
if (topology.nodes.length === 0) return null;
const ys = [60, 180, 320, 480, 640];
const labels = [t('k8s.tab.overview'), 'Ingress', 'Service', 'Deployment', 'Node'];
return (
<svg ref={svgRef} width="100%" height="100%" viewBox={`0 0 ${canvasW} ${canvasH}`} className="bg-[var(--dg-bg-primary)] rounded-lg" preserveAspectRatio="xMidYMin meet"
onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" strokeWidth="0.5" />
</pattern>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill={connectMode ? '#f59e0b' : '#475569'} />
</marker>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{labels.map((label, i) => (
<text key={label} x={14} y={ys[i] + 4} fill="#475569" fontSize="11" className="font-medium select-none">{label}</text>
))}
{topology.edges.map((edge) => {
const source = topology.nodes.find((n) => n.id === edge.source);
const target = topology.nodes.find((n) => n.id === edge.target);
if (!source || !target || !visibleIds.has(source.id) || !visibleIds.has(target.id)) return null;
const isCustom = edge.id.startsWith('custom-edge');
return (
<g key={edge.id} className={editMode && isCustom ? 'cursor-pointer' : ''} onClick={() => { if (editMode && isCustom) handleDeleteEdge(edge); }}>
<line x1={source.x} y1={source.y} x2={target.x} y2={target.y}
stroke={isCustom ? '#f59e0b' : '#475569'} strokeWidth={isCustom ? 2 : 1.5}
strokeDasharray={isCustom ? '6 3' : undefined}
markerEnd="url(#arrowhead)" opacity={isCustom ? 0.8 : 0.6} />
{editMode && isCustom && (
<text x={(source.x + target.x) / 2} y={(source.y + target.y) / 2}
fill="#f59e0b" fontSize="10" textAnchor="middle" className="select-none pointer-events-none">✕ {t('k8s.topology.deleteText')}</text>
)}
</g>
);
})}
{filteredNodes.map((node) => {
const isCustom = isCustomNode(node.id);
const isConnectSource = connectSource === node.id;
return (
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}
className={connectMode ? 'cursor-crosshair' : draggingId === node.id ? 'cursor-grabbing' : 'cursor-grab'}
onMouseDown={(e) => handleNodeMouseDown(e, node)}
onClick={() => handleNodeClick(node)}
onDoubleClick={() => { if (editMode) handleEditNode(node); }}>
<circle r="28" fill="#0f172a"
stroke={isConnectSource ? '#f59e0b' : selectedNode?.id === node.id ? '#3b82f6' : isCustom ? '#f59e0b' : '#334155'}
strokeWidth={isConnectSource || selectedNode?.id === node.id ? 3 : 2}
strokeDasharray={isCustom ? '4 2' : undefined}
className="transition-all hover:stroke-blue-500" />
<foreignObject x="-12" y="-12" width="24" height="24">
<div className={`flex items-center justify-center w-full h-full ${getNodeColor(node.type).split(' ')[1]}`}>{getNodeIcon(node.type)}</div>
</foreignObject>
<text y="45" fill={isCustom ? '#f59e0b' : '#94a3b8'} fontSize="12" textAnchor="middle" className="font-medium select-none">
{node.name.length > 20 ? node.name.slice(0, 18) + '…' : node.name}
</text>
</g>
);
})}
</svg>
);
};
const renderNodeDetailFields = (node: TopologyNode) => {
const data = node.data || {};
const entries = Object.entries(data).filter(([k]) => k !== 'kind');
if (entries.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-3">
{entries.map(([key, val]) => (
<div key={key} className="p-2 rounded-lg bg-[var(--dg-bg-tertiary)]/50">
<p className="text-xs dg-text-muted mb-0.5">{key}</p>
<p className="text-sm text-[var(--dg-text-primary)] truncate" title={String(val)}>{val === null || val === undefined ? '—' : String(val)}</p>
</div>
))}
</div>
);
};
const selectedClusterName = clusters.find((c) => String(c.id) === selectedClusterId)?.name;
const filterToolbar = (
<div className="flex items-center gap-3">
@@ -474,7 +394,28 @@ export function TopologyView() {
<Cloud className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{selectedClusterId ? t('k8s.topology.noDataWithCluster') : t('k8s.topology.noDataSelectCluster')}</p>
</div>
) : renderTopology()}
) : (
<TopologyCanvas
svgRef={svgRef}
canvasW={canvasW}
canvasH={canvasH}
nodes={topology.nodes}
edges={topology.edges}
filteredNodes={filteredNodes}
visibleIds={visibleIds}
editMode={editMode}
connectMode={connectMode}
connectSource={connectSource}
selectedNode={selectedNode}
draggingId={draggingId}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onNodeMouseDown={handleNodeMouseDown}
onNodeClick={handleNodeClick}
onNodeDoubleClick={(node) => { if (editMode) handleEditNode(node); }}
onEdgeClick={handleDeleteEdge}
/>
)}
</div>
</CardContent>
</Card>
@@ -507,90 +448,25 @@ export function TopologyView() {
</div>
{/* Node Detail Dialog */}
<Dialog open={!!selectedNode} onOpenChange={() => setSelectedNode(null)}>
<DialogContent className="dg-card max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] 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 dg-text-yellow text-xs">{t('k8s.topology.custom2')}</Badge>}
</DialogTitle>
<DialogDescription className="dg-text-muted">{t('common.type')}: {NODE_TYPE_LABELS[selectedNode?.type || ''] ? t(NODE_TYPE_LABELS[selectedNode?.type || '']) : selectedNode?.type}</DialogDescription>
</DialogHeader>
{selectedNode && (
<div className="space-y-4">
{renderNodeDetailFields(selectedNode)}
<div>
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)] mb-2">{t('k8s.topology.relatedConnections')}</h4>
<div className="space-y-2">
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((edge) => {
const isSource = edge.source === selectedNode.id;
const related = topology.nodes.find((n) => n.id === (isSource ? edge.target : edge.source));
if (!related) return null;
return (
<div key={edge.id} className="flex items-center gap-2 p-2 rounded-lg bg-[var(--dg-bg-tertiary)]/50">
<span className="text-sm dg-text-secondary">{isSource ? '→' : '←'}</span>
<div className={`p-1 rounded ${getNodeColor(related.type)}`}>{getNodeIcon(related.type, 'h-4 w-4')}</div>
<span className="text-sm text-[var(--dg-text-secondary)]">{related.name}</span>
<Badge variant="outline" className="ml-auto border-[var(--dg-border)] dg-text-secondary text-xs">{edge.label}</Badge>
</div>
);
})}
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length === 0 && <p className="text-xs dg-text-muted">{t('k8s.topology.noConnections')}</p>}
</div>
</div>
{/* 编辑模式操作按钮 */}
{editMode && selectedNode && (
<div className="flex gap-2 pt-2 border-t border-[var(--dg-border)]">
<Button size="sm" variant="outline" className="border-[var(--dg-border-hover)] text-[var(--dg-text-primary)]" onClick={() => handleEditNode(selectedNode)}>
<Pencil className="h-3.5 w-3.5 mr-1" />{t('common.edit')}
</Button>
{isCustomNode(selectedNode.id) && (
<Button size="sm" variant="outline" className="border-red-500/30 dg-text-red hover:bg-red-500/10" onClick={() => handleDeleteNode(selectedNode)}>
<Trash2 className="h-3.5 w-3.5 mr-1" />{t('common.delete')}
</Button>
)}
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
<NodeDetailDialog
node={selectedNode}
edges={topology.edges}
nodes={topology.nodes}
editMode={editMode}
onOpenChange={() => setSelectedNode(null)}
onEditNode={handleEditNode}
onDeleteNode={handleDeleteNode}
/>
{/* Edit/Add Node Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="dg-card max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">{editingNode ? t('k8s.topology.editNodeTitle') : t('k8s.topology.addNodeTitle')}</DialogTitle>
<DialogDescription className="dg-text-muted">{editingNode ? t('k8s.topology.editNodeDesc') : t('k8s.topology.addNodeDesc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">{t('k8s.topology.nodeName')}</Label>
<Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
placeholder={t('k8s.topology.namePlaceholder')} className="dg-input" />
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">{t('k8s.topology.nodeType')}</Label>
<Select value={editForm.type} onValueChange={(v) => setEditForm({ ...editForm, type: v as TopologyNode['type'] })}>
<SelectTrigger className="dg-input"><SelectValue /></SelectTrigger>
<SelectContent className="dg-input">
{CUSTOM_NODE_TYPES.map((opt) => <SelectItem key={opt.value} value={opt.value}>{t(opt.label)} — {t(opt.desc)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">{t('k8s.topology.nodeNote')}</Label>
<Textarea value={editForm.note} onChange={(e) => setEditForm({ ...editForm, note: e.target.value })}
placeholder={t('k8s.topology.notePlaceholder')} rows={2} className="dg-input resize-none" />
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => setEditDialogOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSaveNode}>{editingNode ? t('common.save') : t('k8s.topology.add2')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<EditNodeDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
editingNode={editingNode}
editForm={editForm}
setEditForm={setEditForm}
onSave={handleSaveNode}
/>
</PageShell>
);
}
@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { CUSTOM_NODE_TYPES } from '../topology-helpers';
import type { EditFormState } from './types';
import type { TopologyNode } from '@/types';
interface EditNodeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingNode: TopologyNode | null;
editForm: EditFormState;
setEditForm: (form: EditFormState) => void;
onSave: () => void;
}
export function EditNodeDialog({
open, onOpenChange, editingNode, editForm, setEditForm, onSave,
}: EditNodeDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="dg-card max-w-md">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">{editingNode ? t('k8s.topology.editNodeTitle') : t('k8s.topology.addNodeTitle')}</DialogTitle>
<DialogDescription className="dg-text-muted">{editingNode ? t('k8s.topology.editNodeDesc') : t('k8s.topology.addNodeDesc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">{t('k8s.topology.nodeName')}</Label>
<Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
placeholder={t('k8s.topology.namePlaceholder')} className="dg-input" />
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">{t('k8s.topology.nodeType')}</Label>
<Select value={editForm.type} onValueChange={(v) => setEditForm({ ...editForm, type: v as TopologyNode['type'] })}>
<SelectTrigger className="dg-input"><SelectValue /></SelectTrigger>
<SelectContent className="dg-input">
{CUSTOM_NODE_TYPES.map((opt) => <SelectItem key={opt.value} value={opt.value}>{t(opt.label)} — {t(opt.desc)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-[var(--dg-text-secondary)]">{t('k8s.topology.nodeNote')}</Label>
<Textarea value={editForm.note} onChange={(e) => setEditForm({ ...editForm, note: e.target.value })}
placeholder={t('k8s.topology.notePlaceholder')} rows={2} className="dg-input resize-none" />
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button onClick={onSave}>{editingNode ? t('common.save') : t('k8s.topology.add2')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,93 @@
import { useTranslation } from 'react-i18next';
import { Badge, } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Pencil, Trash2 } from 'lucide-react';
import type { TopologyNode, TopologyEdge } from '@/types';
import { getNodeIcon, getNodeColor, isCustomNode, NODE_TYPE_LABELS } from '../topology-helpers';
interface NodeDetailDialogProps {
node: TopologyNode | null;
edges: TopologyEdge[];
nodes: TopologyNode[];
editMode: boolean;
onOpenChange: (open: boolean) => void;
onEditNode: (node: TopologyNode) => void;
onDeleteNode: (node: TopologyNode) => void;
}
function 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-[var(--dg-bg-tertiary)]/50">
<p className="text-xs dg-text-muted mb-0.5">{key}</p>
<p className="text-sm text-[var(--dg-text-primary)] truncate" title={String(val)}>{val === null || val === undefined ? '—' : String(val)}</p>
</div>
))}
</div>
);
}
export function NodeDetailDialog({
node, edges, nodes, editMode, onOpenChange, onEditNode, onDeleteNode,
}: NodeDetailDialogProps) {
const { t } = useTranslation();
if (!node) return null;
const relatedEdges = edges.filter((e) => e.source === node.id || e.target === node.id);
return (
<Dialog open={!!node} onOpenChange={onOpenChange}>
<DialogContent className="dg-card max-w-lg">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<div className={`p-2 rounded-lg ${getNodeColor(node.type)}`}>{getNodeIcon(node.type)}</div>
{node.name}
{isCustomNode(node.id) && <Badge variant="outline" className="border-amber-500/30 dg-text-yellow text-xs">{t('k8s.topology.custom2')}</Badge>}
</DialogTitle>
<DialogDescription className="dg-text-muted">{t('common.type')}: {NODE_TYPE_LABELS[node.type] ? t(NODE_TYPE_LABELS[node.type]) : node.type}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{renderNodeDetailFields(node)}
<div>
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)] mb-2">{t('k8s.topology.relatedConnections')}</h4>
<div className="space-y-2">
{relatedEdges.map((edge) => {
const isSource = edge.source === node.id;
const related = 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-[var(--dg-bg-tertiary)]/50">
<span className="text-sm dg-text-secondary">{isSource ? '→' : '←'}</span>
<div className={`p-1 rounded ${getNodeColor(related.type)}`}>{getNodeIcon(related.type, 'h-4 w-4')}</div>
<span className="text-sm text-[var(--dg-text-secondary)]">{related.name}</span>
<Badge variant="outline" className="ml-auto border-[var(--dg-border)] dg-text-secondary text-xs">{edge.label}</Badge>
</div>
);
})}
{relatedEdges.length === 0 && <p className="text-xs dg-text-muted">{t('k8s.topology.noConnections')}</p>}
</div>
</div>
{editMode && (
<div className="flex gap-2 pt-2 border-t border-[var(--dg-border)]">
<Button size="sm" variant="outline" className="border-[var(--dg-border-hover)] text-[var(--dg-text-primary)]" onClick={() => onEditNode(node)}>
<Pencil className="h-3.5 w-3.5 mr-1" />{t('common.edit')}
</Button>
{isCustomNode(node.id) && (
<Button size="sm" variant="outline" className="border-red-500/30 dg-text-red hover:bg-red-500/10" onClick={() => onDeleteNode(node)}>
<Trash2 className="h-3.5 w-3.5 mr-1" />{t('common.delete')}
</Button>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,94 @@
import { type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import type { TopologyNode, TopologyEdge } from '@/types';
import { getNodeIcon, getNodeColor, isCustomNode } from '../topology-helpers';
interface TopologyCanvasProps {
svgRef: RefObject<SVGSVGElement | null>;
canvasW: number;
canvasH: number;
nodes: TopologyNode[];
edges: TopologyEdge[];
filteredNodes: TopologyNode[];
visibleIds: Set<string>;
editMode: boolean;
connectMode: boolean;
connectSource: string | null;
selectedNode: TopologyNode | null;
draggingId: string | null;
onMouseMove: (e: React.MouseEvent) => void;
onMouseUp: () => void;
onNodeMouseDown: (e: React.MouseEvent, node: TopologyNode) => void;
onNodeClick: (node: TopologyNode) => void;
onNodeDoubleClick: (node: TopologyNode) => void;
onEdgeClick: (edge: TopologyEdge) => void;
}
export function TopologyCanvas({
svgRef, canvasW, canvasH, nodes, edges, filteredNodes, visibleIds,
editMode, connectMode, connectSource, selectedNode, draggingId,
onMouseMove, onMouseUp, onNodeMouseDown, onNodeClick, onNodeDoubleClick, onEdgeClick,
}: TopologyCanvasProps) {
const { t } = useTranslation();
const ys = [60, 180, 320, 480, 640];
const labels = [t('k8s.tab.overview'), 'Ingress', 'Service', 'Deployment', 'Node'];
return (
<svg ref={svgRef} width="100%" height="100%" viewBox={`0 0 ${canvasW} ${canvasH}`} className="bg-[var(--dg-bg-primary)] rounded-lg" preserveAspectRatio="xMidYMin meet"
onMouseMove={onMouseMove} onMouseUp={onMouseUp} onMouseLeave={onMouseUp}>
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" strokeWidth="0.5" />
</pattern>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill={connectMode ? '#f59e0b' : '#475569'} />
</marker>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{labels.map((label, i) => (
<text key={label} x={14} y={ys[i] + 4} fill="#475569" fontSize="11" className="font-medium select-none">{label}</text>
))}
{edges.map((edge) => {
const source = nodes.find((n) => n.id === edge.source);
const target = nodes.find((n) => n.id === edge.target);
if (!source || !target || !visibleIds.has(source.id) || !visibleIds.has(target.id)) return null;
const isCustom = edge.id.startsWith('custom-edge');
return (
<g key={edge.id} className={editMode && isCustom ? 'cursor-pointer' : ''} onClick={() => { if (editMode && isCustom) onEdgeClick(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">✕ {t('k8s.topology.deleteText')}</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) => onNodeMouseDown(e, node)}
onClick={() => onNodeClick(node)}
onDoubleClick={() => onNodeDoubleClick(node)}>
<circle r="28" fill="#0f172a"
stroke={isConnectSource ? '#f59e0b' : selectedNode?.id === node.id ? '#3b82f6' : isCustom ? '#f59e0b' : '#334155'}
strokeWidth={isConnectSource || selectedNode?.id === node.id ? 3 : 2}
strokeDasharray={isCustom ? '4 2' : undefined}
className="transition-all hover:stroke-blue-500" />
<foreignObject x="-12" y="-12" width="24" height="24">
<div className={`flex items-center justify-center w-full h-full ${getNodeColor(node.type).split(' ')[1]}`}>{getNodeIcon(node.type)}</div>
</foreignObject>
<text y="45" fill={isCustom ? '#f59e0b' : '#94a3b8'} fontSize="12" textAnchor="middle" className="font-medium select-none">
{node.name.length > 20 ? node.name.slice(0, 18) + '…' : node.name}
</text>
</g>
);
})}
</svg>
);
}
@@ -0,0 +1,33 @@
import type { TopologyNode } from '@/types';
/** 集群信息(来自 kubernetesApi.getClusters 返回的列表项) */
export interface ClusterInfo {
id: number;
name: string;
code?: string;
version?: string;
status?: string;
api_server?: string;
node_count?: number;
pod_count?: number;
[key: string]: unknown;
}
/** 编辑/新增节点表单状态 */
export interface EditFormState {
name: string;
type: TopologyNode['type'];
note: string;
}
/** 拖拽偏移量 */
export interface DragOffset {
x: number;
y: number;
}
/** SVG 坐标点 */
export interface SvgPoint {
x: number;
y: number;
}