From f090c7481b34a78eb89957b9a5ee4aa0e3be173c Mon Sep 17 00:00:00 2001 From: SerinaNya <34389622+SerinaNya@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:36:27 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20init:=20impl=20dashboard=20overv?= =?UTF-8?q?iew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-workspace.yaml | 2 + src/App.tsx | 69 ++- src/components/layout/app-header.tsx | 119 +++++ src/components/layout/app-sidebar.tsx | 162 ++++++ src/components/ui/alert.tsx | 76 +++ src/components/ui/badge.tsx | 52 ++ src/components/ui/card.tsx | 103 ++++ src/components/ui/input.tsx | 20 + src/components/ui/separator.tsx | 25 + src/components/ui/sheet.tsx | 136 +++++ src/components/ui/sidebar.tsx | 723 ++++++++++++++++++++++++++ src/components/ui/skeleton.tsx | 13 + src/components/ui/tooltip.tsx | 64 +++ src/hooks/use-klalb-sse.ts | 128 +++++ src/hooks/use-mobile.ts | 19 + src/lib/format.ts | 64 +++ src/pages/dashboard.tsx | 496 ++++++++++++++++++ src/types/api.ts | 100 ++++ vite.config.ts | 11 +- 19 files changed, 2366 insertions(+), 16 deletions(-) create mode 100644 src/components/layout/app-header.tsx create mode 100644 src/components/layout/app-sidebar.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/sidebar.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/hooks/use-klalb-sse.ts create mode 100644 src/hooks/use-mobile.ts create mode 100644 src/lib/format.ts create mode 100644 src/pages/dashboard.tsx create mode 100644 src/types/api.ts diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6afd1d4..c33bf3d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,5 @@ packages: [] allowBuilds: esbuild: true msw: false + +registry: https://registry.npmjs.org/ \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 4fddc48..7692336 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,61 @@ -import { Button } from "@/components/ui/button" +import { useState } 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 { AppHeader } from '@/components/layout/app-header' +import { DashboardPage } from '@/pages/dashboard' +import { useKlalbSSE } from '@/hooks/use-klalb-sse' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' export function App() { + const [currentTab, setCurrentTab] = useState('dashboard') + const { status, links, isConnected, reconnectAll, refreshStatus } = useKlalbSSE() + return ( -
-
-
-

Project ready!

-

You may now add components and start building.

-

We've already added the button component for you.

