diff --git a/src/components/topology/node-detail-sheet.tsx b/src/components/topology/node-detail-sheet.tsx
index c48953e..99cba9c 100644
--- a/src/components/topology/node-detail-sheet.tsx
+++ b/src/components/topology/node-detail-sheet.tsx
@@ -6,6 +6,7 @@ import {
Network,
Clock3,
FileText,
+ Route,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -18,6 +19,7 @@ import {
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { formatNanoDelay } from '@/lib/format'
export interface SheetNodeInfo {
@@ -45,6 +47,7 @@ interface NodeDetailSheetProps {
/** 设备描述(来自 /api/node-info 查询) */
description?: string | null
descriptionLoading?: boolean
+ extraRoutes?: string[]
}
export function NodeDetailSheet({
@@ -56,6 +59,7 @@ export function NodeDetailSheet({
onCopy,
description,
descriptionLoading,
+ extraRoutes = [],
}: NodeDetailSheetProps) {
if (!node) return null
@@ -91,7 +95,13 @@ export function NodeDetailSheet({
-
+
+
+
+ 概要
+ 额外路由
+
+
{/* Device Description */}
@@ -197,6 +207,26 @@ export function NodeDetailSheet({
发起 Kperf 测速(即将支持)
+
+
+ {descriptionLoading ? (
+
+
+
+
+ ) : extraRoutes.length === 0 ? (
+ 暂无额外路由
+ ) : (
+
+ {extraRoutes.map((route, index) => (
+ -
+
{route}
+
+ ))}
+
+ )}
+
+
diff --git a/src/hooks/use-topology.ts b/src/hooks/use-topology.ts
index 411b600..6e8b8fb 100644
--- a/src/hooks/use-topology.ts
+++ b/src/hooks/use-topology.ts
@@ -14,6 +14,8 @@ export interface LayoutNode extends SimulationNodeDatum {
compressedAddress?: string
isSelf: boolean
deviceName?: string
+ nodeInfoFullRevision?: number
+ nodeInfoFullRevisionEpoch?: string
x: number
y: number
}
@@ -117,7 +119,11 @@ export function useTopology(): UseTopologyResult {
const topoNodes: TopologyNode[] = Array.isArray(payload.nodes)
? payload.nodes.filter(
(n) => n && typeof n.id === 'string' && n.id !== ''
- )
+ ).map((n) => ({
+ ...n,
+ nodeInfoFullRevision: n.nodeInfoFullRevision ?? 0,
+ nodeInfoFullRevisionEpoch: n.nodeInfoFullRevisionEpoch ?? '',
+ }))
: []
const topoEdgesRaw: TopologyEdge[] = Array.isArray(payload.edges)
? payload.edges
@@ -170,7 +176,14 @@ export function useTopology(): UseTopologyResult {
prev.length === topoNodes.length
? prev.map((n) => {
const fresh = topoNodes.find((t) => t.id === n.id)
- return fresh ? { ...n, deviceName: fresh.deviceName } : n
+ return fresh
+ ? {
+ ...n,
+ deviceName: fresh.deviceName,
+ nodeInfoFullRevision: fresh.nodeInfoFullRevision,
+ nodeInfoFullRevisionEpoch: fresh.nodeInfoFullRevisionEpoch,
+ }
+ : n
})
: prev
)
diff --git a/src/pages/topology.tsx b/src/pages/topology.tsx
index eaa795c..28c0918 100644
--- a/src/pages/topology.tsx
+++ b/src/pages/topology.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo, useCallback, useRef } from 'react'
+import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import { ReactFlowProvider } from '@xyflow/react'
import {
Share2,
@@ -17,6 +17,22 @@ import { TopologyCanvas } from '@/components/topology/topology-canvas'
import { NodeDetailSheet } from '@/components/topology/node-detail-sheet'
import type { NodeInfoDetail } from '@/types/api'
+type NodeInfoFreshnessKey = readonly [epoch: string, revision: number]
+
+function getNodeInfoFreshnessKey(node: {
+ nodeInfoFullRevision?: number
+ nodeInfoFullRevisionEpoch?: string
+}): NodeInfoFreshnessKey {
+ return [node.nodeInfoFullRevisionEpoch ?? '', node.nodeInfoFullRevision ?? 0]
+}
+
+function freshnessKeysEqual(
+ left: NodeInfoFreshnessKey | null,
+ right: NodeInfoFreshnessKey
+): boolean {
+ return left?.[0] === right[0] && left?.[1] === right[1]
+}
+
export function TopologyPage() {
const { nodes, edges, isLoading, error, relayout } = useTopology()
const [searchQuery, setSearchQuery] = useState('')
@@ -28,6 +44,23 @@ export function TopologyPage() {
const [nodeInfo, setNodeInfo] = useState(null)
const [nodeInfoLoading, setNodeInfoLoading] = useState(false)
const nodeInfoSeqRef = useRef(0)
+ const nodeInfoFreshnessRef = useRef(null)
+ const nodeInfoRevisionInitializedRef = useRef(false)
+
+ const fetchNodeInfo = useCallback((nodeId: string) => {
+ setNodeInfo(null)
+ setNodeInfoLoading(true)
+ const seq = ++nodeInfoSeqRef.current
+ fetch(`/api/node-info?address=${encodeURIComponent(nodeId)}`)
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data: NodeInfoDetail | null) => {
+ if (seq === nodeInfoSeqRef.current && data) setNodeInfo(data)
+ })
+ .catch(() => {})
+ .finally(() => {
+ if (seq === nodeInfoSeqRef.current) setNodeInfoLoading(false)
+ })
+ }, [])
const handleCopy = useCallback((text: string) => {
navigator.clipboard.writeText(text)
@@ -39,30 +72,18 @@ export function TopologyPage() {
setSelectedNodeId(nodeId)
if (nodeId) {
setSheetOpen(true)
- setNodeInfo(null)
- setNodeInfoLoading(true)
- const seq = ++nodeInfoSeqRef.current
- fetch(`/api/node-info?address=${encodeURIComponent(nodeId)}`)
- .then((res) => (res.ok ? res.json() : null))
- .then((data: NodeInfoDetail | null) => {
- // 防止乱序:仅当仍是当前选中节点时才应用结果
- if (seq === nodeInfoSeqRef.current && data) {
- setNodeInfo(data)
- }
- })
- .catch(() => {})
- .finally(() => {
- if (seq === nodeInfoSeqRef.current) {
- setNodeInfoLoading(false)
- }
- })
+ const selected = nodes.find((node) => node.id === nodeId)
+ nodeInfoFreshnessRef.current = getNodeInfoFreshnessKey(selected ?? {})
+ nodeInfoRevisionInitializedRef.current = true
+ fetchNodeInfo(nodeId)
} else {
setSheetOpen(false)
setNodeInfo(null)
setNodeInfoLoading(false)
nodeInfoSeqRef.current++
+ nodeInfoRevisionInitializedRef.current = false
}
- }, [])
+ }, [fetchNodeInfo, nodes])
const handleNodeDragEnd = useCallback(
(_id: string, _x: number, _y: number) => {
@@ -78,6 +99,16 @@ export function TopologyPage() {
[nodes, selectedNodeId]
)
+ useEffect(() => {
+ if (!selectedNode) return
+ const freshnessKey = getNodeInfoFreshnessKey(selectedNode)
+ if (!nodeInfoRevisionInitializedRef.current) return
+ if (!freshnessKeysEqual(nodeInfoFreshnessRef.current, freshnessKey)) {
+ nodeInfoFreshnessRef.current = freshnessKey
+ fetchNodeInfo(selectedNode.id)
+ }
+ }, [selectedNode, fetchNodeInfo])
+
// 邻居信息(基于边集合推导)
const neighbors = useMemo(() => {
if (!selectedNode) return []
@@ -203,6 +234,7 @@ export function TopologyPage() {
onCopy={handleCopy}
description={nodeInfo?.deviceDescription ?? null}
descriptionLoading={nodeInfoLoading}
+ extraRoutes={nodeInfo?.extraRoutes}
/>
)
diff --git a/src/types/api.ts b/src/types/api.ts
index 3b54c6e..9a87d83 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -80,6 +80,8 @@ export interface TopologyNode {
compressedAddress?: string
isSelf: boolean
deviceName?: string
+ nodeInfoFullRevision?: number
+ nodeInfoFullRevisionEpoch?: string
}
export interface TopologyEdge {
@@ -101,6 +103,7 @@ export interface NodeInfoDetail {
deviceDescription: string
reachable?: boolean
openLines?: string[]
+ extraRoutes?: string[]
}
export interface KLALBControllerConfig {