🎉 init: impl dashboard overview
This commit is contained in:
@@ -3,3 +3,5 @@ packages: []
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
msw: false
|
||||
|
||||
registry: https://registry.npmjs.org/
|
||||
+54
-13
@@ -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<NavTab>('dashboard')
|
||||
const { status, links, isConnected, reconnectAll, refreshStatus } = useKlalbSSE()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh p-6">
|
||||
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
|
||||
<div>
|
||||
<h1 className="font-medium">Project ready!</h1>
|
||||
<p>You may now add components and start building.</p>
|
||||
<p>We've already added the button component for you.</p>
|
||||
<Button className="mt-2">Button</Button>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
(Press <kbd>d</kbd> to toggle dark mode)
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
currentTab={currentTab}
|
||||
onSelectTab={setCurrentTab}
|
||||
onlineDevices={status?.onlineDevices}
|
||||
linksCount={links.length}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
<SidebarInset className="flex flex-col">
|
||||
<AppHeader
|
||||
status={status}
|
||||
isConnected={isConnected}
|
||||
onReconnectAll={reconnectAll}
|
||||
onRefresh={refreshStatus}
|
||||
/>
|
||||
|
||||
<main className="flex flex-1 flex-col overflow-y-auto">
|
||||
{currentTab === 'dashboard' && (
|
||||
<DashboardPage
|
||||
status={status}
|
||||
links={links}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentTab !== 'dashboard' && (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<Card className="max-w-md text-center">
|
||||
<CardHeader>
|
||||
<CardTitle className="capitalize">{currentTab} 模块</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
该模块即将实现。当前可查看「仪表盘总览」获取实时指标监控。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean>
|
||||
onRefresh: () => Promise<void>
|
||||
}
|
||||
|
||||
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 (
|
||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center justify-between gap-2 border-b bg-background/95 px-4 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" />
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-mono font-semibold text-foreground">
|
||||
{status?.address || status?.deviceName || 'KLALB SRv6 节点'}
|
||||
</span>
|
||||
{status?.version && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
v{status.version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReconnect}
|
||||
disabled={isReconnecting}
|
||||
title="立即向所有远端线路重新发起连接握手"
|
||||
>
|
||||
{reconnectSuccess ? (
|
||||
<Check data-icon="inline-start" className="text-emerald-500" />
|
||||
) : (
|
||||
<Unplug
|
||||
data-icon="inline-start"
|
||||
className={isReconnecting ? 'animate-spin' : ''}
|
||||
/>
|
||||
)}
|
||||
{isReconnecting ? '重连中...' : reconnectSuccess ? '已发送' : '全网重连'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleManualRefresh}
|
||||
disabled={isRefreshing}
|
||||
title="手动刷新状态"
|
||||
>
|
||||
<RefreshCw className={isRefreshing ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={toggleTheme}
|
||||
title="切换深色/浅色主题 (快捷键: d)"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Moon />
|
||||
) : theme === 'light' ? (
|
||||
<Sun />
|
||||
) : (
|
||||
<Laptop />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="border-b border-sidebar-border p-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Layers className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">KLALB SRv6</span>
|
||||
<span className="truncate text-xs text-muted-foreground">多WAN汇聚网络</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>网络监控与管理</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = currentTab === item.id
|
||||
return (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
tooltip={item.title}
|
||||
onClick={() => onSelectTab(item.id)}
|
||||
>
|
||||
<Icon data-icon="inline-start" />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
{item.badge && (
|
||||
<SidebarMenuBadge>
|
||||
<Badge
|
||||
variant={isActive ? 'default' : 'secondary'}
|
||||
className="px-1.5 py-0 text-[10px]"
|
||||
>
|
||||
{item.badge}
|
||||
</Badge>
|
||||
</SidebarMenuBadge>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="border-t border-sidebar-border p-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
tooltip={isConnected ? 'SSE 实时同步正常' : 'SSE 连接断开'}
|
||||
className="justify-start"
|
||||
>
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full shrink-0 ${
|
||||
isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-rose-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="flex-1 truncate text-xs text-muted-foreground">
|
||||
{isConnected ? 'SSE 实时流已连接' : '实时连接断开'}
|
||||
</span>
|
||||
<Activity className="size-3.5 text-muted-foreground shrink-0 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>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 (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -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<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>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 (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -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 (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -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 (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -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 <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -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<SidebarContextProps | null>(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<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-label",
|
||||
sidebar: "group-label",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-action",
|
||||
sidebar: "group-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
render,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const { isMobile, state } = useSidebar()
|
||||
const comp = useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||
state: {
|
||||
slot: "sidebar-menu-button",
|
||||
sidebar: "menu-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
|
||||
if (!tooltip) {
|
||||
return comp
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
render,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-action",
|
||||
sidebar: "menu-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
render,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a"> &
|
||||
React.ComponentProps<"a"> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-sub-button",
|
||||
sidebar: "menu-sub-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import type { SystemStatus, RemoteLinkItem, SSEEventData } from '@/types/api'
|
||||
|
||||
interface UseKlalbSSEResult {
|
||||
status: SystemStatus | null
|
||||
links: RemoteLinkItem[]
|
||||
isConnected: boolean
|
||||
error: string | null
|
||||
reconnectAll: () => Promise<boolean>
|
||||
refreshStatus: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useKlalbSSE(): UseKlalbSSEResult {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||
const [links, setLinks] = useState<RemoteLinkItem[]>([])
|
||||
const [isConnected, setIsConnected] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/status')
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as SystemStatus
|
||||
setStatus(data)
|
||||
setError(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch status fallback:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reconnectAll = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch('/api/links/reconnect', { method: 'POST' })
|
||||
return res.ok
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger reconnect all:', err)
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let isUnmounted = false
|
||||
|
||||
function connectSSE() {
|
||||
if (isUnmounted) return
|
||||
|
||||
try {
|
||||
const es = new EventSource('/api/events')
|
||||
eventSourceRef.current = es
|
||||
|
||||
es.onopen = () => {
|
||||
if (!isUnmounted) {
|
||||
setIsConnected(true)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
es.onmessage = (event) => {
|
||||
if (isUnmounted) return
|
||||
try {
|
||||
const data = JSON.parse(event.data) as SSEEventData
|
||||
if (data.status) {
|
||||
setStatus(data.status)
|
||||
}
|
||||
if (data.links) {
|
||||
setLinks(data.links)
|
||||
}
|
||||
setIsConnected(true)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE event data:', e)
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
if (isUnmounted) return
|
||||
setIsConnected(false)
|
||||
setError('SSE connection lost, reconnecting...')
|
||||
es.close()
|
||||
eventSourceRef.current = null
|
||||
|
||||
// Fallback poll
|
||||
refreshStatus()
|
||||
|
||||
// Schedule reconnection in 2s
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
connectSSE()
|
||||
}, 2000)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('SSE initialization error:', e)
|
||||
if (!isUnmounted) {
|
||||
setIsConnected(false)
|
||||
setError('Failed to establish event stream')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshStatus()
|
||||
connectSSE()
|
||||
|
||||
return () => {
|
||||
isUnmounted = true
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [refreshStatus])
|
||||
|
||||
return {
|
||||
status,
|
||||
links,
|
||||
isConnected,
|
||||
error,
|
||||
reconnectAll,
|
||||
refreshStatus,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export function formatBytes(bytes?: number, decimals = 2): string {
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes) || bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k))
|
||||
const idx = Math.min(i, sizes.length - 1)
|
||||
return `${parseFloat((bytes / Math.pow(k, idx)).toFixed(dm))} ${sizes[idx]}`
|
||||
}
|
||||
|
||||
export function formatSpeed(bps?: number, decimals = 2): string {
|
||||
if (bps === undefined || bps === null || isNaN(bps) || bps === 0) return '0 B/s'
|
||||
return `${formatBytes(bps, decimals)}/s`
|
||||
}
|
||||
|
||||
export function formatPPS(pps?: number): string {
|
||||
if (pps === undefined || pps === null || isNaN(pps) || pps === 0) return '0 PPS'
|
||||
if (pps >= 1_000_000) {
|
||||
return `${(pps / 1_000_000).toFixed(2)} M PPS`
|
||||
}
|
||||
if (pps >= 1_000) {
|
||||
return `${(pps / 1_000).toFixed(2)} K PPS`
|
||||
}
|
||||
return `${Math.round(pps)} PPS`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间延迟(核心协议与 Java 监控全部输出纳秒 ns)
|
||||
* @param nanoDelay 纳秒 (ns)
|
||||
*/
|
||||
export function formatNanoDelay(nanoDelay?: number): string {
|
||||
if (nanoDelay === undefined || nanoDelay === null || isNaN(nanoDelay) || nanoDelay <= 0) {
|
||||
return '0 ns'
|
||||
}
|
||||
if (nanoDelay >= 1_000_000_000) {
|
||||
return `${(nanoDelay / 1_000_000_000).toFixed(2)} s`
|
||||
}
|
||||
if (nanoDelay >= 1_000_000) {
|
||||
return `${(nanoDelay / 1_000_000).toFixed(2)} ms`
|
||||
}
|
||||
if (nanoDelay >= 1_000) {
|
||||
return `${(nanoDelay / 1_000).toFixed(1)} μs`
|
||||
}
|
||||
return `${Math.round(nanoDelay)} ns`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化背板延迟(以微秒/纳秒为主要显示单位)
|
||||
* @param nanoTime 纳秒 (ns)
|
||||
*/
|
||||
export function formatBackplaneDelay(nanoTime?: number): string {
|
||||
if (nanoTime === undefined || nanoTime === null || isNaN(nanoTime) || nanoTime <= 0) {
|
||||
return '0.0 μs'
|
||||
}
|
||||
if (nanoTime >= 1_000_000) {
|
||||
return `${(nanoTime / 1_000_000).toFixed(2)} ms`
|
||||
}
|
||||
return `${(nanoTime / 1_000).toFixed(1)} μs`
|
||||
}
|
||||
|
||||
export function formatPercent(rate?: number): string {
|
||||
if (rate === undefined || rate === null || isNaN(rate)) return '0.0%'
|
||||
return `${(rate * 100).toFixed(2)}%`
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Copy,
|
||||
Check,
|
||||
Zap,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Server,
|
||||
Radio,
|
||||
ChevronsLeftRightEllipsis,
|
||||
Rocket,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
formatBytes,
|
||||
formatSpeed,
|
||||
formatPPS,
|
||||
formatBackplaneDelay,
|
||||
formatPercent,
|
||||
} from '@/lib/format'
|
||||
import type { SystemStatus, RemoteLinkItem } from '@/types/api'
|
||||
|
||||
interface DashboardPageProps {
|
||||
status: SystemStatus | null
|
||||
links: RemoteLinkItem[]
|
||||
isConnected: boolean
|
||||
}
|
||||
|
||||
export function DashboardPage({ status, links, isConnected }: DashboardPageProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copyToClipboard = (text?: string) => {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const metrics = status?.metrics
|
||||
|
||||
// Calculate link status counts
|
||||
const establishedLinks = links.filter(
|
||||
(l) => l.state === 'ESTABLISHED' || l.stateCode === 1
|
||||
).length
|
||||
const totalLinks = links.length
|
||||
|
||||
// Dynamic loss rate status
|
||||
const lossRate = metrics?.backplaneLossRate ?? 0
|
||||
const ecnRate = metrics?.backplaneECNRate ?? 0
|
||||
const isCongested = lossRate > 0.05 || ecnRate > 0.1
|
||||
const isHealthy = lossRate === 0 && ecnRate === 0
|
||||
|
||||
// TUN device status check
|
||||
const isTunDisabled = !status?.tunName || status.tunName.trim() === ''
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-6 p-6">
|
||||
{/* TUN Disabled Warning Alert */}
|
||||
{isTunDisabled && (
|
||||
<Alert className="border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-50 shadow-2xs">
|
||||
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
<AlertTitle className="font-semibold">TUN 未启用</AlertTitle>
|
||||
<AlertDescription className="text-amber-800 dark:text-amber-200">
|
||||
TUN 设备当前处于禁用状态,流量无法进入系统内核。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 1. Hero Banner: Node Identity & SRv6 Virtual Network Overview */}
|
||||
<Card className="border-border/80 bg-linear-to-r from-card via-card to-secondary/30 shadow-xs">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between">
|
||||
{/* Left: Device Identity */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary shadow-2xs">
|
||||
<Server className="size-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
{status?.deviceName || 'KLALB SRv6 Node'}
|
||||
</h1>
|
||||
<Badge variant={isConnected ? 'default' : 'secondary'}>
|
||||
{isConnected ? '在线运行' : '离线'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status?.deviceDescription || '无节点描述信息'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: IPv6 Address Hero Pill (Left of Online/Lines metrics) */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* IPv6 Address Info Pill (Normal unified style) */}
|
||||
<div className="flex flex-col gap-0.5 rounded-xl border bg-background/60 px-3 py-1.5 shadow-2xs">
|
||||
<div className="flex items-center justify-between gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ChevronsLeftRightEllipsis className="size-3.5" />
|
||||
<span>SID / IPv6 地址</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-4 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => copyToClipboard(status?.address)}
|
||||
title="复制本节点的 SID"
|
||||
>
|
||||
{copied ? <Check className="size-3 text-emerald-500" /> : <Copy className="size-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
{status?.address ? (
|
||||
<span className="font-mono text-sm font-bold text-foreground select-all">
|
||||
{status.address}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-44" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Online devices & Active lines metrics tags */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5 rounded-xl border bg-background/60 px-3 py-1.5 shadow-2xs">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Radio className="size-3.5" />
|
||||
<span>在线节点</span>
|
||||
</div>
|
||||
<span className="font-mono text-sm font-bold text-foreground">
|
||||
{status?.onlineDevices ?? 0}
|
||||
</span>
|
||||
</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 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Zap className="size-3.5" />
|
||||
<span>活跃线路</span>
|
||||
</div>
|
||||
<span className="font-mono text-sm font-bold text-foreground">
|
||||
{establishedLinks} <span className="text-xs font-normal text-muted-foreground">/ {totalLinks}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. Key Metrics Matrix: 4 Primary Cards */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{/* Card 1: Throughput (物理带宽与数据净荷) */}
|
||||
<Card className="flex flex-col justify-between shadow-2xs">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
实时网络吞吐 (Throughput)
|
||||
</CardTitle>
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<ArrowUpDown className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ArrowUp className="size-3.5 text-rose-500" />
|
||||
<span>物理上行:</span>
|
||||
</div>
|
||||
<span className="font-mono text-base font-bold text-foreground">
|
||||
{formatSpeed(metrics?.upSpeed)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ArrowDown className="size-3.5 text-emerald-500" />
|
||||
<span>物理下行:</span>
|
||||
</div>
|
||||
<span className="font-mono text-base font-bold text-foreground">
|
||||
{formatSpeed(metrics?.downSpeed)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between">
|
||||
<span>业务数据上行:</span>
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{formatSpeed(metrics?.dataUpSpeed)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>业务数据下行:</span>
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{formatSpeed(metrics?.dataDownSpeed)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span>峰值记录 (上/下):</span>
|
||||
<span className="font-mono">
|
||||
{formatSpeed(metrics?.upSpeedMax, 1)} / {formatSpeed(metrics?.downSpeedMax, 1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Packet Rate (PPS 包速率) */}
|
||||
<Card className="flex flex-col justify-between shadow-2xs">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
包转发率 (Packet Rate)
|
||||
</CardTitle>
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
<Activity className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ArrowUp className="size-3.5 text-rose-500" />
|
||||
<span>上行 PPS:</span>
|
||||
</div>
|
||||
<span className="font-mono text-base font-bold text-foreground">
|
||||
{formatPPS(metrics?.upPPS)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ArrowDown className="size-3.5 text-emerald-500" />
|
||||
<span>下行 PPS:</span>
|
||||
</div>
|
||||
<span className="font-mono text-base font-bold text-foreground">
|
||||
{formatPPS(metrics?.downPPS)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between">
|
||||
<span>业务包上行:</span>
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{formatPPS(metrics?.dataUpPPS)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>业务包下行:</span>
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{formatPPS(metrics?.dataDownPPS)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span>峰值记录 (上/下):</span>
|
||||
<span className="font-mono">
|
||||
{formatPPS(metrics?.upPPSMax)} / {formatPPS(metrics?.downPPSMax)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 3: Backplane & Latency (背板转发与时延) */}
|
||||
<Card className="flex flex-col justify-between shadow-2xs">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
背板转发与延迟 (Backplane)
|
||||
</CardTitle>
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<Zap className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">背板处理延迟:</span>
|
||||
<span className="font-mono text-base font-bold text-foreground">
|
||||
{formatBackplaneDelay(metrics?.backplaneDelay)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">背板转发吞吐:</span>
|
||||
<span className="font-mono text-base font-bold text-foreground">
|
||||
{formatPPS(metrics?.backplanePPS)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between">
|
||||
<span>背板最高包速率:</span>
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{formatPPS(metrics?.backplanePPSMax)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>调度策略:</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{status?.performanceStrategy || 'multifill'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-[11px]">
|
||||
<span>TUN 设备:</span>
|
||||
{isTunDisabled ? (
|
||||
<span className="flex items-center gap-1 font-mono font-medium text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="size-3" />
|
||||
禁用 / 未加载
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-foreground">{status?.tunName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 4: Network Quality & Congestion (网络健康与质量) */}
|
||||
<Card className="flex flex-col justify-between shadow-2xs">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
链路质量与拥塞 (Quality)
|
||||
</CardTitle>
|
||||
<div
|
||||
className={`flex size-8 items-center justify-center rounded-lg ${
|
||||
isCongested
|
||||
? 'bg-rose-500/10 text-rose-500'
|
||||
: isHealthy
|
||||
? 'bg-emerald-500/10 text-emerald-500'
|
||||
: 'bg-amber-500/10 text-amber-500'
|
||||
}`}
|
||||
>
|
||||
{isCongested ? (
|
||||
<AlertTriangle className="size-4" />
|
||||
) : (
|
||||
<Rocket className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">背板丢包率:</span>
|
||||
<span
|
||||
className={`font-mono text-base font-bold ${
|
||||
lossRate > 0.02 ? 'text-rose-500' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{formatPercent(metrics?.backplaneLossRate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">ECN 标记率:</span>
|
||||
<span
|
||||
className={`font-mono text-base font-bold ${
|
||||
ecnRate > 0.05 ? 'text-amber-500' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{formatPercent(metrics?.backplaneECNRate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>网络状态评级:</span>
|
||||
<Badge
|
||||
variant={
|
||||
isCongested
|
||||
? 'destructive'
|
||||
: isHealthy
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className="px-2 py-0 text-[10px]"
|
||||
>
|
||||
{isCongested ? '拥塞警告' : isHealthy ? '极佳 (0 丢包)' : '轻度抖动'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>拥塞控制算法:</span>
|
||||
<Badge variant="outline" className="px-1.5 py-0 font-mono text-[11px]">
|
||||
{status?.congestionAlgorithm || 'BBR'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 3. Cumulative Traffic & Network Summary Cards */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Cumulative Traffic */}
|
||||
<Card className="lg:col-span-2 shadow-2xs">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">累计数据流量传输</CardTitle>
|
||||
<CardDescription>
|
||||
自系统启动以来所有多 WAN 线路汇总的传输量与有效净荷
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">累计物理发送</span>
|
||||
<ArrowUp className="size-4 text-rose-500" />
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold tracking-tight">
|
||||
{formatBytes(metrics?.upTraffic)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
业务净荷: {formatBytes(metrics?.dataUpTraffic)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">累计物理接收</span>
|
||||
<ArrowDown className="size-4 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold tracking-tight">
|
||||
{formatBytes(metrics?.downTraffic)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
业务净荷: {formatBytes(metrics?.dataDownTraffic)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Distributed Clock & System Info */}
|
||||
<Card className="shadow-2xs">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">高精度分布式时钟</CardTitle>
|
||||
<CardDescription>SRv6 网络同步参考时钟源</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1 rounded-lg border bg-muted/40 p-3">
|
||||
<span className="text-xs text-muted-foreground">时钟时间 (HighAccuracyClock)</span>
|
||||
<span className="font-mono text-sm font-semibold">
|
||||
{status?.timeStr || '正在初始化...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between">
|
||||
<span>频偏 PPM:</span>
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{status?.timePpm !== undefined ? `${status.timePpm.toFixed(3)} ppm` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 4. Alert if not connected */}
|
||||
{!isConnected && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>与后端服务断开连接</AlertTitle>
|
||||
<AlertDescription>
|
||||
未收到 SSE 实时事件流,正在尝试自动重新建立连接...
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
+9
-2
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user