feat(topology): add network topology page; tighten config save check
- Interactive SRv6 topology canvas (@xyflow/react + d3-force auto-layout), custom nodes/edges, detail sheet, search and relayout controls - Strict success-flag validation on /api/config save responses
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
Server,
|
||||
Copy,
|
||||
Check,
|
||||
Gauge,
|
||||
Network,
|
||||
Clock3,
|
||||
} from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { formatNanoDelay } from '@/lib/format'
|
||||
|
||||
export interface SheetNodeInfo {
|
||||
id: string
|
||||
address: string
|
||||
compressedAddress?: string
|
||||
isSelf: boolean
|
||||
deviceName?: string
|
||||
}
|
||||
|
||||
export interface SheetNeighborInfo {
|
||||
address: string
|
||||
deviceName?: string
|
||||
delay: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
interface NodeDetailSheetProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
node: SheetNodeInfo | null
|
||||
neighbors: SheetNeighborInfo[]
|
||||
copiedId: string | null
|
||||
onCopy: (text: string) => void
|
||||
}
|
||||
|
||||
export function NodeDetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
node,
|
||||
neighbors,
|
||||
copiedId,
|
||||
onCopy,
|
||||
}: NodeDetailSheetProps) {
|
||||
if (!node) return null
|
||||
|
||||
const isCopied = copiedId === node.address
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full gap-0 sm:max-w-sm">
|
||||
<SheetHeader className="border-b">
|
||||
<div className="flex items-center gap-2.5 pt-4">
|
||||
<div
|
||||
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
|
||||
node.isSelf
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Server className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="flex items-center gap-2 truncate">
|
||||
{node.deviceName || '未命名节点'}
|
||||
{node.isSelf && (
|
||||
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
||||
本机
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="truncate font-mono text-xs">
|
||||
{node.compressedAddress || node.address}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||
{/* Full Address */}
|
||||
<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">
|
||||
完整 SRv6 地址 (SID)
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<code className="break-all font-mono text-xs font-semibold text-foreground select-all">
|
||||
{node.address}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0"
|
||||
onClick={() => onCopy(node.address)}
|
||||
title="复制完整地址"
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="size-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-0.5 rounded-lg border bg-card p-2.5">
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Network className="size-3" />
|
||||
邻居数量
|
||||
</div>
|
||||
<span className="font-mono text-lg font-bold">{neighbors.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 rounded-lg border bg-card p-2.5">
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Gauge className="size-3" />
|
||||
平均时延
|
||||
</div>
|
||||
<span className="font-mono text-lg font-bold">
|
||||
{neighbors.length > 0
|
||||
? formatNanoDelay(
|
||||
neighbors.reduce((acc, n) => acc + n.delay, 0) / neighbors.length
|
||||
)
|
||||
: '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Neighbor list */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">邻居链路</span>
|
||||
{neighbors.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||
暂无可达邻居节点
|
||||
</div>
|
||||
) : (
|
||||
neighbors.map((nb) => (
|
||||
<div
|
||||
key={nb.address}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border bg-card px-2.5 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col leading-tight">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{nb.deviceName || '未命名节点'}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{nb.address}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-[11px] font-semibold">
|
||||
<Clock3 className="size-3" />
|
||||
{formatNanoDelay(nb.delay)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reserved for Kperf speed test */}
|
||||
<Button variant="outline" size="sm" disabled title="测速功能即将接入">
|
||||
<Gauge data-icon="inline-start" />
|
||||
发起 Kperf 测速(即将支持)
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user