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
This commit is contained in:
2026-08-24 23:59:00 +08:00
parent 4531e5366b
commit 3f0b20e757
10 changed files with 1114 additions and 37 deletions
+2 -2
View File
@@ -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) {
+214
View File
@@ -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<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>()
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<LayoutNode[]>([])
const [edges, setEdges] = useState<LayoutEdge[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const lastSignatureRef = useRef<string>('')
const savedPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map())
const layoutPositionsRef = useRef<Map<string, { x: number; y: number }>>(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<TopologyData>
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<string, TopologyEdge>()
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<string, { x: number; y: number }>
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 }
}