From e3f575096b42101435aaec71a6a92e2b53f56863 Mon Sep 17 00:00:00 2001 From: SerinaNya <34389622+SerinaNya@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:01:41 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20connections=20page,?= =?UTF-8?q?=20rename=20dashboard=20page=20to=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 54 +- .../connections/connection-action-menu.tsx | 82 +++ src/components/layout/app-sidebar.tsx | 14 +- src/components/ui/dialog.tsx | 158 +++++ src/components/ui/dropdown-menu.tsx | 268 ++++++++ src/components/ui/table.tsx | 114 ++++ src/hooks/use-hash-route.ts | 59 ++ src/hooks/use-klalb-sse.ts | 96 ++- src/lib/format.ts | 5 +- src/pages/connections.tsx | 584 ++++++++++++++++++ src/pages/{dashboard.tsx => overview.tsx} | 16 +- 11 files changed, 1416 insertions(+), 34 deletions(-) create mode 100644 src/components/connections/connection-action-menu.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/hooks/use-hash-route.ts create mode 100644 src/pages/connections.tsx rename src/pages/{dashboard.tsx => overview.tsx} (97%) diff --git a/src/App.tsx b/src/App.tsx index 7692336..06d6606 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,44 @@ -import { useState } from 'react' +import { useMemo } from 'react' import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar' import { TooltipProvider } from '@/components/ui/tooltip' -import { AppSidebar, type NavTab } from '@/components/layout/app-sidebar' +import { AppSidebar } from '@/components/layout/app-sidebar' import { AppHeader } from '@/components/layout/app-header' -import { DashboardPage } from '@/pages/dashboard' +import { OverviewPage } from '@/pages/overview' +import { ConnectionsPage } from '@/pages/connections' import { useKlalbSSE } from '@/hooks/use-klalb-sse' +import { useHashRoute } from '@/hooks/use-hash-route' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' export function App() { - const [currentTab, setCurrentTab] = useState('dashboard') - const { status, links, isConnected, reconnectAll, refreshStatus } = useKlalbSSE() + const { currentTab, navigate } = useHashRoute() + const { + status, + links, + isConnected, + reconnectAll, + refreshStatus, + addLink, + removeLink, + executeLinkAction, + } = useKlalbSSE() + + const safeLinks = Array.isArray(links) ? links : [] + + const establishedLinksCount = useMemo(() => { + return safeLinks.filter( + (l) => l && (l.stateCode === 2 || (l.state && l.state.toLowerCase().includes('up')) || l.state === 'ESTABLISHED') + ).length + }, [safeLinks]) return ( @@ -30,15 +50,25 @@ export function App() { />
- {currentTab === 'dashboard' && ( - )} - {currentTab !== 'dashboard' && ( + {currentTab === 'connections' && ( + + )} + + {currentTab !== 'overview' && currentTab !== 'connections' && (
@@ -46,7 +76,7 @@ export function App() {

- 该模块即将实现。当前可查看「仪表盘总览」获取实时指标监控。 + 该模块即将实现。当前可查看「总览」与「连接」进行实时监控与管理。

diff --git a/src/components/connections/connection-action-menu.tsx b/src/components/connections/connection-action-menu.tsx new file mode 100644 index 0000000..4d2fcfe --- /dev/null +++ b/src/components/connections/connection-action-menu.tsx @@ -0,0 +1,82 @@ +import { + MoreVertical, + RotateCcw, + Unplug, + Trash2, + Copy, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import type { RemoteLinkItem } from '@/types/api' + +interface ConnectionActionMenuProps { + link: RemoteLinkItem + onLinkAction: ( + address: string, + action: 'reconnect' | 'disconnect' | 'remove' + ) => Promise<{ success: boolean; message?: string }> + onRemoveLink: (address: string) => Promise<{ success: boolean; message?: string }> + onCopyText: (text: string) => void +} + +export function ConnectionActionMenu({ + link, + onLinkAction, + onRemoveLink, + onCopyText, +}: ConnectionActionMenuProps) { + const targetAddress = link.socketAddress || link.name + + return ( + + + + + } + /> + + onLinkAction(link.name, 'reconnect')} + > + + 立即重连 + + onLinkAction(link.name, 'disconnect')} + > + + 断开连接 + + + onCopyText(targetAddress)} + > + + 复制 Socket 地址 + + {link.vaddr && ( + onCopyText(link.vaddr!)}> + + 复制远端 SID + + )} + + onRemoveLink(targetAddress)} + > + + 删除连接 + + + + ) +} diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 5dc18e0..2320916 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -24,13 +24,14 @@ import { } from '@/components/ui/sidebar' import { Badge } from '@/components/ui/badge' -export type NavTab = 'dashboard' | 'links' | 'routes' | 'topology' | 'interfaces' | 'settings' +export type NavTab = 'overview' | 'connections' | 'routes' | 'topology' | 'interfaces' | 'settings' interface AppSidebarProps { currentTab: NavTab onSelectTab: (tab: NavTab) => void onlineDevices?: number linksCount?: number + establishedLinksCount?: number isConnected?: boolean } @@ -39,20 +40,21 @@ export function AppSidebar({ onSelectTab, onlineDevices = 0, linksCount = 0, + establishedLinksCount = 0, isConnected = false, }: AppSidebarProps) { const navItems = [ { - id: 'dashboard' as NavTab, - title: '仪表盘总览', + id: 'overview' as NavTab, + title: '总览', icon: LayoutDashboard, badge: isConnected ? 'Live' : undefined, }, { - id: 'links' as NavTab, - title: '线路与链路', + id: 'connections' as NavTab, + title: '连接', icon: Network, - badge: linksCount > 0 ? `${linksCount}` : undefined, + badge: linksCount > 0 ? `${establishedLinksCount}/${linksCount}` : undefined, }, { id: 'routes' as NavTab, diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..3fc1dda --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,158 @@ +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + }> + Close + + )} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..9d5ebbd --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,268 @@ +"use client" + +import * as React from "react" +import { Menu as MenuPrimitive } from "@base-ui/react/menu" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick< + MenuPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: MenuPrimitive.Separator.Props) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..ac9585e --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/hooks/use-hash-route.ts b/src/hooks/use-hash-route.ts new file mode 100644 index 0000000..f407a59 --- /dev/null +++ b/src/hooks/use-hash-route.ts @@ -0,0 +1,59 @@ +import { useState, useEffect, useCallback } from 'react' +import type { NavTab } from '@/components/layout/app-sidebar' + +const VALID_TABS: NavTab[] = [ + 'overview', + 'connections', + 'routes', + 'topology', + 'interfaces', + 'settings', +] + +function getTabFromHash(hash: string): NavTab { + const cleanHash = hash.replace(/^#\/?/, '').trim().toLowerCase() + if (!cleanHash || cleanHash === '') { + return 'overview' + } + // 向后兼容旧的 dashboard hash + if (cleanHash === 'dashboard') { + return 'overview' + } + const matchingTab = VALID_TABS.find((tab) => tab === cleanHash) + return matchingTab || 'overview' +} + +export function useHashRoute() { + const [currentTab, setCurrentTabState] = useState(() => + getTabFromHash(window.location.hash) + ) + + useEffect(() => { + const handleHashChange = () => { + const newTab = getTabFromHash(window.location.hash) + setCurrentTabState(newTab) + } + + // If current hash is empty, initialize with #/overview + if (!window.location.hash || window.location.hash === '#' || window.location.hash === '#/') { + window.location.hash = '#/overview' + } else if (window.location.hash === '#/dashboard') { + window.location.hash = '#/overview' + } + + window.addEventListener('hashchange', handleHashChange) + return () => { + window.removeEventListener('hashchange', handleHashChange) + } + }, []) + + const navigate = useCallback((tab: NavTab) => { + window.location.hash = `#/${tab}` + setCurrentTabState(tab) + }, []) + + return { + currentTab, + navigate, + } +} diff --git a/src/hooks/use-klalb-sse.ts b/src/hooks/use-klalb-sse.ts index dc36c86..b999390 100644 --- a/src/hooks/use-klalb-sse.ts +++ b/src/hooks/use-klalb-sse.ts @@ -8,6 +8,12 @@ interface UseKlalbSSEResult { error: string | null reconnectAll: () => Promise refreshStatus: () => Promise + addLink: (address: string) => Promise<{ success: boolean; message?: string }> + removeLink: (address: string) => Promise<{ success: boolean; message?: string }> + executeLinkAction: ( + address: string, + action: 'reconnect' | 'disconnect' | 'remove' + ) => Promise<{ success: boolean; message?: string }> } export function useKlalbSSE(): UseKlalbSSEResult { @@ -20,14 +26,21 @@ export function useKlalbSSE(): UseKlalbSSEResult { const refreshStatus = useCallback(async () => { try { - const res = await fetch('/api/status') - if (res.ok) { - const data = (await res.json()) as SystemStatus + const [statusRes, linksRes] = await Promise.all([ + fetch('/api/status'), + fetch('/api/links'), + ]) + if (statusRes.ok) { + const data = (await statusRes.json()) as SystemStatus setStatus(data) - setError(null) } + if (linksRes.ok) { + const linksData = await linksRes.json() + setLinks(Array.isArray(linksData) ? linksData : []) + } + setError(null) } catch (err) { - console.warn('Failed to fetch status fallback:', err) + console.warn('Failed to fetch status/links fallback:', err) } }, []) @@ -41,6 +54,74 @@ export function useKlalbSSE(): UseKlalbSSEResult { } }, []) + const addLink = useCallback( + async (address: string): Promise<{ success: boolean; message?: string }> => { + try { + const res = await fetch('/api/links', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address: address.trim() }), + }) + const data = await res.json() + if (res.ok && data.success) { + refreshStatus() + return { success: true } + } + return { success: false, message: data.error || '添加连接失败' } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + return { success: false, message: msg } + } + }, + [refreshStatus] + ) + + const removeLink = useCallback( + async (address: string): Promise<{ success: boolean; message?: string }> => { + try { + const res = await fetch( + `/api/links?address=${encodeURIComponent(address.trim())}`, + { method: 'DELETE' } + ) + const data = await res.json() + if (res.ok && data.success) { + refreshStatus() + return { success: true } + } + return { success: false, message: data.error || '移除连接失败' } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + return { success: false, message: msg } + } + }, + [refreshStatus] + ) + + const executeLinkAction = useCallback( + async ( + address: string, + action: 'reconnect' | 'disconnect' | 'remove' + ): Promise<{ success: boolean; message?: string }> => { + try { + const res = await fetch('/api/links/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, action }), + }) + const data = await res.json() + if (res.ok && data.success) { + refreshStatus() + return { success: true } + } + return { success: false, message: data.error || `执行 ${action} 失败` } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + return { success: false, message: msg } + } + }, + [refreshStatus] + ) + useEffect(() => { let isUnmounted = false @@ -65,7 +146,7 @@ export function useKlalbSSE(): UseKlalbSSEResult { if (data.status) { setStatus(data.status) } - if (data.links) { + if (Array.isArray(data.links)) { setLinks(data.links) } setIsConnected(true) @@ -124,5 +205,8 @@ export function useKlalbSSE(): UseKlalbSSEResult { error, reconnectAll, refreshStatus, + addLink, + removeLink, + executeLinkAction, } } diff --git a/src/lib/format.ts b/src/lib/format.ts index 0965b1c..64a436d 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -29,7 +29,10 @@ export function formatPPS(pps?: number): string { * @param nanoDelay 纳秒 (ns) */ export function formatNanoDelay(nanoDelay?: number): string { - if (nanoDelay === undefined || nanoDelay === null || isNaN(nanoDelay) || nanoDelay <= 0) { + if (nanoDelay === undefined || nanoDelay === null || isNaN(nanoDelay)) { + return 'NaN' + } + if (nanoDelay <= 0) { return '0 ns' } if (nanoDelay >= 1_000_000_000) { diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx new file mode 100644 index 0000000..1917c00 --- /dev/null +++ b/src/pages/connections.tsx @@ -0,0 +1,584 @@ +import { ConnectionActionMenu } from '@/components/connections/connection-action-menu' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + formatBytes, + formatNanoDelay, + formatPPS, + formatSpeed, +} from '@/lib/format' +import type { RemoteLinkItem } from '@/types/api' +import { + ArrowDown, + ArrowUp, + Check, + ChevronsLeftRightEllipsis, + Copy, + LayoutGrid, + List, + Network, + Plus, + Search, + Plug, + Unplug +} from "lucide-react" +import { useMemo, useState } from 'react' + +interface ConnectionsPageProps { + links: RemoteLinkItem[] + isConnected: boolean + onAddLink: (address: string) => Promise<{ success: boolean; message?: string }> + onRemoveLink: (address: string) => Promise<{ success: boolean; message?: string }> + onLinkAction: ( + address: string, + action: 'reconnect' | 'disconnect' | 'remove' + ) => Promise<{ success: boolean; message?: string }> +} + +export function ConnectionsPage({ + links, + onAddLink, + onRemoveLink, + onLinkAction, +}: ConnectionsPageProps) { + const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid') + const [searchQuery, setSearchQuery] = useState('') + const [statusFilter, setStatusFilter] = useState<'all' | 'up' | 'down'>('all') + + // Add Dialog State + const [isAddOpen, setIsAddOpen] = useState(false) + const [newAddress, setNewAddress] = useState('') + const [addError, setAddError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Copy Feedback state + const [copiedAddress, setCopiedAddress] = useState(null) + + const copyText = (text: string) => { + navigator.clipboard.writeText(text) + setCopiedAddress(text) + setTimeout(() => setCopiedAddress(null), 2000) + } + + // Summary calculations + const stats = useMemo(() => { + const total = links.length + const up = links.filter((l) => l.stateCode === 2 || l.state.toLowerCase().includes('up') || l.state === 'ESTABLISHED').length + const down = total - up + const totalUpSpeed = links.reduce((acc, l) => acc + (l.upSpeed || 0), 0) + const totalDownSpeed = links.reduce((acc, l) => acc + (l.downSpeed || 0), 0) + return { total, up, down, totalUpSpeed, totalDownSpeed } + }, [links]) + + // Filtered links + const filteredLinks = useMemo(() => { + return links.filter((link) => { + const matchSearch = + searchQuery === '' || + link.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (link.socketAddress && + link.socketAddress.toLowerCase().includes(searchQuery.toLowerCase())) || + (link.vaddr && + link.vaddr.toLowerCase().includes(searchQuery.toLowerCase())) + + const isLinkUp = + link.stateCode === 2 || + link.state.toLowerCase().includes('up') || + link.state === 'ESTABLISHED' + + if (statusFilter === 'up') return matchSearch && isLinkUp + if (statusFilter === 'down') return matchSearch && !isLinkUp + return matchSearch + }) + }, [links, searchQuery, statusFilter]) + + const handleAddSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!newAddress.trim()) { + setAddError('请输入远程连接 Socket 地址') + return + } + setIsSubmitting(true) + setAddError(null) + const res = await onAddLink(newAddress.trim()) + setIsSubmitting(false) + if (res.success) { + setNewAddress('') + setIsAddOpen(false) + } else { + setAddError(res.message || '添加连接失败') + } + } + + const getProtocolBadge = (address?: string) => { + if (!address) return TCP + const lower = address.toLowerCase() + if (lower.startsWith('udp://')) { + return UDP + } + if (lower.startsWith('kltp://')) { + return KLTP + } + return TCP + } + + return ( +
+ {/* 1. Top Summary Banner */} +
+ + +
+ 全部连接 + + {stats.total} + +
+
+ +
+
+
+ + + +
+ 已建立连接 + + {stats.up} + +
+
+ +
+
+
+ + + +
+ 异常/重试中 + + {stats.down} + +
+
+ +
+
+
+ + + +
+ + 实时吞吐 + +
+ + ↑ {formatSpeed(stats.totalUpSpeed, 1)} + + + ↓ {formatSpeed(stats.totalDownSpeed, 1)} + +
+
+
+ +
+
+
+
+ + {/* 2. Control Toolbar */} +
+
+ {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + /> +
+ + {/* Status filter toggle */} +
+ + + +
+
+ + {/* Action buttons & View mode switch */} +
+
+ + +
+ + {/* Add Connection Dialog */} + + + + 添加连接 + + } + /> + +
+ + 添加远程连接 + + 输入远端主机的 Socket 协议地址建立隧道 + + +
+
+ + setNewAddress(e.target.value)} + autoFocus + /> + + 格式示例:tcp://ip:port、 + udp://ip:portkltp://ip:port + +
+ {addError && ( +
+ {addError} +
+ )} +
+ + + + +
+
+
+
+
+ + {/* 3. Main Content: Grid or Table View */} + {filteredLinks.length === 0 ? ( + +
+ +
+ + 未发现匹配的连接 + +

+ 当前没有满足条件的远程连接。您可以点击右上角的「添加连接」来新建与远端节点的链路。 +

+
+ ) : viewMode === "grid" ? ( +
+ {filteredLinks.map((link) => { + const isUp = + link.stateCode === 2 || + link.state.toLowerCase().includes("up") || + link.state === "ESTABLISHED" + const isUnstable = + link.stateCode === 1 || + link.state.toLowerCase().includes("unstable") + + return ( + + +
+
+ +
+
+ {getProtocolBadge(link.socketAddress || link.name)} + + {link.socketAddress || link.name} + +
+
+
+ + {/* Actions dropdown component */} + +
+
+ + + {/* Remote Virtual IPv6 Address */} +
+
+ + 远端 SID: +
+ {link.vaddr ? ( +
+ + {link.vaddr} + + +
+ ) : ( + + 未协商 + + )} +
+ + {/* Realtime Throughput & PPS */} +
+
+
+ + 发送 + + {formatPPS(link.upPPS)} +
+ + {formatSpeed(link.upSpeed)} + +
+ +
+
+ + 接收 + + {formatPPS(link.downPPS)} +
+ + {formatSpeed(link.downSpeed)} + +
+
+ + {/* Delay & Jitter */} +
+
+ 单向时延 (上/下): + + {isUp + ? `${formatNanoDelay(link.upDelay)} / ${formatNanoDelay(link.downDelay)}` + : "NaN / NaN"} + +
+
+ 网络抖动 (Jitter): + + {isUp ? formatNanoDelay(link.upJitter) : "NaN"} + +
+
+ 累计流量 (发/收): + + {formatBytes(link.upTraffic, 1)} /{" "} + {formatBytes(link.downTraffic, 1)} + +
+
+
+
+ ) + })} +
+ ) : ( + /* Table View */ + + + + + 状态 + Socket 地址 + 远端 SID (IPv6) + 实时发送 (↑) + 实时接收 (↓) + 单向时延 + 累计总流量 + 操作 + + + + {filteredLinks.map((link) => { + const isUp = + link.stateCode === 2 || + link.state.toLowerCase().includes("up") || + link.state === "ESTABLISHED" + return ( + + + + + +
+ {getProtocolBadge(link.socketAddress || link.name)} + {link.socketAddress || link.name} +
+
+ + + {link.vaddr || "--"} + + + + + {formatSpeed(link.upSpeed)} + + + ({formatPPS(link.upPPS)}) + + + + + {formatSpeed(link.downSpeed)} + + + ({formatPPS(link.downPPS)}) + + + + {isUp ? formatNanoDelay(link.upDelay) : "NaN"} + + + {formatBytes( + (link.upTraffic || 0) + (link.downTraffic || 0) + )} + + + + +
+ ) + })} +
+
+
+ )} +
+ ) +} diff --git a/src/pages/dashboard.tsx b/src/pages/overview.tsx similarity index 97% rename from src/pages/dashboard.tsx rename to src/pages/overview.tsx index cea7d06..2fec8bc 100644 --- a/src/pages/dashboard.tsx +++ b/src/pages/overview.tsx @@ -34,13 +34,13 @@ import { } from '@/lib/format' import type { SystemStatus, RemoteLinkItem } from '@/types/api' -interface DashboardPageProps { +interface OverviewPageProps { status: SystemStatus | null links: RemoteLinkItem[] isConnected: boolean } -export function DashboardPage({ status, links, isConnected }: DashboardPageProps) { +export function OverviewPage({ status, links, isConnected }: OverviewPageProps) { const [copied, setCopied] = useState(false) const copyToClipboard = (text?: string) => { @@ -145,14 +145,12 @@ export function DashboardPage({ status, links, isConnected }: DashboardPageProps
-
- - 活跃线路 -
- - {establishedLinks} / {totalLinks} - + + 活跃线路
+ + {establishedLinks} / {totalLinks} +