From 0b705f9ba0008540099e307bbb1e3e6944d7aa71 Mon Sep 17 00:00:00 2001 From: SerinaNya <34389622+SerinaNya@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:13:49 +0800 Subject: [PATCH] feat(topology): show extra routes in node details Refresh selected node details when full metadata changes. --- src/components/topology/node-detail-sheet.tsx | 32 ++++++++- src/hooks/use-topology.ts | 17 ++++- src/pages/topology.tsx | 70 ++++++++++++++----- src/types/api.ts | 3 + 4 files changed, 100 insertions(+), 22 deletions(-) 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 {