feat(topology): show extra routes in node details
Refresh selected node details when full metadata changes.
This commit is contained in:
@@ -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({
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto p-4">
|
||||
<Tabs defaultValue="overview" className="min-h-0">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">概要</TabsTrigger>
|
||||
<TabsTrigger value="routes"><Route className="size-3.5" />额外路由</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="flex flex-col gap-4 pt-4">
|
||||
{/* Device Description */}
|
||||
<div className="flex flex-col gap-1.5 rounded-lg border bg-muted/30 p-3">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
@@ -197,6 +207,26 @@ export function NodeDetailSheet({
|
||||
<Gauge data-icon="inline-start" />
|
||||
发起 Kperf 测速(即将支持)
|
||||
</Button>
|
||||
</TabsContent>
|
||||
<TabsContent value="routes" className="pt-4">
|
||||
{descriptionLoading ? (
|
||||
<div className="flex flex-col gap-1.5 py-8" aria-live="polite">
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-2/3" />
|
||||
</div>
|
||||
) : extraRoutes.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">暂无额外路由</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5" aria-label="额外路由列表">
|
||||
{extraRoutes.map((route, index) => (
|
||||
<li key={`${route}-${index}`}>
|
||||
<code className="block select-text break-all rounded-md border bg-muted/30 px-2.5 py-2 font-mono text-xs text-foreground">{route}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+51
-19
@@ -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<NodeInfoDetail | null>(null)
|
||||
const [nodeInfoLoading, setNodeInfoLoading] = useState(false)
|
||||
const nodeInfoSeqRef = useRef(0)
|
||||
const nodeInfoFreshnessRef = useRef<NodeInfoFreshnessKey | null>(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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user