Compare commits
1
Commits
f38d640ac4
...
ad94d2c274
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad94d2c274
|
@@ -48,7 +48,6 @@ export function AppSidebar({
|
|||||||
id: 'overview' as NavTab,
|
id: 'overview' as NavTab,
|
||||||
title: '总览',
|
title: '总览',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
badge: isConnected ? 'Live' : undefined,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'connections' as NavTab,
|
id: 'connections' as NavTab,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function DeviceNodeComponent({ data, selected }: NodeProps<Node<DeviceNodeData>>
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex min-w-44 cursor-pointer flex-col gap-1 rounded-xl border bg-card px-3 py-2.5 shadow-sm transition-all',
|
'group relative flex w-fit min-w-56 cursor-pointer flex-col gap-1 rounded-xl border bg-card px-3 py-2.5 shadow-sm transition-all',
|
||||||
'hover:border-primary/50 hover:shadow-md',
|
'hover:border-primary/50 hover:shadow-md',
|
||||||
selected && 'border-primary ring-2 ring-primary/30',
|
selected && 'border-primary ring-2 ring-primary/30',
|
||||||
isSelf &&
|
isSelf &&
|
||||||
@@ -48,7 +48,7 @@ function DeviceNodeComponent({ data, selected }: NodeProps<Node<DeviceNodeData>>
|
|||||||
|
|
||||||
<div className="flex min-w-0 flex-col leading-tight">
|
<div className="flex min-w-0 flex-col leading-tight">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="max-w-36 truncate text-xs font-semibold text-foreground">
|
<span className="max-w-60 truncate text-xs font-semibold text-foreground">
|
||||||
{deviceName || '未命名节点'}
|
{deviceName || '未命名节点'}
|
||||||
</span>
|
</span>
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
@@ -66,7 +66,7 @@ function DeviceNodeComponent({ data, selected }: NodeProps<Node<DeviceNodeData>>
|
|||||||
className="flex items-center gap-0.5 font-mono text-[10px] text-muted-foreground hover:text-foreground"
|
className="flex items-center gap-0.5 font-mono text-[10px] text-muted-foreground hover:text-foreground"
|
||||||
title="点击复制完整地址"
|
title="点击复制完整地址"
|
||||||
>
|
>
|
||||||
<span className="max-w-32 truncate">{compressedAddress || address}</span>
|
<span className="whitespace-nowrap">{compressedAddress || address}</span>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
<Check className="size-2.5 shrink-0 text-emerald-500" />
|
<Check className="size-2.5 shrink-0 text-emerald-500" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {
|
|||||||
Gauge,
|
Gauge,
|
||||||
Network,
|
Network,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -40,6 +42,9 @@ interface NodeDetailSheetProps {
|
|||||||
neighbors: SheetNeighborInfo[]
|
neighbors: SheetNeighborInfo[]
|
||||||
copiedId: string | null
|
copiedId: string | null
|
||||||
onCopy: (text: string) => void
|
onCopy: (text: string) => void
|
||||||
|
/** 设备描述(来自 /api/node-info 查询) */
|
||||||
|
description?: string | null
|
||||||
|
descriptionLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeDetailSheet({
|
export function NodeDetailSheet({
|
||||||
@@ -49,6 +54,8 @@ export function NodeDetailSheet({
|
|||||||
neighbors,
|
neighbors,
|
||||||
copiedId,
|
copiedId,
|
||||||
onCopy,
|
onCopy,
|
||||||
|
description,
|
||||||
|
descriptionLoading,
|
||||||
}: NodeDetailSheetProps) {
|
}: NodeDetailSheetProps) {
|
||||||
if (!node) return null
|
if (!node) return null
|
||||||
|
|
||||||
@@ -85,6 +92,24 @@ export function NodeDetailSheet({
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-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">
|
||||||
|
<FileText className="size-3.5" />
|
||||||
|
设备描述
|
||||||
|
</span>
|
||||||
|
{descriptionLoading ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Skeleton className="h-3.5 w-full" />
|
||||||
|
<Skeleton className="h-3.5 w-2/3" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="whitespace-pre-wrap break-words text-xs leading-relaxed text-foreground">
|
||||||
|
{description ? description : '无描述信息'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Full Address */}
|
{/* Full Address */}
|
||||||
<div className="flex flex-col gap-1.5 rounded-lg border bg-muted/30 p-3">
|
<div className="flex flex-col gap-1.5 rounded-lg border bg-muted/30 p-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
|||||||
+13
-10
@@ -49,28 +49,31 @@ function computeForceLayout(
|
|||||||
const positions = new Map<string, { x: number; y: number }>()
|
const positions = new Map<string, { x: number; y: number }>()
|
||||||
if (topoNodes.length === 0) return positions
|
if (topoNodes.length === 0) return positions
|
||||||
|
|
||||||
|
// 节点卡片较宽(~300px),初始环形半径与斥力需足够大以避免重叠
|
||||||
const simNodes: LayoutNode[] = topoNodes.map((n, i) => ({
|
const simNodes: LayoutNode[] = topoNodes.map((n, i) => ({
|
||||||
...n,
|
...n,
|
||||||
// 初始位置:环形分布避免重叠
|
x: Math.cos((2 * Math.PI * i) / Math.max(topoNodes.length, 1)) * 450,
|
||||||
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)) * 450,
|
||||||
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 nodeIds = new Set(simNodes.map((n) => n.id))
|
||||||
|
|
||||||
const simLinks = topoEdges
|
const simLinks = topoEdges
|
||||||
.filter((e) => nodeIndex.has(e.source) && nodeIndex.has(e.target))
|
.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const cost = e.cost > 0 ? e.cost : e.delay
|
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))
|
const distance = Math.max(280, Math.min(650, 300 + Math.log10(Math.max(cost, 1)) * 35))
|
||||||
return { source: nodeIndex.get(e.source)!, target: nodeIndex.get(e.target)!, distance }
|
return { source: e.source, target: e.target, distance }
|
||||||
})
|
})
|
||||||
|
|
||||||
const simulation = forceSimulation(simNodes)
|
const simulation = forceSimulation(simNodes)
|
||||||
.force('charge', forceManyBody().strength(-600))
|
.force('charge', forceManyBody().strength(-2200))
|
||||||
.force(
|
.force(
|
||||||
'link',
|
'link',
|
||||||
forceLink(simLinks).id((d: SimulationNodeDatum) => (d as LayoutNode).id).distance((l: { distance: number }) => l.distance).strength(0.6)
|
forceLink(simLinks)
|
||||||
|
.id((d: SimulationNodeDatum) => (d as LayoutNode).id)
|
||||||
|
.distance((l: any) => l.distance ?? 300)
|
||||||
|
.strength(0.5)
|
||||||
)
|
)
|
||||||
.force('center', forceCenter(0, 0))
|
.force('center', forceCenter(0, 0))
|
||||||
.stop()
|
.stop()
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export function OverviewPage({ status, links, isConnected }: OverviewPageProps)
|
|||||||
|
|
||||||
const metrics = status?.metrics
|
const metrics = status?.metrics
|
||||||
|
|
||||||
// Calculate link status counts
|
// Calculate link status counts (LinkStatus: DOWN=0, UNSTABLE=1, UP=2; state string like "●up"/"○down")
|
||||||
const establishedLinks = links.filter(
|
const establishedLinks = links.filter(
|
||||||
(l) => l.state === 'ESTABLISHED' || l.stateCode === 1
|
(l) => l.stateCode === 2 || (l.state && l.state.toLowerCase().includes('up')) || l.state === 'ESTABLISHED'
|
||||||
).length
|
).length
|
||||||
const totalLinks = links.length
|
const totalLinks = links.length
|
||||||
|
|
||||||
@@ -142,7 +142,8 @@ export function OverviewPage({ status, links, isConnected }: OverviewPageProps)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-0.5 rounded-xl border bg-background/60 px-3 py-1.5 shadow-2xs">
|
<div className="flex flex-col gap-0.5 rounded-xl border bg-background/60 px-3 py-1.5 shadow-2xs">
|
||||||
<Zap className="size-3.5 text-muted-foreground" />
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Zap className="size-3.5" />
|
||||||
<span>活跃线路</span>
|
<span>活跃线路</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-sm font-bold text-foreground">
|
<span className="font-mono text-sm font-bold text-foreground">
|
||||||
@@ -151,6 +152,7 @@ export function OverviewPage({ status, links, isConnected }: OverviewPageProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
@@ -411,15 +412,16 @@ function SettingsForm({
|
|||||||
|
|
||||||
<Field className="md:col-span-2">
|
<Field className="md:col-span-2">
|
||||||
<FieldLabel htmlFor="deviceDescription">设备描述</FieldLabel>
|
<FieldLabel htmlFor="deviceDescription">设备描述</FieldLabel>
|
||||||
<Input
|
<Textarea
|
||||||
id="deviceDescription"
|
id="deviceDescription"
|
||||||
placeholder="如: 机房 A 区多线聚合网关"
|
placeholder={'如: 机房 A 区多线聚合网关\n支持多行输入'}
|
||||||
|
className="min-h-20"
|
||||||
value={form.DeviceDescription || ''}
|
value={form.DeviceDescription || ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((prev) => ({ ...prev, DeviceDescription: e.target.value }))
|
setForm((prev) => ({ ...prev, DeviceDescription: e.target.value }))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FieldDescription>本地备注说明信息,不参与网络路由决策</FieldDescription>
|
<FieldDescription>本地备注说明信息,支持多行,不参与网络路由决策</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+39
-3
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react'
|
import { useState, useMemo, useCallback, useRef } from 'react'
|
||||||
import { ReactFlowProvider } from '@xyflow/react'
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Share2,
|
Share2,
|
||||||
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { useTopology } from '@/hooks/use-topology'
|
import { useTopology } from '@/hooks/use-topology'
|
||||||
import { TopologyCanvas } from '@/components/topology/topology-canvas'
|
import { TopologyCanvas } from '@/components/topology/topology-canvas'
|
||||||
import { NodeDetailSheet } from '@/components/topology/node-detail-sheet'
|
import { NodeDetailSheet } from '@/components/topology/node-detail-sheet'
|
||||||
|
import type { NodeInfoDetail } from '@/types/api'
|
||||||
|
|
||||||
export function TopologyPage() {
|
export function TopologyPage() {
|
||||||
const { nodes, edges, isLoading, error, relayout } = useTopology()
|
const { nodes, edges, isLoading, error, relayout } = useTopology()
|
||||||
@@ -23,6 +24,11 @@ export function TopologyPage() {
|
|||||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
|
||||||
|
// 节点详情(含设备描述,来自 /api/node-info)
|
||||||
|
const [nodeInfo, setNodeInfo] = useState<NodeInfoDetail | null>(null)
|
||||||
|
const [nodeInfoLoading, setNodeInfoLoading] = useState(false)
|
||||||
|
const nodeInfoSeqRef = useRef(0)
|
||||||
|
|
||||||
const handleCopy = useCallback((text: string) => {
|
const handleCopy = useCallback((text: string) => {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
setCopiedId(text)
|
setCopiedId(text)
|
||||||
@@ -31,7 +37,31 @@ export function TopologyPage() {
|
|||||||
|
|
||||||
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
||||||
setSelectedNodeId(nodeId)
|
setSelectedNodeId(nodeId)
|
||||||
if (nodeId) setSheetOpen(true)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setSheetOpen(false)
|
||||||
|
setNodeInfo(null)
|
||||||
|
setNodeInfoLoading(false)
|
||||||
|
nodeInfoSeqRef.current++
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleNodeDragEnd = useCallback(
|
const handleNodeDragEnd = useCallback(
|
||||||
@@ -161,12 +191,18 @@ export function TopologyPage() {
|
|||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setSheetOpen(open)
|
setSheetOpen(open)
|
||||||
if (!open) setSelectedNodeId(null)
|
if (!open) {
|
||||||
|
setSelectedNodeId(null)
|
||||||
|
setNodeInfo(null)
|
||||||
|
nodeInfoSeqRef.current++
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
node={sheetNode}
|
node={sheetNode}
|
||||||
neighbors={neighbors}
|
neighbors={neighbors}
|
||||||
copiedId={copiedId}
|
copiedId={copiedId}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
|
description={nodeInfo?.deviceDescription ?? null}
|
||||||
|
descriptionLoading={nodeInfoLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ export interface TopologyData {
|
|||||||
edges: TopologyEdge[]
|
edges: TopologyEdge[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NodeInfoDetail {
|
||||||
|
address: string
|
||||||
|
isSelf: boolean
|
||||||
|
deviceName: string
|
||||||
|
deviceDescription: string
|
||||||
|
reachable?: boolean
|
||||||
|
openLines?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface NetworkInterfaceInfo {
|
export interface NetworkInterfaceInfo {
|
||||||
name: string
|
name: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
|||||||
Reference in New Issue
Block a user