🎉 init: impl dashboard overview

This commit is contained in:
2026-08-23 17:36:27 +08:00
parent ce5c3c5773
commit f090c7481b
19 changed files with 2366 additions and 16 deletions
+2
View File
@@ -3,3 +3,5 @@ packages: []
allowBuilds:
esbuild: true
msw: false
registry: https://registry.npmjs.org/
+55 -14
View File
@@ -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&apos;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>
</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>
)
}
+119
View File
@@ -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>
)
}
+162
View File
@@ -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>
)
}
+76
View File
@@ -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 }
+52
View File
@@ -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 }
+103
View File
@@ -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,
}
+20
View File
@@ -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 }
+25
View File
@@ -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 }
+136
View File
@@ -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,
}
+723
View File
@@ -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,
}
+13
View File
@@ -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 }
+64
View File
@@ -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 }
+128
View File
@@ -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,
}
}
+19
View File
@@ -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
}
+64
View File
@@ -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)}%`
}
+496
View File
@@ -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>
)
}
+100
View File
@@ -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
View File
@@ -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,
},
},
},
})