From 3f0b20e75736551027e65eabd2e33061940ff8f7 Mon Sep 17 00:00:00 2001
From: SerinaNya <34389622+SerinaNya@users.noreply.github.com>
Date: Mon, 24 Aug 2026 23:59:00 +0800
Subject: [PATCH] feat(topology): add network topology page; tighten config
save check
- Interactive SRv6 topology canvas (@xyflow/react + d3-force auto-layout),
custom nodes/edges, detail sheet, search and relayout controls
- Strict success-flag validation on /api/config save responses
---
package.json | 3 +
pnpm-lock.yaml | 248 +++++++++++++++---
src/App.tsx | 6 +-
src/components/topology/device-node.tsx | 96 +++++++
src/components/topology/link-edge.tsx | 94 +++++++
src/components/topology/node-detail-sheet.tsx | 179 +++++++++++++
src/components/topology/topology-canvas.tsx | 134 ++++++++++
src/hooks/use-klalb-config.ts | 4 +-
src/hooks/use-topology.ts | 214 +++++++++++++++
src/pages/topology.tsx | 173 ++++++++++++
10 files changed, 1114 insertions(+), 37 deletions(-)
create mode 100644 src/components/topology/device-node.tsx
create mode 100644 src/components/topology/link-edge.tsx
create mode 100644 src/components/topology/node-detail-sheet.tsx
create mode 100644 src/components/topology/topology-canvas.tsx
create mode 100644 src/hooks/use-topology.ts
create mode 100644 src/pages/topology.tsx
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