Compare commits
2
Commits
f38d640ac4
...
89474b9e91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89474b9e91
|
||
|
|
ad94d2c274
|
+5
-2
@@ -8,6 +8,7 @@ import { OverviewPage } from '@/pages/overview'
|
|||||||
import { ConnectionsPage } from '@/pages/connections'
|
import { ConnectionsPage } from '@/pages/connections'
|
||||||
import { SettingsPage } from '@/pages/settings'
|
import { SettingsPage } from '@/pages/settings'
|
||||||
import { TopologyPage } from '@/pages/topology'
|
import { TopologyPage } from '@/pages/topology'
|
||||||
|
import { RoutingTablePage } from '@/pages/routing-table'
|
||||||
import { useKlalbSSE } from '@/hooks/use-klalb-sse'
|
import { useKlalbSSE } from '@/hooks/use-klalb-sse'
|
||||||
import { useHashRoute } from '@/hooks/use-hash-route'
|
import { useHashRoute } from '@/hooks/use-hash-route'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -39,7 +40,6 @@ export function App() {
|
|||||||
<AppSidebar
|
<AppSidebar
|
||||||
currentTab={currentTab}
|
currentTab={currentTab}
|
||||||
onSelectTab={navigate}
|
onSelectTab={navigate}
|
||||||
onlineDevices={status?.onlineDevices}
|
|
||||||
linksCount={safeLinks.length}
|
linksCount={safeLinks.length}
|
||||||
establishedLinksCount={establishedLinksCount}
|
establishedLinksCount={establishedLinksCount}
|
||||||
isConnected={isConnected}
|
isConnected={isConnected}
|
||||||
@@ -75,10 +75,13 @@ export function App() {
|
|||||||
|
|
||||||
{currentTab === 'topology' && <TopologyPage />}
|
{currentTab === 'topology' && <TopologyPage />}
|
||||||
|
|
||||||
|
{currentTab === 'routing-table' && <RoutingTablePage />}
|
||||||
|
|
||||||
{currentTab !== 'overview' &&
|
{currentTab !== 'overview' &&
|
||||||
currentTab !== 'connections' &&
|
currentTab !== 'connections' &&
|
||||||
currentTab !== 'settings' &&
|
currentTab !== 'settings' &&
|
||||||
currentTab !== 'topology' && (
|
currentTab !== 'topology' &&
|
||||||
|
currentTab !== 'routing-table' && (
|
||||||
<div className="flex flex-1 items-center justify-center p-8">
|
<div className="flex flex-1 items-center justify-center p-8">
|
||||||
<Card className="max-w-md text-center">
|
<Card className="max-w-md text-center">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Network,
|
Network,
|
||||||
GitFork,
|
GitFork,
|
||||||
Share2,
|
Share2,
|
||||||
Cpu,
|
|
||||||
Settings,
|
Settings,
|
||||||
Activity,
|
Activity,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -24,12 +23,11 @@ import {
|
|||||||
} from '@/components/ui/sidebar'
|
} from '@/components/ui/sidebar'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
export type NavTab = 'overview' | 'connections' | 'routes' | 'topology' | 'interfaces' | 'settings'
|
export type NavTab = 'overview' | 'connections' | 'routing-table' | 'topology' | 'settings'
|
||||||
|
|
||||||
interface AppSidebarProps {
|
interface AppSidebarProps {
|
||||||
currentTab: NavTab
|
currentTab: NavTab
|
||||||
onSelectTab: (tab: NavTab) => void
|
onSelectTab: (tab: NavTab) => void
|
||||||
onlineDevices?: number
|
|
||||||
linksCount?: number
|
linksCount?: number
|
||||||
establishedLinksCount?: number
|
establishedLinksCount?: number
|
||||||
isConnected?: boolean
|
isConnected?: boolean
|
||||||
@@ -38,7 +36,6 @@ interface AppSidebarProps {
|
|||||||
export function AppSidebar({
|
export function AppSidebar({
|
||||||
currentTab,
|
currentTab,
|
||||||
onSelectTab,
|
onSelectTab,
|
||||||
onlineDevices = 0,
|
|
||||||
linksCount = 0,
|
linksCount = 0,
|
||||||
establishedLinksCount = 0,
|
establishedLinksCount = 0,
|
||||||
isConnected = false,
|
isConnected = false,
|
||||||
@@ -48,7 +45,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,
|
||||||
@@ -57,7 +53,7 @@ export function AppSidebar({
|
|||||||
badge: linksCount > 0 ? `${establishedLinksCount}/${linksCount}` : undefined,
|
badge: linksCount > 0 ? `${establishedLinksCount}/${linksCount}` : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'routes' as NavTab,
|
id: 'routing-table' as NavTab,
|
||||||
title: 'SRv6 路由表',
|
title: 'SRv6 路由表',
|
||||||
icon: GitFork,
|
icon: GitFork,
|
||||||
},
|
},
|
||||||
@@ -65,12 +61,6 @@ export function AppSidebar({
|
|||||||
id: 'topology' as NavTab,
|
id: 'topology' as NavTab,
|
||||||
title: '网络拓扑',
|
title: '网络拓扑',
|
||||||
icon: Share2,
|
icon: Share2,
|
||||||
badge: onlineDevices > 0 ? `${onlineDevices}` : undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'interfaces' as NavTab,
|
|
||||||
title: '网卡接口',
|
|
||||||
icon: Cpu,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'settings' as NavTab,
|
id: 'settings' as NavTab,
|
||||||
@@ -161,4 +151,3 @@ export function AppSidebar({
|
|||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import type { NavTab } from '@/components/layout/app-sidebar'
|
|||||||
const VALID_TABS: NavTab[] = [
|
const VALID_TABS: NavTab[] = [
|
||||||
'overview',
|
'overview',
|
||||||
'connections',
|
'connections',
|
||||||
'routes',
|
'routing-table',
|
||||||
'topology',
|
'topology',
|
||||||
'interfaces',
|
|
||||||
'settings',
|
'settings',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { RoutingTableItem } from '@/types/api'
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 1000
|
||||||
|
|
||||||
|
interface UseRoutingTableResult {
|
||||||
|
routingTable: RoutingTableItem[]
|
||||||
|
isLoading: boolean
|
||||||
|
error: string | null
|
||||||
|
refresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoutingTable(): UseRoutingTableResult {
|
||||||
|
const [routingTable, setRoutingTable] = useState<RoutingTableItem[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
const fetchRoutingTable = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/routing-table')
|
||||||
|
if (!response.ok) throw new Error(`路由接口异常 (${response.status})`)
|
||||||
|
const payload: unknown = await response.json()
|
||||||
|
if (!Array.isArray(payload)) throw new Error('路由接口返回格式异常')
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setRoutingTable(payload as RoutingTableItem[])
|
||||||
|
setError(null)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (mountedRef.current) setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
void fetchRoutingTable()
|
||||||
|
const timer = window.setInterval(() => void fetchRoutingTable(), POLL_INTERVAL_MS)
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
window.clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, [fetchRoutingTable])
|
||||||
|
|
||||||
|
const refresh = useCallback(() => void fetchRoutingTable(), [fetchRoutingTable])
|
||||||
|
return { routingTable, isLoading, error, refresh }
|
||||||
|
}
|
||||||
+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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { Check, Copy, GitFork, RefreshCw, Search } from "lucide-react"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { useRoutingTable } from "@/hooks/use-routing-table"
|
||||||
|
import type { RoutingTableItem } from "@/types/api"
|
||||||
|
|
||||||
|
type ProtocolFilter = "all" | "direct" | "klalb"
|
||||||
|
|
||||||
|
function protocolName(protocol: RoutingTableItem["protocol"]): string {
|
||||||
|
const value = String(protocol)
|
||||||
|
const normalized = value.toLowerCase()
|
||||||
|
if (normalized === "direct" || value === "0") return "Direct"
|
||||||
|
if (
|
||||||
|
normalized.includes("klalb") ||
|
||||||
|
normalized.includes("srv6") ||
|
||||||
|
value === "1"
|
||||||
|
)
|
||||||
|
return "KLALB SRv6"
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function protocolBadge(protocol: RoutingTableItem["protocol"]) {
|
||||||
|
const name = protocolName(protocol)
|
||||||
|
if (name === "Direct")
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
if (name === "KLALB SRv6")
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-purple-500/10 text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
return <Badge variant="outline">{name}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutingTablePage() {
|
||||||
|
const { routingTable, isLoading, error, refresh } = useRoutingTable()
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [protocolFilter, setProtocolFilter] = useState<ProtocolFilter>("all")
|
||||||
|
const [copiedHop, setCopiedHop] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const counts = useMemo(
|
||||||
|
() => ({
|
||||||
|
total: routingTable.length,
|
||||||
|
direct: routingTable.filter(
|
||||||
|
(route) => protocolName(route.protocol) === "Direct"
|
||||||
|
).length,
|
||||||
|
klalb: routingTable.filter(
|
||||||
|
(route) => protocolName(route.protocol) === "KLALB SRv6"
|
||||||
|
).length,
|
||||||
|
}),
|
||||||
|
[routingTable]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredRoutingTable = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase()
|
||||||
|
return routingTable.filter((route) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!query ||
|
||||||
|
[
|
||||||
|
route.destination,
|
||||||
|
route.nexthop,
|
||||||
|
route.interface,
|
||||||
|
protocolName(route.protocol),
|
||||||
|
].some((value) => String(value).toLowerCase().includes(query))
|
||||||
|
const name = protocolName(route.protocol)
|
||||||
|
const matchesProtocol =
|
||||||
|
protocolFilter === "all" ||
|
||||||
|
(protocolFilter === "direct" && name === "Direct") ||
|
||||||
|
(protocolFilter === "klalb" && name === "KLALB SRv6")
|
||||||
|
return matchesSearch && matchesProtocol
|
||||||
|
})
|
||||||
|
}, [routingTable, searchQuery, protocolFilter])
|
||||||
|
|
||||||
|
const copyNextHop = async (nextHop: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(nextHop)
|
||||||
|
setCopiedHop(nextHop)
|
||||||
|
window.setTimeout(
|
||||||
|
() => setCopiedHop((current) => (current === nextHop ? null : current)),
|
||||||
|
1500
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
setCopiedHop(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterOptions: { value: ProtocolFilter; label: string }[] = [
|
||||||
|
{ value: "all", label: "全部" },
|
||||||
|
{ value: "direct", label: "直连" },
|
||||||
|
{ value: "klalb", label: "KLALB SRv6" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col gap-5 p-6">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||||
|
<GitFork className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold tracking-tight">SRv6 路由表</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
实时查看当前 IPv6 转发路径与下一跳。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 sm:ml-13 lg:ml-0">
|
||||||
|
<Badge variant="outline">全部 {counts.total}</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
直连 {counts.direct}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-purple-500/10 text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
KLALB SRv6 {counts.klalb}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="relative w-full max-w-sm">
|
||||||
|
<Search className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="搜索目的地址、下一跳、接口或协议"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center rounded-lg border bg-background p-0.5 shadow-2xs">
|
||||||
|
{filterOptions.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
variant={
|
||||||
|
protocolFilter === option.value ? "secondary" : "ghost"
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setProtocolFilter(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
title="刷新路由表"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
data-icon="inline-start"
|
||||||
|
className={isLoading ? "animate-spin" : ""}
|
||||||
|
/>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
路由数据更新失败:{error},当前显示最近一次成功数据。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{isLoading && routingTable.length === 0 ? (
|
||||||
|
<div className="border-y py-12 text-center text-sm text-muted-foreground">
|
||||||
|
正在加载路由表...
|
||||||
|
</div>
|
||||||
|
) : filteredRoutingTable.length === 0 ? (
|
||||||
|
<div className="border-y py-12 text-center">
|
||||||
|
<p className="text-sm font-medium">未找到匹配的路由</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
调整搜索条件或等待路由表更新。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border bg-card shadow-2xs">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>目的地址</TableHead>
|
||||||
|
<TableHead>协议</TableHead>
|
||||||
|
<TableHead className="text-right">优先级</TableHead>
|
||||||
|
<TableHead className="text-right">开销</TableHead>
|
||||||
|
<TableHead>标志</TableHead>
|
||||||
|
<TableHead>下一跳</TableHead>
|
||||||
|
<TableHead>出接口</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredRoutingTable.map((route, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={`${route.destination}-${route.nexthop}-${route.interface}-${index}`}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono text-xs font-medium">
|
||||||
|
{route.destination}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{protocolBadge(route.protocol)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-xs">
|
||||||
|
{route.preference}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-xs">
|
||||||
|
{(route.cost / 1_000_000).toFixed(2)} ms
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="font-mono text-[10px]">
|
||||||
|
{route.flag}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{route.nexthop || "--"}
|
||||||
|
</span>
|
||||||
|
{route.nexthop && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
title="复制下一跳"
|
||||||
|
onClick={() => void copyNextHop(route.nexthop)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{copiedHop === route.nexthop ? (
|
||||||
|
<Check
|
||||||
|
data-icon="inline-start"
|
||||||
|
className="text-emerald-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Copy data-icon="inline-start" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>复制下一跳</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{route.interface || "--"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+100
-128
@@ -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'
|
||||||
@@ -87,55 +88,6 @@ function toAddrString(item: unknown): string {
|
|||||||
return String(item)
|
return String(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectionItem {
|
|
||||||
id: string
|
|
||||||
address: string
|
|
||||||
autoConnect: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseConnectionsFromConfig(config: KLALBControllerConfig | null): ConnectionItem[] {
|
|
||||||
if (!config) return []
|
|
||||||
const lineTable = (
|
|
||||||
Array.isArray(config.externalEndpoints)
|
|
||||||
? config.externalEndpoints
|
|
||||||
: Array.isArray(config.openConnections)
|
|
||||||
? config.openConnections
|
|
||||||
: Array.isArray(config.LineTable)
|
|
||||||
? config.LineTable
|
|
||||||
: []
|
|
||||||
).map(toAddrString)
|
|
||||||
const connectTable = (
|
|
||||||
Array.isArray(config.autoConnections)
|
|
||||||
? config.autoConnections
|
|
||||||
: Array.isArray(config.ConnectLineTable)
|
|
||||||
? config.ConnectLineTable
|
|
||||||
: []
|
|
||||||
).map(toAddrString)
|
|
||||||
|
|
||||||
const items: ConnectionItem[] = []
|
|
||||||
// openConnections (autoConnect: false)
|
|
||||||
lineTable.forEach((addr, index) => {
|
|
||||||
if (addr && addr.trim() !== '') {
|
|
||||||
items.push({
|
|
||||||
id: `open-${index}-${addr}`,
|
|
||||||
address: addr,
|
|
||||||
autoConnect: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// autoConnections (autoConnect: true)
|
|
||||||
connectTable.forEach((addr, index) => {
|
|
||||||
if (addr && addr.trim() !== '') {
|
|
||||||
items.push({
|
|
||||||
id: `auto-${index}-${addr}`,
|
|
||||||
address: addr,
|
|
||||||
autoConnect: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsForm({
|
function SettingsForm({
|
||||||
initialConfig,
|
initialConfig,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -162,8 +114,26 @@ function SettingsForm({
|
|||||||
initialConfig.TUNName.trim() !== '')
|
initialConfig.TUNName.trim() !== '')
|
||||||
)
|
)
|
||||||
|
|
||||||
const [connections, setConnections] = useState<ConnectionItem[]>(() =>
|
const [autoConnections, setAutoConnections] = useState<string[]>(() =>
|
||||||
parseConnectionsFromConfig(initialConfig)
|
(
|
||||||
|
Array.isArray(initialConfig.autoConnections)
|
||||||
|
? initialConfig.autoConnections
|
||||||
|
: Array.isArray(initialConfig.ConnectLineTable)
|
||||||
|
? initialConfig.ConnectLineTable
|
||||||
|
: []
|
||||||
|
).map(toAddrString)
|
||||||
|
)
|
||||||
|
|
||||||
|
const [externalEndpoints, setExternalEndpoints] = useState<string[]>(() =>
|
||||||
|
(
|
||||||
|
Array.isArray(initialConfig.externalEndpoints)
|
||||||
|
? initialConfig.externalEndpoints
|
||||||
|
: Array.isArray(initialConfig.openConnections)
|
||||||
|
? initialConfig.openConnections
|
||||||
|
: Array.isArray(initialConfig.LineTable)
|
||||||
|
? initialConfig.LineTable
|
||||||
|
: []
|
||||||
|
).map(toAddrString)
|
||||||
)
|
)
|
||||||
|
|
||||||
const [ntpServers, setNtpServers] = useState<string[]>(() =>
|
const [ntpServers, setNtpServers] = useState<string[]>(() =>
|
||||||
@@ -195,14 +165,12 @@ function SettingsForm({
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const cleanConnections = connections.filter((c) => c.address.trim() !== '')
|
const updatedAutoConnections = autoConnections
|
||||||
// 互斥分离:选中自动连接的进 autoConnections,未选中的进 externalEndpoints
|
.filter((address) => address.trim() !== '')
|
||||||
const updatedExternalEndpoints = cleanConnections
|
.map((address) => address.trim())
|
||||||
.filter((c) => !c.autoConnect)
|
const updatedExternalEndpoints = externalEndpoints
|
||||||
.map((c) => c.address.trim())
|
.filter((address) => address.trim() !== '')
|
||||||
const updatedAutoConnections = cleanConnections
|
.map((address) => address.trim())
|
||||||
.filter((c) => c.autoConnect)
|
|
||||||
.map((c) => c.address.trim())
|
|
||||||
|
|
||||||
// 保存时同时携带新键名与历史别名,兼容新旧后端
|
// 保存时同时携带新键名与历史别名,兼容新旧后端
|
||||||
const denyQuery =
|
const denyQuery =
|
||||||
@@ -247,34 +215,6 @@ function SettingsForm({
|
|||||||
await onSave(updatedConfig as KLALBControllerConfig)
|
await onSave(updatedConfig as KLALBControllerConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Dynamic Connection Items Handlers ---
|
|
||||||
const addConnection = () => {
|
|
||||||
setConnections((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: `conn-${Date.now()}`,
|
|
||||||
address: '',
|
|
||||||
autoConnect: true,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateConnection = (
|
|
||||||
index: number,
|
|
||||||
field: 'address' | 'autoConnect',
|
|
||||||
value: string | boolean
|
|
||||||
) => {
|
|
||||||
setConnections((prev) => {
|
|
||||||
const next = [...prev]
|
|
||||||
next[index] = { ...next[index], [field]: value }
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeConnection = (index: number) => {
|
|
||||||
setConnections((prev) => prev.filter((_, i) => i !== index))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Generic Dynamic List Handlers ---
|
// --- Generic Dynamic List Handlers ---
|
||||||
const addListItem = (setter: React.Dispatch<React.SetStateAction<string[]>>) => {
|
const addListItem = (setter: React.Dispatch<React.SetStateAction<string[]>>) => {
|
||||||
setter((prev) => [...prev, ''])
|
setter((prev) => [...prev, ''])
|
||||||
@@ -411,15 +351,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>
|
||||||
@@ -686,80 +627,111 @@ function SettingsForm({
|
|||||||
|
|
||||||
{/* Tab 3: 连接与同步列表 (Lists) */}
|
{/* Tab 3: 连接与同步列表 (Lists) */}
|
||||||
<TabsContent value="lists" className="m-0 flex flex-col gap-6">
|
<TabsContent value="lists" className="m-0 flex flex-col gap-6">
|
||||||
{/* 1. Connections List (with Auto Connect toggle) */}
|
{/* 1. Automatic Connection Endpoints */}
|
||||||
<Card className="shadow-2xs">
|
<Card className="shadow-2xs">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">连接列表</CardTitle>
|
<CardTitle className="text-base">与以下端点自动建立连接</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>本节点启动时主动连接的远端端点</CardDescription>
|
||||||
配置开放的 Socket 连接地址,开启「自动连接」将作为启动自动连接目标,未开启则作为对外开放连接
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={addConnection}
|
onClick={() => addListItem(setAutoConnections)}
|
||||||
>
|
>
|
||||||
<Plus data-icon="inline-start" />
|
<Plus data-icon="inline-start" />
|
||||||
添加连接
|
添加端点
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<FieldGroup className="gap-3">
|
<FieldGroup className="gap-2">
|
||||||
{connections.length === 0 ? (
|
{autoConnections.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed p-6 text-center text-xs text-muted-foreground">
|
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||||
暂未配置连接条目,请点击右上角「添加连接」
|
未配置自动连接端点
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
connections.map((item, idx) => (
|
autoConnections.map((endpoint, idx) => (
|
||||||
<div
|
<div key={idx} className="flex items-center gap-2">
|
||||||
key={item.id}
|
|
||||||
className="flex flex-col gap-3 rounded-lg border bg-card p-3 shadow-2xs sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 items-center gap-2">
|
|
||||||
<Input
|
<Input
|
||||||
className="flex-1 font-mono text-xs"
|
className="flex-1 font-mono text-xs"
|
||||||
placeholder="tcp://kne01.yoyo250.fun:4565 或 udp://..."
|
placeholder="tcp://kne01.yoyo250.fun:4565 或 udp://..."
|
||||||
value={item.address}
|
value={endpoint}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateConnection(idx, 'address', e.target.value)
|
updateListItem(idx, e.target.value, setAutoConnections)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center justify-end gap-4">
|
|
||||||
<Field orientation="horizontal" className="flex items-center gap-2">
|
|
||||||
<FieldLabel htmlFor={`auto-conn-${item.id}`} className="cursor-pointer text-xs text-muted-foreground">
|
|
||||||
自动连接
|
|
||||||
</FieldLabel>
|
|
||||||
<Switch
|
|
||||||
id={`auto-conn-${item.id}`}
|
|
||||||
checked={item.autoConnect}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateConnection(idx, 'autoConnect', checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
onClick={() => removeConnection(idx)}
|
onClick={() => removeListItem(idx, setAutoConnections)}
|
||||||
title="删除该连接"
|
title="删除该端点"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 2. NTP Server List */}
|
{/* 2. Published External Endpoints */}
|
||||||
|
<Card className="shadow-2xs">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">本机外部端点</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
本节点对外发布且可被其他节点访问的端点,不是远端连接目标
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => addListItem(setExternalEndpoints)}
|
||||||
|
>
|
||||||
|
<Plus data-icon="inline-start" />
|
||||||
|
添加端点
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup className="gap-2">
|
||||||
|
{externalEndpoints.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||||
|
未配置本机外部端点
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
externalEndpoints.map((endpoint, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
className="flex-1 font-mono text-xs"
|
||||||
|
placeholder="tcp://本机公网地址:4565 或 udp://..."
|
||||||
|
value={endpoint}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateListItem(idx, e.target.value, setExternalEndpoints)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeListItem(idx, setExternalEndpoints)}
|
||||||
|
title="删除该端点"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 3. NTP Server List */}
|
||||||
<Card className="shadow-2xs">
|
<Card className="shadow-2xs">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+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>
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-5
@@ -64,7 +64,7 @@ export interface SSEEventData {
|
|||||||
links: RemoteLinkItem[]
|
links: RemoteLinkItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteItemData {
|
export interface RoutingTableItem {
|
||||||
destination: string
|
destination: string
|
||||||
protocol: number
|
protocol: number
|
||||||
preference: number
|
preference: number
|
||||||
@@ -94,10 +94,13 @@ export interface TopologyData {
|
|||||||
edges: TopologyEdge[]
|
edges: TopologyEdge[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkInterfaceInfo {
|
export interface NodeInfoDetail {
|
||||||
name: string
|
address: string
|
||||||
displayName: string
|
isSelf: boolean
|
||||||
addresses: string[]
|
deviceName: string
|
||||||
|
deviceDescription: string
|
||||||
|
reachable?: boolean
|
||||||
|
openLines?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KLALBControllerConfig {
|
export interface KLALBControllerConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user