From 89474b9e91791b8efe098a13171e6ff962ad7ba1 Mon Sep 17 00:00:00 2001 From: SerinaNya <34389622+SerinaNya@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:19:39 +0800 Subject: [PATCH] feat(dashboard)!: add routing table and split endpoint settings - Add #/routing-table with one-second polling and route filters - Separate auto-connect targets from local external endpoints - Remove the obsolete interfaces navigation module BREAKING CHANGE: #/interfaces is removed. --- src/App.tsx | 7 +- src/components/layout/app-sidebar.tsx | 14 +- src/hooks/use-hash-route.ts | 3 +- src/hooks/use-routing-table.ts | 47 +++++ src/pages/routing-table.tsx | 285 ++++++++++++++++++++++++++ src/pages/settings.tsx | 248 ++++++++++------------ src/types/api.ts | 8 +- 7 files changed, 450 insertions(+), 162 deletions(-) create mode 100644 src/hooks/use-routing-table.ts create mode 100644 src/pages/routing-table.tsx diff --git a/src/App.tsx b/src/App.tsx index 75a5551..555dfce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { OverviewPage } from '@/pages/overview' import { ConnectionsPage } from '@/pages/connections' import { SettingsPage } from '@/pages/settings' import { TopologyPage } from '@/pages/topology' +import { RoutingTablePage } from '@/pages/routing-table' import { useKlalbSSE } from '@/hooks/use-klalb-sse' import { useHashRoute } from '@/hooks/use-hash-route' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -39,7 +40,6 @@ export function App() { } + {currentTab === 'routing-table' && } + {currentTab !== 'overview' && currentTab !== 'connections' && currentTab !== 'settings' && - currentTab !== 'topology' && ( + currentTab !== 'topology' && + currentTab !== 'routing-table' && (
diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index a079014..e7e5ed6 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -3,7 +3,6 @@ import { Network, GitFork, Share2, - Cpu, Settings, Activity, Layers, @@ -24,12 +23,11 @@ import { } from '@/components/ui/sidebar' 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 { currentTab: NavTab onSelectTab: (tab: NavTab) => void - onlineDevices?: number linksCount?: number establishedLinksCount?: number isConnected?: boolean @@ -38,7 +36,6 @@ interface AppSidebarProps { export function AppSidebar({ currentTab, onSelectTab, - onlineDevices = 0, linksCount = 0, establishedLinksCount = 0, isConnected = false, @@ -56,7 +53,7 @@ export function AppSidebar({ badge: linksCount > 0 ? `${establishedLinksCount}/${linksCount}` : undefined, }, { - id: 'routes' as NavTab, + id: 'routing-table' as NavTab, title: 'SRv6 路由表', icon: GitFork, }, @@ -64,12 +61,6 @@ export function AppSidebar({ id: 'topology' as NavTab, title: '网络拓扑', icon: Share2, - badge: onlineDevices > 0 ? `${onlineDevices}` : undefined, - }, - { - id: 'interfaces' as NavTab, - title: '网卡接口', - icon: Cpu, }, { id: 'settings' as NavTab, @@ -160,4 +151,3 @@ export function AppSidebar({ ) } - diff --git a/src/hooks/use-hash-route.ts b/src/hooks/use-hash-route.ts index f407a59..3030e11 100644 --- a/src/hooks/use-hash-route.ts +++ b/src/hooks/use-hash-route.ts @@ -4,9 +4,8 @@ import type { NavTab } from '@/components/layout/app-sidebar' const VALID_TABS: NavTab[] = [ 'overview', 'connections', - 'routes', + 'routing-table', 'topology', - 'interfaces', 'settings', ] diff --git a/src/hooks/use-routing-table.ts b/src/hooks/use-routing-table.ts new file mode 100644 index 0000000..ff7d525 --- /dev/null +++ b/src/hooks/use-routing-table.ts @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 } +} diff --git a/src/pages/routing-table.tsx b/src/pages/routing-table.tsx new file mode 100644 index 0000000..1fad455 --- /dev/null +++ b/src/pages/routing-table.tsx @@ -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 ( + + {name} + + ) + if (name === "KLALB SRv6") + return ( + + {name} + + ) + return {name} +} + +export function RoutingTablePage() { + const { routingTable, isLoading, error, refresh } = useRoutingTable() + const [searchQuery, setSearchQuery] = useState("") + const [protocolFilter, setProtocolFilter] = useState("all") + const [copiedHop, setCopiedHop] = useState(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 ( +
+
+
+
+ +
+
+

SRv6 路由表

+

+ 实时查看当前 IPv6 转发路径与下一跳。 +

+
+
+
+ 全部 {counts.total} + + 直连 {counts.direct} + + + KLALB SRv6 {counts.klalb} + +
+
+
+
+ + setSearchQuery(event.target.value)} + placeholder="搜索目的地址、下一跳、接口或协议" + className="pl-8" + /> +
+
+
+ {filterOptions.map((option) => ( + + ))} +
+ +
+
+ {error && ( + + + 路由数据更新失败:{error},当前显示最近一次成功数据。 + + + )} + {isLoading && routingTable.length === 0 ? ( +
+ 正在加载路由表... +
+ ) : filteredRoutingTable.length === 0 ? ( +
+

未找到匹配的路由

+

+ 调整搜索条件或等待路由表更新。 +

+
+ ) : ( +
+ + + + 目的地址 + 协议 + 优先级 + 开销 + 标志 + 下一跳 + 出接口 + + + + {filteredRoutingTable.map((route, index) => ( + + + {route.destination} + + {protocolBadge(route.protocol)} + + {route.preference} + + + {(route.cost / 1_000_000).toFixed(2)} ms + + + + {route.flag} + + + +
+ + {route.nexthop || "--"} + + {route.nexthop && ( + + void copyNextHop(route.nexthop)} + /> + } + > + <> + {copiedHop === route.nexthop ? ( + + ) : ( + + )} + + + 复制下一跳 + + )} +
+
+ + {route.interface || "--"} + +
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 798ad26..460cbb1 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -88,55 +88,6 @@ function toAddrString(item: unknown): string { 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({ initialConfig, onSave, @@ -163,8 +114,26 @@ function SettingsForm({ initialConfig.TUNName.trim() !== '') ) - const [connections, setConnections] = useState(() => - parseConnectionsFromConfig(initialConfig) + const [autoConnections, setAutoConnections] = useState(() => + ( + Array.isArray(initialConfig.autoConnections) + ? initialConfig.autoConnections + : Array.isArray(initialConfig.ConnectLineTable) + ? initialConfig.ConnectLineTable + : [] + ).map(toAddrString) + ) + + const [externalEndpoints, setExternalEndpoints] = useState(() => + ( + Array.isArray(initialConfig.externalEndpoints) + ? initialConfig.externalEndpoints + : Array.isArray(initialConfig.openConnections) + ? initialConfig.openConnections + : Array.isArray(initialConfig.LineTable) + ? initialConfig.LineTable + : [] + ).map(toAddrString) ) const [ntpServers, setNtpServers] = useState(() => @@ -196,14 +165,12 @@ function SettingsForm({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - const cleanConnections = connections.filter((c) => c.address.trim() !== '') - // 互斥分离:选中自动连接的进 autoConnections,未选中的进 externalEndpoints - const updatedExternalEndpoints = cleanConnections - .filter((c) => !c.autoConnect) - .map((c) => c.address.trim()) - const updatedAutoConnections = cleanConnections - .filter((c) => c.autoConnect) - .map((c) => c.address.trim()) + const updatedAutoConnections = autoConnections + .filter((address) => address.trim() !== '') + .map((address) => address.trim()) + const updatedExternalEndpoints = externalEndpoints + .filter((address) => address.trim() !== '') + .map((address) => address.trim()) // 保存时同时携带新键名与历史别名,兼容新旧后端 const denyQuery = @@ -248,34 +215,6 @@ function SettingsForm({ 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 --- const addListItem = (setter: React.Dispatch>) => { setter((prev) => [...prev, '']) @@ -688,72 +627,50 @@ function SettingsForm({ {/* Tab 3: 连接与同步列表 (Lists) */} - {/* 1. Connections List (with Auto Connect toggle) */} + {/* 1. Automatic Connection Endpoints */}
- 连接列表 - - 配置开放的 Socket 连接地址,开启「自动连接」将作为启动自动连接目标,未开启则作为对外开放连接 - + 与以下端点自动建立连接 + 本节点启动时主动连接的远端端点
- - {connections.length === 0 ? ( -
- 暂未配置连接条目,请点击右上角「添加连接」 + + {autoConnections.length === 0 ? ( +
+ 未配置自动连接端点
) : ( - connections.map((item, idx) => ( -
-
- - updateConnection(idx, 'address', e.target.value) - } - /> -
- -
- - - 自动连接 - - - updateConnection(idx, 'autoConnect', checked) - } - /> - - -
+ autoConnections.map((endpoint, idx) => ( +
+ + updateListItem(idx, e.target.value, setAutoConnections) + } + /> +
)) )} @@ -761,7 +678,60 @@ function SettingsForm({ - {/* 2. NTP Server List */} + {/* 2. Published External Endpoints */} + + +
+ 本机外部端点 + + 本节点对外发布且可被其他节点访问的端点,不是远端连接目标 + +
+ +
+ + + {externalEndpoints.length === 0 ? ( +
+ 未配置本机外部端点 +
+ ) : ( + externalEndpoints.map((endpoint, idx) => ( +
+ + updateListItem(idx, e.target.value, setExternalEndpoints) + } + /> + +
+ )) + )} +
+
+
+ + {/* 3. NTP Server List */}
diff --git a/src/types/api.ts b/src/types/api.ts index 86276cc..3b54c6e 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -64,7 +64,7 @@ export interface SSEEventData { links: RemoteLinkItem[] } -export interface RouteItemData { +export interface RoutingTableItem { destination: string protocol: number preference: number @@ -103,12 +103,6 @@ export interface NodeInfoDetail { openLines?: string[] } -export interface NetworkInterfaceInfo { - name: string - displayName: string - addresses: string[] -} - export interface KLALBControllerConfig { DeviceName?: string DeviceDescription?: string