- 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
180 lines
5.9 KiB
TypeScript
180 lines
5.9 KiB
TypeScript
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>
|
|
)
|
|
}
|