2 Commits
Author SHA1 Message Date
SerinaNya 8c13af680a refactor(topology)!: rename node info revision
Replace the epoch/revision freshness tuple with nodeInfoRevision.

BREAKING CHANGE: topology API clients must use nodeInfoRevision;
nodeInfoFullRevision and nodeInfoFullRevisionEpoch are removed.
2026-08-31 01:13:34 +08:00
SerinaNya 0b705f9ba0 feat(topology): show extra routes in node details
Refresh selected node details when full metadata changes.
2026-08-28 23:13:49 +08:00
4 changed files with 80 additions and 22 deletions
+31 -1
View File
@@ -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>
+12 -2
View File
@@ -14,6 +14,7 @@ export interface LayoutNode extends SimulationNodeDatum {
compressedAddress?: string
isSelf: boolean
deviceName?: string
nodeInfoRevision?: number
x: number
y: number
}
@@ -117,7 +118,10 @@ 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,
nodeInfoRevision: n.nodeInfoRevision ?? 0,
}))
: []
const topoEdgesRaw: TopologyEdge[] = Array.isArray(payload.edges)
? payload.edges
@@ -170,7 +174,13 @@ 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,
nodeInfoRevision: fresh.nodeInfoRevision,
}
: n
})
: prev
)
+35 -19
View File
@@ -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,
@@ -28,6 +28,23 @@ export function TopologyPage() {
const [nodeInfo, setNodeInfo] = useState<NodeInfoDetail | null>(null)
const [nodeInfoLoading, setNodeInfoLoading] = useState(false)
const nodeInfoSeqRef = useRef(0)
const nodeInfoRevisionRef = useRef<number | 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 +56,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)
nodeInfoRevisionRef.current = selected?.nodeInfoRevision ?? 0
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 +83,16 @@ export function TopologyPage() {
[nodes, selectedNodeId]
)
useEffect(() => {
if (!selectedNode) return
const nodeInfoRevision = selectedNode.nodeInfoRevision ?? 0
if (!nodeInfoRevisionInitializedRef.current) return
if (nodeInfoRevisionRef.current !== nodeInfoRevision) {
nodeInfoRevisionRef.current = nodeInfoRevision
fetchNodeInfo(selectedNode.id)
}
}, [selectedNode, fetchNodeInfo])
// 邻居信息(基于边集合推导)
const neighbors = useMemo(() => {
if (!selectedNode) return []
@@ -203,6 +218,7 @@ export function TopologyPage() {
onCopy={handleCopy}
description={nodeInfo?.deviceDescription ?? null}
descriptionLoading={nodeInfoLoading}
extraRoutes={nodeInfo?.extraRoutes}
/>
</div>
)
+2
View File
@@ -80,6 +80,7 @@ export interface TopologyNode {
compressedAddress?: string
isSelf: boolean
deviceName?: string
nodeInfoRevision?: number
}
export interface TopologyEdge {
@@ -101,6 +102,7 @@ export interface NodeInfoDetail {
deviceDescription: string
reachable?: boolean
openLines?: string[]
extraRoutes?: string[]
}
export interface KLALBControllerConfig {