- -
-
- (Press d to toggle dark mode) -
-
-
+ + + + + + +
+ {currentTab === 'dashboard' && ( + + )} + + {currentTab !== 'dashboard' && ( +
+ + + {currentTab} 模块 + + +

+ 该模块即将实现。当前可查看「仪表盘总览」获取实时指标监控。 +

+
+
+
+ )} +
+
+
+
) } diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx new file mode 100644 index 0000000..c536381 --- /dev/null +++ b/src/components/layout/app-header.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react' +import { + RefreshCw, + Sun, + Moon, + Laptop, + Check, + Unplug, +} from 'lucide-react' +import { SidebarTrigger } from '@/components/ui/sidebar' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { useTheme } from '@/components/theme-provider' +import type { SystemStatus } from '@/types/api' + +interface AppHeaderProps { + status: SystemStatus | null + isConnected: boolean + onReconnectAll: () => Promise + onRefresh: () => Promise +} + +export function AppHeader({ + status, + onReconnectAll, + onRefresh, +}: AppHeaderProps) { + const { theme, setTheme } = useTheme() + const [isReconnecting, setIsReconnecting] = useState(false) + const [reconnectSuccess, setReconnectSuccess] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + + const handleReconnect = async () => { + setIsReconnecting(true) + const success = await onReconnectAll() + setIsReconnecting(false) + if (success) { + setReconnectSuccess(true) + setTimeout(() => setReconnectSuccess(false), 2000) + } + } + + const handleManualRefresh = async () => { + setIsRefreshing(true) + await onRefresh() + setIsRefreshing(false) + } + + const toggleTheme = () => { + if (theme === 'dark') setTheme('light') + else if (theme === 'light') setTheme('system') + else setTheme('dark') + } + + return ( +
+
+ + +
+ + {status?.address || status?.deviceName || 'KLALB SRv6 节点'} + + {status?.version && ( + + v{status.version} + + )} +
+
+ +
+ + + + + +
+
+ ) +} diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..5dc18e0 --- /dev/null +++ b/src/components/layout/app-sidebar.tsx @@ -0,0 +1,162 @@ +import { + LayoutDashboard, + Network, + GitFork, + Share2, + Cpu, + Settings, + Activity, + Layers, +} from 'lucide-react' +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, +} from '@/components/ui/sidebar' +import { Badge } from '@/components/ui/badge' + +export type NavTab = 'dashboard' | 'links' | 'routes' | 'topology' | 'interfaces' | 'settings' + +interface AppSidebarProps { + currentTab: NavTab + onSelectTab: (tab: NavTab) => void + onlineDevices?: number + linksCount?: number + isConnected?: boolean +} + +export function AppSidebar({ + currentTab, + onSelectTab, + onlineDevices = 0, + linksCount = 0, + isConnected = false, +}: AppSidebarProps) { + const navItems = [ + { + id: 'dashboard' as NavTab, + title: '仪表盘总览', + icon: LayoutDashboard, + badge: isConnected ? 'Live' : undefined, + }, + { + id: 'links' as NavTab, + title: '线路与链路', + icon: Network, + badge: linksCount > 0 ? `${linksCount}` : undefined, + }, + { + id: 'routes' as NavTab, + title: 'SRv6 路由表', + icon: GitFork, + }, + { + id: 'topology' as NavTab, + title: '网络拓扑', + icon: Share2, + badge: onlineDevices > 0 ? `${onlineDevices}` : undefined, + }, + { + id: 'interfaces' as NavTab, + title: '网卡接口', + icon: Cpu, + }, + { + id: 'settings' as NavTab, + title: '系统配置', + icon: Settings, + }, + ] + + return ( + + + + + +
+ +
+
+ KLALB SRv6 + 多WAN汇聚网络 +
+
+
+
+
+ + + + 网络监控与管理 + + + {navItems.map((item) => { + const Icon = item.icon + const isActive = currentTab === item.id + return ( + + onSelectTab(item.id)} + > + + {item.title} + + {item.badge && ( + + + {item.badge} + + + )} + + ) + })} + + + + + + + + + + + + {isConnected ? 'SSE 实时流已连接' : '实时连接断开'} + + + + + + + +
+ ) +} + diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..1fe3176 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,76 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..4458dae --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..6e1369e --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,25 @@ +"use client" + +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + + ) +} + +export { Separator } diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..13281f5 --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,136 @@ +import * as React from "react" +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..aca53fc --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,723 @@ +"use client" + +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { PanelLeftIcon } from "lucide-react" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + +
+ {children} +
+
+ ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + dir, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( + +
+ {status?.address ? ( + + {status.address} + + ) : ( + + )} +
+ + {/* Online devices & Active lines metrics tags */} +
+
+
+ + 在线节点 +
+ + {status?.onlineDevices ?? 0} + +
+ +
+
+ + 活跃线路 +
+ + {establishedLinks} / {totalLinks} + +
+
+
+
+ + + + {/* 2. Key Metrics Matrix: 4 Primary Cards */} +
+ {/* Card 1: Throughput (物理带宽与数据净荷) */} + + +
+ + 实时网络吞吐 (Throughput) + +
+ +
+
+
+ +
+
+
+ + 物理上行: +
+ + {formatSpeed(metrics?.upSpeed)} + +
+
+
+ + 物理下行: +
+ + {formatSpeed(metrics?.downSpeed)} + +
+
+ + + +
+
+ 业务数据上行: + + {formatSpeed(metrics?.dataUpSpeed)} + +
+
+ 业务数据下行: + + {formatSpeed(metrics?.dataDownSpeed)} + +
+
+ 峰值记录 (上/下): + + {formatSpeed(metrics?.upSpeedMax, 1)} / {formatSpeed(metrics?.downSpeedMax, 1)} + +
+
+
+
+ + {/* Card 2: Packet Rate (PPS 包速率) */} + + +
+ + 包转发率 (Packet Rate) + +
+ +
+
+
+ +
+
+
+ + 上行 PPS: +
+ + {formatPPS(metrics?.upPPS)} + +
+
+
+ + 下行 PPS: +
+ + {formatPPS(metrics?.downPPS)} + +
+
+ + + +
+
+ 业务包上行: + + {formatPPS(metrics?.dataUpPPS)} + +
+
+ 业务包下行: + + {formatPPS(metrics?.dataDownPPS)} + +
+
+ 峰值记录 (上/下): + + {formatPPS(metrics?.upPPSMax)} / {formatPPS(metrics?.downPPSMax)} + +
+
+
+
+ + {/* Card 3: Backplane & Latency (背板转发与时延) */} + + +
+ + 背板转发与延迟 (Backplane) + +
+ +
+
+
+ +
+
+ 背板处理延迟: + + {formatBackplaneDelay(metrics?.backplaneDelay)} + +
+
+ 背板转发吞吐: + + {formatPPS(metrics?.backplanePPS)} + +
+
+ + + +
+
+ 背板最高包速率: + + {formatPPS(metrics?.backplanePPSMax)} + +
+
+ 调度策略: + + {status?.performanceStrategy || 'multifill'} + +
+
+ TUN 设备: + {isTunDisabled ? ( + + + 禁用 / 未加载 + + ) : ( + {status?.tunName} + )} +
+
+
+
+ + {/* Card 4: Network Quality & Congestion (网络健康与质量) */} + + +
+ + 链路质量与拥塞 (Quality) + +
+ {isCongested ? ( + + ) : ( + + )} +
+
+
+ +
+
+ 背板丢包率: + 0.02 ? 'text-rose-500' : 'text-foreground' + }`} + > + {formatPercent(metrics?.backplaneLossRate)} + +
+
+ ECN 标记率: + 0.05 ? 'text-amber-500' : 'text-foreground' + }`} + > + {formatPercent(metrics?.backplaneECNRate)} + +
+
+ + + +
+
+ 网络状态评级: + + {isCongested ? '拥塞警告' : isHealthy ? '极佳 (0 丢包)' : '轻度抖动'} + +
+
+ 拥塞控制算法: + + {status?.congestionAlgorithm || 'BBR'} + +
+
+
+
+
+ + {/* 3. Cumulative Traffic & Network Summary Cards */} +
+ {/* Cumulative Traffic */} + + + 累计数据流量传输 + + 自系统启动以来所有多 WAN 线路汇总的传输量与有效净荷 + + + +
+
+
+ 累计物理发送 + +
+
+ {formatBytes(metrics?.upTraffic)} +
+ + 业务净荷: {formatBytes(metrics?.dataUpTraffic)} + +
+ +
+
+ 累计物理接收 + +
+
+ {formatBytes(metrics?.downTraffic)} +
+ + 业务净荷: {formatBytes(metrics?.dataDownTraffic)} + +
+
+
+
+ + {/* Distributed Clock & System Info */} + + + 高精度分布式时钟 + SRv6 网络同步参考时钟源 + + +
+ 时钟时间 (HighAccuracyClock) + + {status?.timeStr || '正在初始化...'} + +
+ +
+
+ 频偏 PPM: + + {status?.timePpm !== undefined ? `${status.timePpm.toFixed(3)} ppm` : '--'} + +
+
+
+
+
+ + {/* 4. Alert if not connected */} + {!isConnected && ( + + + 与后端服务断开连接 + + 未收到 SSE 实时事件流,正在尝试自动重新建立连接... + + + )} +
+ ) +} diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..c421ba6 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,100 @@ +export interface StatusMetrics { + upSpeed?: number + downSpeed?: number + upSpeedMax?: number + downSpeedMax?: number + upPPS?: number + downPPS?: number + upPPSMax?: number + downPPSMax?: number + upTraffic?: number + downTraffic?: number + dataUpSpeed?: number + dataDownSpeed?: number + dataUpPPS?: number + dataDownPPS?: number + dataUpTraffic?: number + dataDownTraffic?: number + backplaneDelay?: number + backplanePPS?: number + backplanePPSMax?: number + backplaneECNRate?: number + backplaneLossRate?: number +} + +export interface SystemStatus { + status?: string + version?: string + title?: string + address?: string + onlineDevices?: number + deviceName?: string + deviceDescription?: string + tunName?: string + congestionAlgorithm?: string + performanceStrategy?: string + timeStr?: string + timePpm?: number + metrics?: StatusMetrics +} + +export interface RemoteLinkItem { + name: string + state: string + stateCode: number + socketAddress?: string + vaddr?: string + upSpeed?: number + downSpeed?: number + upPPS?: number + downPPS?: number + upTraffic?: number + downTraffic?: number + upDelay?: number + downDelay?: number + upDelayMin?: number + downDelayMin?: number + upJitter?: number + downJitter?: number +} + +export interface SSEEventData { + status: SystemStatus + links: RemoteLinkItem[] +} + +export interface RouteItemData { + destination: string + protocol: number + preference: number + cost: number + flag: number + nexthop: string + interface: string +} + +export interface TopologyNode { + id: string + address: string + compressedAddress?: string + isSelf: boolean + deviceName?: string +} + +export interface TopologyEdge { + source: string + target: string + delay: number + cost: number +} + +export interface TopologyData { + nodes: TopologyNode[] + edges: TopologyEdge[] +} + +export interface NetworkInterfaceInfo { + name: string + displayName: string + addresses: string[] +} diff --git a/vite.config.ts b/vite.config.ts index 79a65e0..d50e40f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,3 @@ -import path from "path" import tailwindcss from "@tailwindcss/vite" import react from "@vitejs/plugin-react" import { defineConfig } from "vite" @@ -8,7 +7,15 @@ export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { - "@": path.resolve(__dirname, "./src"), + "@": import.meta.dirname ? `${import.meta.dirname}/src` : "./src", + }, + }, + server: { + proxy: { + "/api": { + target: "http://127.0.0.1:4665", + changeOrigin: true, + }, }, }, })