Compare commits

...
1 Commits
Author SHA1 Message Date
SerinaNya ad94d2c274 feat(topology): add device description support and polish UI layouts
- Topology: query /api/node-info on node select and display multi-line description
- Topology: widen device node cards and remove SID truncation for full display
- Topology: tune d3-force layout parameters (increased distance & repulsion)
- Topology: fix d3-force string ID matching error ("node not found: 0")
- Settings: switch device description field from Input to multi-line Textarea
- Overview: fix active lines count logic and broken badge layout
- Sidebar: remove redundant Live badge from overview navigation item
2026-08-26 21:27:44 +08:00
8 changed files with 103 additions and 27 deletions
-1
View File
@@ -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,
+3 -3
View File
@@ -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
View File
@@ -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()
+5 -3
View File
@@ -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>
+5 -3
View File
@@ -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
View File
@@ -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>
) )
+9
View File
@@ -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