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 -1
View File
@@ -129,7 +129,7 @@
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q19 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + **GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级)**,累计提取 63 个独立组件文件** |
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q20 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级) + **TopologyView 596L→472L (-21%,提取 TopologyCanvas/NodeDetailDialog/EditNodeDialog,移除文件级 eslint-disable + any→ClusterInfo 具体接口)**,累计提取 67 个独立组件文件** |
### 3.2 🟡 中优先级
+37
View File
@@ -491,6 +491,43 @@ services/api.ts (1324L)
| Q17 | 前端巨型组件拆分(DeploymentEditService 627L→394L,提取 types + ServiceCreateForm + IngressCreateForm + ServiceListItem + IngressListItem) | P0 | **done** |
| Q18 | 前端巨型组件拆分(TraceManager 605L→331L,提取 types + trace-helpers + SpanTreeNode + TraceDetailDialog + SpanDetailDialog,修复 SpanTreeNode 内联定义反模式) | P0 | **done** |
| Q19 | 前端巨型组件拆分(GrafanaDashboard 624L→78L,提取 types + datasource-toolbar + BatchDeployDialog + AgentTab + ExternalDatasourceTab,DRY 合并 Zabbix/Prometheus Tab,移除文件级 eslint-disable,类型安全升级 any→具体接口) | P0 | **done** |
| Q20 | 前端巨型组件拆分(TopologyView 596L→472L,提取 types + TopologyCanvas + NodeDetailDialog + EditNodeDialog,移除文件级 eslint-disable,any→具体接口类型) | P0 | **done** |
---
## Q20: 前端巨型组件拆分 — TopologyView [done]
**问题**: `TopologyView.tsx`(596 行)是 Q19 拆分后项目最大的 K8s 业务组件之一(排除 UI 库 sidebar/data-table),包含架构拓扑页面的全部逻辑:集群加载、拓扑数据构建(Promise.all 拉取 5 种资源 + buildTopology)、已保存布局合并、节点过滤、SVG 画布渲染(defs/marker/edges/nodes 绘制 + 拖拽 + 连线模式)、编辑模式操作(新增/编辑/删除节点 + 创建/删除连线)、保存/重置布局、节点详情弹窗(基本信息网格 + 关联连线列表)、新增/编辑节点弹窗。文件顶部有 `eslint-disable @typescript-eslint/no-explicit-any`,多处 `any` 类型(clusters/c/cluster/v/node.data)。单文件过大导致维护困难。
**已完成 (2026-07-08)**:
将类型定义、SVG 画布渲染、节点详情弹窗、新增/编辑节点弹窗提取为 `topology-view/` 子目录下的独立文件:
| 新文件 | 行数 | 职责 |
|--------|------|------|
| `types.ts` | 33 | ClusterInfo/EditFormState/DragOffset/SvgPoint 接口(替代 clusters: any[] 等 any 类型) |
| `TopologyCanvas.tsx` | 94 | SVG 拓扑画布渲染(defs pattern/marker + 层级行标签 + edges 连线 + nodes 节点圆+图标+名称),受控组件接收 18 个 props(svgRef/canvasW/canvasH/nodes/edges/filteredNodes/visibleIds/editMode/connectMode/connectSource/selectedNode/draggingId + 6 回调) |
| `NodeDetailDialog.tsx` | 93 | 节点详情弹窗(renderNodeDetailFields 基本信息 grid-cols-2 + 关联连线列表 →/← + 编辑模式操作按钮),接收 node/edges/nodes/editMode + 3 回调 |
| `EditNodeDialog.tsx` | 65 | 新增/编辑节点弹窗(名称 Input + 类型 Select + 备注 Textarea),接收 open/editingNode/editForm/setEditForm/onSave |
主文件 `TopologyView.tsx` 从 **596 行降至 472 行**(-21%),仅保留:状态管理(clusters/topology/editMode/connectMode/editForm 等 20+ 个 useState)、数据加载(loadClusters/loadSavedLayout/loadTopology + Promise.all 5 资源拉取 + 布局合并)、拖拽处理(getSvgPoint/handleNodeMouseDown/handleMouseMove/handleMouseUp)、节点点击(区分普通/编辑/连线模式)、编辑操作(handleAddNode/handleEditNode/handleSaveNode/handleDeleteNode/handleDeleteEdge)、布局保存/重置、画布尺寸计算、filterToolbar、编辑模式工具栏、Canvas+NodeList 双栏布局、以及 3 个子组件编排。
**关键改进**:
- **移除文件级 `eslint-disable`**: `clusters: any[]` → `ClusterInfo[]`;`(cluster as any).code` → `cluster.code`(ClusterInfo 接口含 code 字段);`v: any` → `v: unknown` + toArray 返回 `unknown[]`;`(node.data as Record<string, any>)` → `Record<string, unknown>`。全局 ESLint any 保持 **0**。
- **SVG 渲染提取**: 原 `renderTopology()` 内联函数(60 行)提取为 `TopologyCanvas` 独立组件,消除内联函数每次 render 重建的性能隐患。
**验收标准**:
- [x] TopologyView.tsx 从 596 行降至 **472 行**(-21%)
- [x] 4 个提取的文件全部 < 95 行(最大 TopologyCanvas 94 行)
- [x] `tsc -b` 零错误
- [x] `npm run build` 零错误(✓ built in 17.89s)
- [x] ESLint errors 保持 **0**(topology-view/ 目录零 warnings)
- [x] ESLint warnings 保持 **65**(无新增)
- [x] 全局 ESLint any 保持 **0**(移除文件级 eslint-disable)
- [x] `go build` 零错误
- [x] 17/17 集成回归测试通过(零回归)
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
- [x] 架构拓扑页功能不变(SVG 画布/节点拖拽/编辑模式/连线/保存布局/节点列表/详情弹窗全部正常)
---
@@ -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;
}