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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user