diff --git a/package.json b/package.json index 21d8c32..a896ad5 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "@base-ui/react": "^1.7.0", "@fontsource-variable/inter": "^5.3.0", "@tailwindcss/vite": "^4", + "@xyflow/react": "^12.11.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "d3-force": "^3.0.0", "lucide-react": "^1.33.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -27,6 +29,7 @@ }, "devDependencies": { "@eslint/js": "^10", + "@types/d3-force": "^3.0.10", "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd34e17..175d6e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,12 +17,18 @@ importers: '@tailwindcss/vite': specifier: ^4 version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)) + '@xyflow/react': + specifier: ^12.11.3 + version: 12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 clsx: specifier: ^2.1.1 version: 2.1.1 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 lucide-react: specifier: ^1.33.0 version: 1.33.0(react@19.2.8) @@ -48,6 +54,9 @@ importers: '@eslint/js': specifier: ^10 version: 10.0.1(eslint@10.9.0(jiti@2.7.0)) + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/node': specifier: ^24 version: 24.13.3 @@ -347,10 +356,6 @@ packages: '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} - '@jridgewell/gen-mapping@0.3.5': - resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} - engines: {node: '>=6.0.0'} - '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -358,19 +363,6 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.4.10': - resolution: {integrity: sha512-Ht8wIW5v165atIX1p+JvKR5ONzUyF4Ac8DZIQ5kZs9zrb6M8SJNXpx1zn04rn65VjBMygRoMXcyYwNK0fT7bEg==} - - '@jridgewell/sourcemap-codec@1.4.14': - resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} - - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -609,6 +601,27 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -707,6 +720,22 @@ packages: oxc-transform-react: optional: true + '@xyflow/react@12.11.3': + resolution: {integrity: sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.80': + resolution: {integrity: sha512-ywc3ZqG91brzWrH1WlwMdIX4goOfrpBy6AbLdVSaof/Xx9l138ijIKRExM6EkMro2F+OImGmSiA/WKcXvKVcfA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -836,6 +865,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -918,6 +950,52 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -2405,6 +2483,21 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + snapshots: '@babel/code-frame@7.29.7': @@ -2723,36 +2816,22 @@ snapshots: '@jridgewell/gen-mapping@0.3.12': dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/gen-mapping@0.3.5': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.4.10 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': dependencies: - '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/gen-mapping': 0.3.12 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/set-array@1.2.1': {} - - '@jridgewell/sourcemap-codec@1.4.10': {} - - '@jridgewell/sourcemap-codec@1.4.14': {} - - '@jridgewell/sourcemap-codec@1.5.0': {} - '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.4.14 + '@jridgewell/sourcemap-codec': 1.5.5 '@modelcontextprotocol/sdk@1.30.0(zod@3.24.1)': dependencies: @@ -2918,6 +2997,29 @@ snapshots: minimatch: 10.2.6 path-browserify: 1.0.1 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-force@3.0.10': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -3034,6 +3136,31 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0) + '@xyflow/react@12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@xyflow/system': 0.0.80 + classcat: 5.0.5 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + zustand: 4.5.7(@types/react@19.2.18)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.80': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -3153,6 +3280,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -3222,6 +3351,50 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-quadtree@3.0.1: {} + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -4541,3 +4714,10 @@ snapshots: zod@3.24.1: {} zod@4.4.3: {} + + zustand@4.5.7(@types/react@19.2.18)(react@19.2.8): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + react: 19.2.8 diff --git a/src/App.tsx b/src/App.tsx index 87ba06b..75a5551 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { AppHeader } from '@/components/layout/app-header' import { OverviewPage } from '@/pages/overview' import { ConnectionsPage } from '@/pages/connections' import { SettingsPage } from '@/pages/settings' +import { TopologyPage } from '@/pages/topology' import { useKlalbSSE } from '@/hooks/use-klalb-sse' import { useHashRoute } from '@/hooks/use-hash-route' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -72,9 +73,12 @@ export function App() { {currentTab === 'settings' && } + {currentTab === 'topology' && } + {currentTab !== 'overview' && currentTab !== 'connections' && - currentTab !== 'settings' && ( + currentTab !== 'settings' && + currentTab !== 'topology' && (
diff --git a/src/components/topology/device-node.tsx b/src/components/topology/device-node.tsx new file mode 100644 index 0000000..9c7544c --- /dev/null +++ b/src/components/topology/device-node.tsx @@ -0,0 +1,96 @@ +import { memo } from 'react' +import { Handle, Position, type NodeProps, type Node } from '@xyflow/react' +import { Server, Copy, Check } from 'lucide-react' +import { cn } from '@/lib/utils' + +export interface DeviceNodeData { + address: string + compressedAddress?: string + isSelf: boolean + deviceName?: string + onCopy?: (text: string) => void + copiedId?: string | null + [key: string]: unknown +} + +export type DeviceNodeType = Node + +function DeviceNodeComponent({ data, selected }: NodeProps>) { + const { deviceName, compressedAddress, address, isSelf, onCopy, copiedId } = data + const isCopied = copiedId === address + + return ( +
+ + +
+
+ +
+ +
+
+ + {deviceName || '未命名节点'} + + {isSelf && ( + + 本机 + + )} +
+ +
+
+ + {isSelf && ( + + + + + )} + + +
+ ) +} + +export const DeviceNode = memo(DeviceNodeComponent) diff --git a/src/components/topology/link-edge.tsx b/src/components/topology/link-edge.tsx new file mode 100644 index 0000000..63b3ae5 --- /dev/null +++ b/src/components/topology/link-edge.tsx @@ -0,0 +1,94 @@ +import { memo } from 'react' +import { + BaseEdge, + EdgeLabelRenderer, + getSmoothStepPath, + useReactFlow, + type EdgeProps, + type Edge, +} from '@xyflow/react' +import { formatNanoDelay } from '@/lib/format' + +export interface LinkEdgeData { + delay: number + cost: number + [key: string]: unknown +} + +export type LinkEdgeType = Edge + +function delayColor(delayNs: number): { stroke: string; badge: string } { + const ms = delayNs / 1_000_000 + if (delayNs <= 0 || !Number.isFinite(delayNs)) { + return { stroke: 'hsl(var(--muted-foreground))', badge: 'bg-muted text-muted-foreground' } + } + if (ms < 1) { + return { stroke: '#10b981', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' } + } + if (ms < 20) { + return { stroke: '#f59e0b', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400' } + } + return { stroke: '#f43f5e', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400' } +} + +function LinkEdgeComponent({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + selected, +}: EdgeProps) { + const { setEdges } = useReactFlow() + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 16, + }) + + const delay = data?.delay ?? 0 + const { stroke, badge } = delayColor(delay) + const showLabel = delay > 0 && Number.isFinite(delay) + + return ( + <> + + {showLabel && ( + +
+ +
+
+ )} + + ) +} + +export const LinkEdge = memo(LinkEdgeComponent) diff --git a/src/components/topology/node-detail-sheet.tsx b/src/components/topology/node-detail-sheet.tsx new file mode 100644 index 0000000..4f9d8da --- /dev/null +++ b/src/components/topology/node-detail-sheet.tsx @@ -0,0 +1,179 @@ +import { + Server, + Copy, + Check, + Gauge, + Network, + Clock3, +} from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' +import { formatNanoDelay } from '@/lib/format' + +export interface SheetNodeInfo { + id: string + address: string + compressedAddress?: string + isSelf: boolean + deviceName?: string +} + +export interface SheetNeighborInfo { + address: string + deviceName?: string + delay: number + cost: number +} + +interface NodeDetailSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + node: SheetNodeInfo | null + neighbors: SheetNeighborInfo[] + copiedId: string | null + onCopy: (text: string) => void +} + +export function NodeDetailSheet({ + open, + onOpenChange, + node, + neighbors, + copiedId, + onCopy, +}: NodeDetailSheetProps) { + if (!node) return null + + const isCopied = copiedId === node.address + + return ( + + + +
+
+ +
+
+ + {node.deviceName || '未命名节点'} + {node.isSelf && ( + + 本机 + + )} + + + {node.compressedAddress || node.address} + +
+
+
+ +
+ {/* Full Address */} +
+ + 完整 SRv6 地址 (SID) + +
+ + {node.address} + + +
+
+ + {/* Stats */} +
+
+
+ + 邻居数量 +
+ {neighbors.length} +
+
+
+ + 平均时延 +
+ + {neighbors.length > 0 + ? formatNanoDelay( + neighbors.reduce((acc, n) => acc + n.delay, 0) / neighbors.length + ) + : '--'} + +
+
+ + + + {/* Neighbor list */} +
+ 邻居链路 + {neighbors.length === 0 ? ( +
+ 暂无可达邻居节点 +
+ ) : ( + neighbors.map((nb) => ( +
+
+ + {nb.deviceName || '未命名节点'} + + + {nb.address} + +
+
+ + {formatNanoDelay(nb.delay)} +
+
+ )) + )} +
+ + {/* Reserved for Kperf speed test */} + +
+
+
+ ) +} diff --git a/src/components/topology/topology-canvas.tsx b/src/components/topology/topology-canvas.tsx new file mode 100644 index 0000000..7c65562 --- /dev/null +++ b/src/components/topology/topology-canvas.tsx @@ -0,0 +1,134 @@ +import { useCallback, useMemo } from 'react' +import { + ReactFlow, + Background, + BackgroundVariant, + Controls, + MiniMap, + type Node, + type Edge, + type NodeMouseHandler, + type OnNodesChange, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' +import { DeviceNode, type DeviceNodeData } from './device-node' +import { LinkEdge, type LinkEdgeData } from './link-edge' +import type { LayoutEdge, LayoutNode } from '@/hooks/use-topology' + +const nodeTypes = { device: DeviceNode } +const edgeTypes = { link: LinkEdge } + +interface TopologyCanvasProps { + nodes: LayoutNode[] + edges: LayoutEdge[] + searchQuery: string + copiedId: string | null + onCopy: (text: string) => void + /** 用户拖拽结束后的位置回写(id -> 坐标),用于轮询刷新时保持位置 */ + onNodeDragEnd?: (id: string, x: number, y: number) => void + onNodeSelect: (nodeId: string | null) => void +} + +function matchesSearch(node: LayoutNode, query: string): boolean { + if (!query.trim()) return true + const q = query.toLowerCase() + return ( + (node.deviceName ?? '').toLowerCase().includes(q) || + node.address.toLowerCase().includes(q) || + (node.compressedAddress ?? '').toLowerCase().includes(q) + ) +} + +export function TopologyCanvas({ + nodes: layoutNodes, + edges: layoutEdges, + searchQuery, + copiedId, + onCopy, + onNodeDragEnd, + onNodeSelect, +}: TopologyCanvasProps) { + // 完全受控渲染:由外部数据纯派生,无内部镜像状态 + const flowNodes = useMemo[]>( + () => + layoutNodes.map((n) => ({ + id: n.id, + type: 'device' as const, + position: { x: n.x, y: n.y }, + data: { + address: n.address, + compressedAddress: n.compressedAddress, + isSelf: n.isSelf, + deviceName: n.deviceName, + copiedId, + onCopy, + }, + style: matchesSearch(n, searchQuery) ? undefined : { opacity: 0.25 }, + })), + [layoutNodes, searchQuery, copiedId, onCopy] + ) + + const flowEdges = useMemo[]>( + () => + layoutEdges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + type: 'link' as const, + data: { delay: e.delay, cost: e.cost }, + })), + [layoutEdges] + ) + + // 拖拽过程中仅更新受控坐标(position 变更);其余变更类型忽略 + const onNodesChange: OnNodesChange> = useCallback( + (changes) => { + if (!onNodeDragEnd) return + for (const change of changes) { + if ( + change.type === 'position' && + change.position && + !change.dragging + ) { + onNodeDragEnd(change.id, change.position.x, change.position.y) + } + } + }, + [onNodeDragEnd] + ) + + const handleNodeClick: NodeMouseHandler> = useCallback( + (_, node) => { + onNodeSelect(node.id) + }, + [onNodeSelect] + ) + + const handlePaneClick = useCallback(() => { + onNodeSelect(null) + }, [onNodeSelect]) + + return ( + + + + + + ) +} diff --git a/src/hooks/use-klalb-config.ts b/src/hooks/use-klalb-config.ts index b2247cf..5bd9043 100644 --- a/src/hooks/use-klalb-config.ts +++ b/src/hooks/use-klalb-config.ts @@ -49,13 +49,13 @@ export function useKlalbConfig(): UseKlalbConfigResult { body: JSON.stringify(newConfig), }) const data = await res.json() - if (res.ok && (data.success || data.success === undefined)) { + if (res.ok && data.success === true) { setConfig(newConfig) setSaveSuccess(true) setTimeout(() => setSaveSuccess(false), 3000) return { success: true } } - const errMsg = data.error || data.message || '保存配置失败' + const errMsg = data.error || '保存配置失败' setError(errMsg) return { success: false, message: errMsg } } catch (err: unknown) { diff --git a/src/hooks/use-topology.ts b/src/hooks/use-topology.ts new file mode 100644 index 0000000..9257ce5 --- /dev/null +++ b/src/hooks/use-topology.ts @@ -0,0 +1,214 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { + forceSimulation, + forceLink, + forceManyBody, + forceCenter, + type SimulationNodeDatum, +} from 'd3-force' +import type { TopologyNode, TopologyEdge, TopologyData } from '@/types/api' + +export interface LayoutNode extends SimulationNodeDatum { + id: string + address: string + compressedAddress?: string + isSelf: boolean + deviceName?: string + x: number + y: number +} + +export interface LayoutEdge { + id: string + source: string + target: string + delay: number + cost: number +} + +interface UseTopologyResult { + nodes: LayoutNode[] + edges: LayoutEdge[] + isLoading: boolean + error: string | null + relayout: () => void +} + +const POLL_INTERVAL_MS = 1000 + +function structureSignature(nodes: TopologyNode[], edges: TopologyEdge[]): string { + const n = nodes.map((x) => x.id).sort().join(',') + const e = edges.map((x) => `${x.source}->${x.target}`).sort().join(',') + return `${n}|${e}` +} + +function computeForceLayout( + topoNodes: TopologyNode[], + topoEdges: TopologyEdge[] +): Map { + const positions = new Map() + if (topoNodes.length === 0) return positions + + const simNodes: LayoutNode[] = topoNodes.map((n, i) => ({ + ...n, + // 初始位置:环形分布避免重叠 + x: Math.cos((2 * Math.PI * i) / Math.max(topoNodes.length, 1)) * 250, + y: Math.sin((2 * Math.PI * i) / Math.max(topoNodes.length, 1)) * 250, + })) + const nodeIndex = new Map(simNodes.map((n, i) => [n.id, i])) + + const simLinks = topoEdges + .filter((e) => nodeIndex.has(e.source) && nodeIndex.has(e.target)) + .map((e) => { + const cost = e.cost > 0 ? e.cost : e.delay + // 延迟越高距离越远;基准距离 120px,按 ns 缩放 + const distance = Math.max(80, Math.min(400, 120 + Math.log10(Math.max(cost, 1)) * 20)) + return { source: nodeIndex.get(e.source)!, target: nodeIndex.get(e.target)!, distance } + }) + + const simulation = forceSimulation(simNodes) + .force('charge', forceManyBody().strength(-600)) + .force( + 'link', + forceLink(simLinks).id((d: SimulationNodeDatum) => (d as LayoutNode).id).distance((l: { distance: number }) => l.distance).strength(0.6) + ) + .force('center', forceCenter(0, 0)) + .stop() + + // 同步运行固定迭代数,提取最终坐标 + const ticks = 300 + for (let i = 0; i < ticks; i++) simulation.tick() + + for (const n of simNodes) { + positions.set(n.id, { x: n.x ?? 0, y: n.y ?? 0 }) + } + return positions +} + +function makeEdgeId(source: string, target: string): string { + return source < target ? `${source}__${target}` : `${target}__${source}` +} + +export function useTopology(): UseTopologyResult { + const [nodes, setNodes] = useState([]) + const [edges, setEdges] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const lastSignatureRef = useRef('') + const savedPositionsRef = useRef>(new Map()) + const layoutPositionsRef = useRef>(new Map()) + const relayoutRequestedRef = useRef(false) + const mountedRef = useRef(true) + + const fetchTopology = useCallback(async () => { + try { + const res = await fetch('/api/nodes') + if (!res.ok) { + setError(`拓扑接口异常 (${res.status})`) + return + } + const payload = (await res.json()) as Partial + if (!mountedRef.current) return + + const topoNodes: TopologyNode[] = Array.isArray(payload.nodes) + ? payload.nodes.filter( + (n) => n && typeof n.id === 'string' && n.id !== '' + ) + : [] + const topoEdgesRaw: TopologyEdge[] = Array.isArray(payload.edges) + ? payload.edges + : [] + + // 边去重(后端已做一次,这里再保险)并生成稳定 id + const edgeMap = new Map() + for (const e of topoEdgesRaw) { + if (!e.source || !e.target || e.source === e.target) continue + const id = makeEdgeId(e.source, e.target) + edgeMap.set(id, { ...e }) + } + const topoEdges = Array.from(edgeMap.values()) + + const signature = structureSignature(topoNodes, topoEdges) + const structureChanged = + signature !== lastSignatureRef.current || + relayoutRequestedRef.current + + if (structureChanged) { + let positions: Map + if ( + !relayoutRequestedRef.current && + layoutPositionsRef.current.size > 0 && + lastSignatureRef.current === '' + ) { + positions = layoutPositionsRef.current + } else { + positions = computeForceLayout(topoNodes, topoEdges) + } + + // 合并用户拖拽过的位置 + for (const [id, pos] of savedPositionsRef.current) { + if (positions.has(id)) positions.set(id, pos) + } + + layoutPositionsRef.current = positions + lastSignatureRef.current = signature + relayoutRequestedRef.current = false + + setNodes( + topoNodes.map((n) => ({ + ...n, + ...(positions.get(n.id) ?? { x: 0, y: 0 }), + })) + ) + } else { + // 结构未变:仅原地刷新延迟等元数据,位置保持不变 + setNodes((prev) => + prev.length === topoNodes.length + ? prev.map((n) => { + const fresh = topoNodes.find((t) => t.id === n.id) + return fresh ? { ...n, deviceName: fresh.deviceName } : n + }) + : prev + ) + } + + setEdges( + topoEdges.map((e) => ({ + id: makeEdgeId(e.source, e.target), + source: e.source, + target: e.target, + delay: e.delay, + cost: e.cost, + })) + ) + setError(null) + } catch (err: unknown) { + if (mountedRef.current) { + const msg = err instanceof Error ? err.message : String(err) + setError(msg) + } + } finally { + if (mountedRef.current) setIsLoading(false) + } + }, []) + + const relayout = useCallback(() => { + relayoutRequestedRef.current = true + savedPositionsRef.current.clear() + fetchTopology() + }, [fetchTopology]) + + // 轮询 + useEffect(() => { + mountedRef.current = true + fetchTopology() + const timer = window.setInterval(fetchTopology, POLL_INTERVAL_MS) + return () => { + mountedRef.current = false + window.clearInterval(timer) + } + }, [fetchTopology]) + + return { nodes, edges, isLoading, error, relayout } +} diff --git a/src/pages/topology.tsx b/src/pages/topology.tsx new file mode 100644 index 0000000..bc03bb3 --- /dev/null +++ b/src/pages/topology.tsx @@ -0,0 +1,173 @@ +import { useState, useMemo, useCallback } from 'react' +import { ReactFlowProvider } from '@xyflow/react' +import { + Share2, + Search, + RefreshCw, + Network, + Link2, + AlertCircle, +} from 'lucide-react' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useTopology } from '@/hooks/use-topology' +import { TopologyCanvas } from '@/components/topology/topology-canvas' +import { NodeDetailSheet } from '@/components/topology/node-detail-sheet' + +export function TopologyPage() { + const { nodes, edges, isLoading, error, relayout } = useTopology() + const [searchQuery, setSearchQuery] = useState('') + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [copiedId, setCopiedId] = useState(null) + const [sheetOpen, setSheetOpen] = useState(false) + + const handleCopy = useCallback((text: string) => { + navigator.clipboard.writeText(text) + setCopiedId(text) + setTimeout(() => setCopiedId(null), 2000) + }, []) + + const handleNodeSelect = useCallback((nodeId: string | null) => { + setSelectedNodeId(nodeId) + if (nodeId) setSheetOpen(true) + }, []) + + const handleNodeDragEnd = useCallback( + (_id: string, _x: number, _y: number) => { + // 受控模式下坐标由 ReactFlow 内部维护至下次结构刷新; + // 结构刷新后 d3-force 会重新计算,拖拽位置保留策略由后续迭代增强。 + }, + [] + ) + + // 选中的节点详情 + const selectedNode = useMemo( + () => nodes.find((n) => n.id === selectedNodeId) ?? null, + [nodes, selectedNodeId] + ) + + // 邻居信息(基于边集合推导) + const neighbors = useMemo(() => { + if (!selectedNode) return [] + return edges + .filter((e) => e.source === selectedNode.id || e.target === selectedNode.id) + .map((e) => { + const otherId = e.source === selectedNode.id ? e.target : e.source + const other = nodes.find((n) => n.id === otherId) + return { + address: other?.address ?? otherId, + deviceName: other?.deviceName, + delay: e.delay, + cost: e.cost, + } + }) + }, [edges, nodes, selectedNode]) + + const sheetNode = useMemo( + () => + selectedNode + ? { + id: selectedNode.id, + address: selectedNode.address, + compressedAddress: selectedNode.compressedAddress, + isSelf: selectedNode.isSelf, + deviceName: selectedNode.deviceName, + } + : null, + [selectedNode] + ) + + return ( +
+ {/* Toolbar */} +
+
+
+ +
+
+

网络拓扑

+

+ SRv6 全网节点与链路的实时可视化视图 +

+
+ +
+ + + {nodes.length} 节点 + + + + {edges.length} 链路 + +
+
+ +
+
+ + setSearchQuery(e.target.value)} + /> +
+ +
+
+ + {/* Error banner */} + {error && !nodes.length && ( + + + + 无法获取拓扑数据:{error} + + + )} + + {/* Canvas */} + +
+ + + +
+
+ + {/* Detail Sheet */} + { + setSheetOpen(open) + if (!open) setSelectedNodeId(null) + }} + node={sheetNode} + neighbors={neighbors} + copiedId={copiedId} + onCopy={handleCopy} + /> +
+ ) +}