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:
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user