feat!(web): migrate UI from SvelteKit to React + shadcn/ui + ReUI
CI / changes (push) Successful in 17s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 17s
CI / release (push) Failing after 2m22s

Web UI полностью переведён с SvelteKit на новый стек: React 19,
TanStack Router/Query/Table/Virtual, shadcn/ui (base-nova) и ReUI
enterprise-компоненты (data-grid, filters, autocomplete). Новый код
разложен по слоям: packages/ui (shadcn-примитивы), apps/web
(роуты, shared-обёртки, ReUI-адаптации).

BREAKING CHANGE: меняется структура и инструментинг фронтенда.

- apps/web/ — новый Vite + React-проект (@evobgp/web), file-based
  роуты TanStack Router; экраны dashboard, modules, monitoring,
  network, operations, schedule, settings, tenant-settings, access,
  directories.
- packages/ui/ — shadcn/ui-примитивы (@evobgp/ui) с общими стилями
  globals.css и cn-утилитой; CLI shadcn запускается из apps/web.
- apps/web/src/components/reui/ — enterprise-паттерны ReUI.
- pnpm workspace (pnpm-workspace.yaml, pnpm-lock.yaml, tsconfig.base.json)
  заменяет npm-проект в web/.
- web/ переименован в web-legacy-svelte/ (архив-референс для миграции);
  импорты оттуда запрещены правилом WEB-22.
- CI (.gitea/workflows/ci.yaml): job web переведён на Node 22 + pnpm 10
  (typecheck/lint/build через pnpm --filter @evobgp/web); пути триггеров
  обновлены под apps/web|packages/ui.
- deploy/docker/evobgp-web/Dockerfile: сборка из корня репозитория,
  pnpm install --frozen-lockfile, выход dist из apps/web/dist.
- .cursor/rules/web-shadcn.mdc, context7-stack.mdc, engineering.mdc,
  AGENTS.md — обновлены под React-стек (WEB-01..WEB-22, DOC-SYNC-06/07).

Проверки WEB-19 локально: typecheck, lint, build — exit 0.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-02 17:59:59 +07:00
co-authored by Cursor
parent db75126bea
commit c144b49acf
417 changed files with 25450 additions and 124 deletions
@@ -0,0 +1,53 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@evobgp/ui/components/alert-dialog'
import type { ReactElement, ReactNode } from 'react'
interface ConfirmDialogProps {
trigger: ReactElement
title: string
description?: ReactNode
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
onConfirm: () => void
}
export function ConfirmDialog({
trigger,
title,
description,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
destructive,
onConfirm,
}: ConfirmDialogProps) {
return (
<AlertDialog>
<AlertDialogTrigger render={trigger} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
interface EmptyStateProps {
title: string
description?: string
icon?: ReactNode
action?: ReactNode
className?: string
}
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
className,
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
@@ -0,0 +1,172 @@
import {
LayoutDashboard,
Boxes,
Network,
Cog,
ListChecks,
Activity,
Settings,
BookText,
KeyRound,
ServerCog,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@evobgp/ui/components/sidebar'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@evobgp/ui/components/breadcrumb'
import { Separator } from '@evobgp/ui/components/separator'
import { Link, useRouterState } from '@tanstack/react-router'
import type { ComponentType, ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
interface NavItem {
to: string
label: string
icon: ComponentType<{ className?: string }>
}
interface NavGroup {
label: string
items: NavItem[]
}
const NAV_GROUPS: NavGroup[] = [
{
label: 'Обзор',
items: [{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }],
},
{
label: 'Маршрутизация',
items: [
{ to: '/modules', label: 'Модули', icon: Boxes },
{ to: '/network', label: 'Сеть', icon: Network },
{ to: '/directories', label: 'Справочники', icon: BookText },
],
},
{
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog },
{ to: '/schedule', label: 'Задачи', icon: ListChecks },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity },
],
},
{
label: 'Система',
items: [
{ to: '/access', label: 'Доступ', icon: KeyRound },
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog },
{ to: '/settings', label: 'Настройки UI', icon: Settings },
],
},
]
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
const PARENT_ROUTE: Record<string, string> = {}
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
return (
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
B
</div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">EvoBGP</span>
<span className="truncate text-xs text-muted-foreground">Control Plane</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
{NAV_GROUPS.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter />
</Sidebar>
<SidebarInset>
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{parentLabel && parentTo ? (
<>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
) : null}
<BreadcrumbItem>
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? activeItem.label}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<ModeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
</SidebarInset>
</SidebarProvider>
)
}
@@ -0,0 +1,19 @@
import { Button } from '@evobgp/ui/components/button'
import { Loader2Icon } from 'lucide-react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
loading?: boolean
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
children: ReactNode
}
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
return (
<Button disabled={disabled || loading} {...props}>
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
{children}
</Button>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Сменить тему</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
import { cn } from '@evobgp/ui/lib/utils'
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react'
import { AlertCircle, RefreshCwIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { EmptyState } from './empty-state'
interface QueryStateProps<T> {
data: T | undefined
isLoading: boolean
isError: boolean
error?: unknown
empty?: boolean
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRetry?: () => void
skeleton?: ReactNode
children: (data: T) => ReactNode
}
export function QueryState<T>({
data,
isLoading,
isError,
error,
empty,
emptyTitle = 'Нет данных',
emptyDescription,
emptyAction,
onRetry,
skeleton,
children,
}: QueryStateProps<T>) {
if (isLoading) {
return <>{skeleton ?? <DefaultSkeleton />}</>
}
if (isError) {
return (
<EmptyState
icon={<AlertCircle className="size-8" />}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
)
}
if (empty || data == null) {
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
}
return <>{children(data)}</>
}
function DefaultSkeleton() {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
</div>
)
}
@@ -0,0 +1,343 @@
"use client"
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
import { ScrollArea } from "@evobgp/ui/components/scroll-area"
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
const inputVariants = cva(
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
{
variants: {
size: {
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
default:
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
},
},
defaultVariants: {
size: "default",
},
}
)
const Autocomplete = AutocompletePrimitive.Root
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
return (
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
)
}
function AutocompleteInput({
className,
size = "default",
showClear = false,
showTrigger = false,
...props
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
VariantProps<typeof inputVariants> & {
showClear?: boolean
showTrigger?: boolean
}) {
return (
<div className="relative w-full">
<AutocompletePrimitive.Input
data-slot="autocomplete-input"
data-size={size}
className={cn(inputVariants({ size }), className)}
{...props}
/>
{showTrigger && <AutocompleteTrigger />}
{showClear && <AutocompleteClear />}
</div>
)
}
function AutocompleteStatus({
className,
...props
}: AutocompletePrimitive.Status.Props) {
return (
<AutocompletePrimitive.Status
data-slot="autocomplete-status"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
return (
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
)
}
function AutocompleteBackdrop({
...props
}: AutocompletePrimitive.Backdrop.Props) {
return (
<AutocompletePrimitive.Backdrop
data-slot="autocomplete-backdrop"
{...props}
/>
)
}
function AutocompletePositioner({
className,
...props
}: AutocompletePrimitive.Positioner.Props) {
return (
<AutocompletePrimitive.Positioner
data-slot="autocomplete-positioner"
className={cn("z-50 outline-none", className)}
{...props}
/>
)
}
function AutocompleteList({
className,
scrollAreaClassName,
...props
}: AutocompletePrimitive.List.Props & {
scrollAreaClassName?: string
scrollFade?: boolean
scrollbarGutter?: boolean
}) {
return (
<ScrollArea
className={cn(
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
scrollAreaClassName
)}
>
<AutocompletePrimitive.List
data-slot="autocomplete-list"
className={cn(
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
className
)}
{...props}
/>
</ScrollArea>
)
}
function AutocompleteCollection({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
return (
<AutocompletePrimitive.Collection
data-slot="autocomplete-collection"
{...props}
/>
)
}
function AutocompleteRow({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
return (
<AutocompletePrimitive.Row
data-slot="autocomplete-row"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function AutocompleteItem({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
return (
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
className
)}
{...props}
/>
)
}
export interface AutocompleteContentProps extends React.ComponentProps<
typeof AutocompletePrimitive.Popup
> {
align?: AutocompletePrimitive.Positioner.Props["align"]
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
side?: AutocompletePrimitive.Positioner.Props["side"]
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
showBackdrop?: boolean
}
function AutocompleteContent({
className,
children,
showBackdrop = false,
align = "start",
sideOffset = 4,
alignOffset = 0,
side = "bottom",
anchor,
...props
}: AutocompleteContentProps) {
return (
<AutocompletePortal>
{showBackdrop && <AutocompleteBackdrop />}
<AutocompletePositioner
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
side={side}
anchor={anchor}
>
<div className="relative flex max-h-full">
<AutocompletePrimitive.Popup
data-slot="autocomplete-popup"
className={cn(
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
className
)}
{...props}
>
{children}
</AutocompletePrimitive.Popup>
</div>
</AutocompletePositioner>
</AutocompletePortal>
)
}
function AutocompleteGroup({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
return (
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
)
}
function AutocompleteGroupLabel({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
return (
<AutocompletePrimitive.GroupLabel
data-slot="autocomplete-group-label"
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
className
)}
{...props}
/>
)
}
function AutocompleteEmpty({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
return (
<AutocompletePrimitive.Empty
data-slot="autocomplete-empty"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompleteClear({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
return (
<AutocompletePrimitive.Clear
data-slot="autocomplete-clear"
className={cn(
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
className
)}
{...props}
>
<XIcon className="size-4" />
</AutocompletePrimitive.Clear>
)
}
function AutocompleteTrigger({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
return (
<AutocompletePrimitive.Trigger
data-slot="autocomplete-trigger"
className={cn(
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
className
)}
{...props}
>
<ChevronsUpDownIcon className="size-4 opacity-70" />
</AutocompletePrimitive.Trigger>
)
}
function AutocompleteArrow({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
return (
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
)
}
function AutocompleteSeparator({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
return (
<AutocompletePrimitive.Separator
data-slot="autocomplete-separator"
className={cn(
"bg-border my-1.5 h-px",
className
)}
{...props}
/>
)
}
export {
Autocomplete,
AutocompleteValue,
AutocompleteTrigger,
AutocompleteInput,
AutocompleteStatus,
AutocompletePortal,
AutocompleteBackdrop,
AutocompletePositioner,
AutocompleteContent,
AutocompleteList,
AutocompleteCollection,
AutocompleteRow,
AutocompleteItem,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteEmpty,
AutocompleteClear,
AutocompleteArrow,
AutocompleteSeparator,
}
+98
View File
@@ -0,0 +1,98 @@
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 "@evobgp/ui/lib/utils"
const badgeVariants = cva(
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border-border bg-transparent dark:bg-input/32",
secondary: "bg-secondary text-secondary-foreground",
info: "bg-info text-white",
success: "bg-success text-white",
warning: "bg-warning text-white",
destructive: "bg-destructive text-white",
focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground",
"primary-light":
"bg-primary/10 border-none text-primary dark:bg-primary/20",
"warning-light":
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
"success-light":
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
"info-light":
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
"destructive-light":
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
"invert-light":
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
"focus-light":
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
"primary-outline":
"bg-background border-border text-primary dark:bg-input/30",
"warning-outline":
"bg-background border-border text-warning-foreground dark:bg-input/30",
"success-outline":
"bg-background border-border text-success-foreground dark:bg-input/30",
"info-outline":
"bg-background border-border text-info-foreground dark:bg-input/30",
"destructive-outline":
"bg-background border-border text-destructive-foreground dark:bg-input/30",
"invert-outline":
"bg-background border-border text-invert-foreground dark:bg-input/30",
"focus-outline":
"bg-background border-border text-focus-foreground dark:bg-input/30",
},
size: {
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
},
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
radius: {
default:
"rounded-sm",
full: "rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
radius: "default",
},
}
)
interface BadgeProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof badgeVariants>["variant"]
size?: VariantProps<typeof badgeVariants>["size"]
radius?: VariantProps<typeof badgeVariants>["radius"]
}
function Badge({
className,
variant,
size,
radius,
render,
...props
}: BadgeProps) {
const defaultProps = {
"data-slot": "badge",
className: cn(badgeVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { Badge, badgeVariants, type BadgeProps }
@@ -0,0 +1,167 @@
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import { Input } from "@evobgp/ui/components/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evobgp/ui/components/popover"
import { Separator } from "@evobgp/ui/components/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
function DataGridColumnFilter<TData, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
if (!searchQuery) return options
return options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [options, searchQuery])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<CirclePlusIcon className="size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
}
/>
<PopoverContent className="w-[200px] p-0" align="start">
<div className="p-2">
<Input
placeholder={title}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<div
key={option.value}
onClick={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
onClick={() => column?.setFilterValue(undefined)}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
export { DataGridColumnFilter, type DataGridColumnFilterProps }
@@ -0,0 +1,343 @@
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import { Column } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
const handleSort = () => {
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
const headerLabelClassName = cn(
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
className
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" />
))
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
filter
const menuItems = useMemo(() => {
const items: ReactNode[] = []
let hasPreviousSection = false
// Filter section
if (filter) {
items.push(
<DropdownMenuGroup key="group-filter">
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
</DropdownMenuGroup>
)
hasPreviousSection = true
}
// Sort section
if (canSort) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-sort" />)
}
items.push(
<DropdownMenuItem
key="sort-asc"
onClick={() => {
if (isSorted === "asc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}}
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="sort-desc"
onClick={() => {
if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}}
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
{isPinned === "left" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
{isPinned === "right" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Move section
if (props.tableLayout?.columnsMovable) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-move" />)
}
items.push(
<DropdownMenuItem
key="move-left"
onClick={() => {
if (columnIndex > 0) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex - 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Visibility section
if (props.tableLayout?.columnsVisibility && visibility) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-visibility" />)
}
items.push(
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className="capitalize"
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filter,
canSort,
isSorted,
column,
props.tableLayout?.columnsPinnable,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
isPinned,
canMoveLeft,
canMoveRight,
visibility,
table,
columnIndex,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
if (hasControls) {
return (
<div className="flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
}
/>
<DropdownMenuContent className="w-40" align="start">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<Button
size="icon-sm"
variant="ghost"
className="-me-1 size-7 rounded-md"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
)}
</div>
)
}
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
onClick={handleSort}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
</div>
)
}
return (
<div className={headerLabelClassName}>
{icon && icon}
{resolvedTitle}
</div>
)
}
const DataGridColumnHeader = memo(
DataGridColumnHeaderInner
) as typeof DataGridColumnHeaderInner
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -0,0 +1,53 @@
"use client"
import { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@evobgp/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData>({
table,
trigger,
}: {
table: Table<TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -0,0 +1,224 @@
import React, { ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Skeleton } from "@evobgp/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface DataGridPaginationProps {
sizes?: number[]
sizesInfo?: string
sizesLabel?: string
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
className?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesLabel: "Show",
sizesDescription: "per page",
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
more: false,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "size-7 p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps?.info
? mergedProps.info
.replace("{from}", from.toString())
.replace("{to}", to.toString())
.replace("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
// Pagination limit logic
const paginationMoreLimit = mergedProps?.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
const currentGroupEnd = Math.min(
currentGroupStart + paginationMoreLimit,
pageCount
)
// Render page buttons based on the current group
const renderPageButtons = () => {
const buttons = []
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
buttons.push(
<Button
key={i}
size="icon-sm"
variant="ghost"
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
onClick={() => {
if (pageIndex !== i) {
table.setPageIndex(i)
}
}}
>
{i + 1}
</Button>
)
}
return buttons
}
// Render a "previous" ellipsis button if there are previous pages to show
const renderEllipsisPrevButton = () => {
if (currentGroupStart > 0) {
return (
<Button
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
// Render a "next" ellipsis button if there are more pages to show after the current group
const renderEllipsisNextButton = () => {
if (currentGroupEnd < pageCount) {
return (
<Button
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
return (
<div
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps?.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-14" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps?.infoSkeleton
) : (
<>
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1 sm:order-2">
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{mergedProps.previousPageLabel}
</span>
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{mergedProps.nextPageLabel}</span>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
export { DataGridPagination, type DataGridPaginationProps }
@@ -0,0 +1,421 @@
"use client"
import {
PointerEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@evobgp/ui/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
const INITIAL_METRICS = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
trackHeight: 0,
} as const
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
type ObservedElements = {
header: HTMLElement | null
horizontalScrollbar: HTMLElement | null
table: HTMLElement | null
tableViewport: HTMLElement | null
}
type DataGridScrollAreaProps = Omit<
ScrollAreaPrimitive.Root.Props,
"children"
> & {
children: ReactNode
orientation?: DataGridScrollAreaOrientation
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
return (
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
next.headerHeight === prev.headerHeight &&
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
next.thumbHeight === prev.thumbHeight &&
next.thumbTop === prev.thumbTop &&
next.trackHeight === prev.trackHeight
)
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
`${metrics.headerHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-height",
`${metrics.thumbHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-top",
`${metrics.thumbTop}px`
)
element.style.setProperty(
"--data-grid-scrollbar-track-height",
`${metrics.trackHeight}px`
)
}
function DataGridScrollArea({
children,
className,
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startScrollTop: number
startY: number
} | null>(null)
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
const observedElementsRef = useRef<ObservedElements>({
header: null,
horizontalScrollbar: null,
table: null,
tableViewport: null,
})
const showHorizontal = orientation !== "vertical"
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}, [])
const resetMetrics = useCallback(() => {
const container = containerRef.current
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
applyMetrics(container, INITIAL_METRICS)
metricsRef.current = INITIAL_METRICS
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
}, [])
const syncCustomVerticalScrollbar = useCallback(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport || !usesCustomVerticalScrollbar) {
resetMetrics()
return
}
const { header, horizontalScrollbar } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
const scrollWidth = viewport.scrollWidth
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
: 0
const trackHeight = Math.max(
0,
viewportHeight - headerHeight - horizontalScrollbarSize
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
let nextMetrics: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
nextMetrics = {
hasVerticalOverflow: false,
headerHeight,
horizontalScrollbarSize,
thumbHeight: trackHeight,
thumbTop: 0,
trackHeight,
}
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
MIN_THUMB_SIZE,
trackHeight
)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const thumbTop =
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
nextMetrics = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics
}
setHasCustomVerticalOverflow((prev) =>
prev === nextMetrics.hasVerticalOverflow
? prev
: nextMetrics.hasVerticalOverflow
)
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
useEffect(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
resetMetrics()
return
}
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
observer?.observe(viewport)
observedElementsRef.current.header &&
observer?.observe(observedElementsRef.current.header)
observedElementsRef.current.table &&
observer?.observe(observedElementsRef.current.table)
observedElementsRef.current.tableViewport &&
observer?.observe(observedElementsRef.current.tableViewport)
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
}, [
clearDragState,
resetMetrics,
syncCustomVerticalScrollbar,
usesCustomVerticalScrollbar,
])
const scrollToThumbOffset = (nextThumbTop: number) => {
const viewport = viewportRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport) return
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
if (maxScroll === 0 || maxThumbTop === 0) {
viewport.scrollTop = 0
return
}
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
viewport.scrollTop = ratio * maxScroll
}
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
if (!viewport) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
const dragState = dragRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (maxThumbTop === 0 || maxScroll === 0) return
const deltaY = event.clientY - dragState.startY
const nextScrollTop =
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
clearDragState()
}
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const { thumbHeight } = metricsRef.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
return (
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
)}
</div>
)
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -0,0 +1,307 @@
import {
createContext,
CSSProperties,
ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
disabled
>
<GripHorizontalIcon
/>
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon
/>
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
}
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
key={row.id}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
return (
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
</DataGridTableBodyRow>
</SortableRowContext.Provider>
)
}
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
onDragEnd={(event) => {
setIsDraggingRow(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -0,0 +1,314 @@
"use client"
import {
CSSProperties,
Fragment,
ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
} from "@tanstack/react-table"
import { Button } from "@evobgp/ui/components/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
header,
}: {
header: Header<TData, unknown>
}) {
const { props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<span className="grow truncate">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</span>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={[restrictToTableBounds]}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => {
return (
<SortableContext
key={cell.id}
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
<DataGridTableDndCell cell={cell} />
</SortableContext>
)
})}
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -0,0 +1,490 @@
import {
memo,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridTableRowSections,
} from "@/components/reui/data-grid/data-grid-table"
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@evobgp/ui/lib/utils"
import { Spinner } from "@evobgp/ui/components/spinner"
type DataGridTableVirtualScrollElements = {
containerElement: HTMLDivElement | null
scrollElement: HTMLElement | null
}
type DataGridTableVirtualizerInstance = Virtualizer<
HTMLElement,
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
isFetchingMore?: boolean
hasMore?: boolean
fetchMoreOffset?: number
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
columnCount: number
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
isInfiniteMode: boolean
isFetchingMore: boolean
hasMore?: boolean
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualSpacer({
columnCount,
height,
}: {
columnCount: number
height: number
}) {
if (height <= 0) return null
return (
<tr aria-hidden="true">
<td colSpan={columnCount} style={{ height, padding: 0 }} />
</tr>
)
}
function DataGridTableVirtualStatusRow({
children,
className,
columnCount,
}: {
children: ReactNode
className?: string
columnCount: number
}) {
return (
<tr>
<td
colSpan={columnCount}
className={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</td>
</tr>
)
}
function DataGridTableVirtualBody<TData>({
table,
columnCount,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
const leadingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? (virtualItems[0]?.start ?? 0)
: 0
const trailingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? Math.max(
0,
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
)
: 0
const renderedRows: ReactNode[] = []
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
/>
)
})
if (isVirtualizationEnabled) {
if (leadingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
columnCount={columnCount}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
columnCount={columnCount}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-loading"
columnCount={columnCount}
>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
if (showCompleteRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
columnCount={columnCount}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
</DataGridTableVirtualStatusRow>
)
}
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
: undefined
}
/>
)
})
return <>{renderedRows}</>
}
/**
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
footerContent,
renderHeader = true,
onFetchMore,
isFetchingMore = false,
hasMore,
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const columnCount =
table.getVisibleFlatColumns().length +
(props.tableLayout?.columnsResizable ? 1 : 0)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
containerElement: null,
scrollElement: null,
})
const {
estimateSize: customEstimateSize,
getItemKey: customGetItemKey,
getScrollElement: customGetScrollElement,
measureElement: customMeasureElement,
overscan: customOverscan,
...virtualizerOptionsRest
} = virtualizerOptions ?? {}
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement:
(node?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node,
})
}, [])
const usesExternalScrollArea =
viewportElements.scrollElement !== null &&
viewportElements.scrollElement !== viewportElements.containerElement
const resolveScrollElement = useCallback(() => {
if (customGetScrollElement) {
return customGetScrollElement(viewportElements)
}
return viewportElements.scrollElement
}, [customGetScrollElement, viewportElements])
const resolveItemKey = useCallback(
(index: number) => {
const row = centerRows[index]
if (!row) return index
return customGetItemKey?.(index, row) ?? row.id ?? index
},
[centerRows, customGetItemKey]
)
const resolveEstimateSize = useCallback(
(index: number) => {
const row = centerRows[index]
return row
? (customEstimateSize?.(index, row) ?? estimateSize)
: estimateSize
},
[centerRows, customEstimateSize, estimateSize]
)
const virtualizer = useVirtualizer({
count: centerRows.length,
getScrollElement: resolveScrollElement,
getItemKey: resolveItemKey,
estimateSize: resolveEstimateSize,
overscan: customOverscan ?? overscan,
measureElement: customMeasureElement,
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = useMemo(
() => Math.max(0, fetchMoreOffset),
[fetchMoreOffset]
)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
onFetchMore?.()
}
}, [
centerRows.length,
hasMore,
isFetchingMore,
isInfiniteMode,
isVirtualizationEnabled,
onFetchMore,
resolvedFetchMoreOffset,
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, hIndex) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={hIndex}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
columnCount={columnCount}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
virtualItems={virtualItems}
totalSize={totalSize}
isVirtualizationEnabled={isVirtualizationEnabled}
isInfiniteMode={isInfiniteMode}
isFetchingMore={isFetchingMore}
hasMore={hasMore}
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export { DataGridTableVirtual }
export type {
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,268 @@
import { createContext, ReactNode, useContext, useMemo } from "react"
import {
Column,
ColumnFiltersState,
RowData,
SortingState,
Table,
} from "@tanstack/react-table"
import { cn } from "@evobgp/ui/lib/utils"
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
}
}
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>(
column: Column<TData, TValue>
): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle
const defHeader = column.columnDef.header
if (typeof defHeader === "string") return defHeader
return String(column.id)
}
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
filters?: ColumnFiltersState
searchQuery?: string
}
export type DataGridApiResponse<T> = {
data: T[]
empty: boolean
pagination: {
total: number
page: number
}
}
export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData>
table: Table<TData>
recordCount: number
isLoading: boolean
}
export type DataGridRequestParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
columnFilters?: ColumnFiltersState
}
export interface DataGridProps<TData extends object> {
className?: string
table?: Table<TData>
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
isLoading?: boolean
loadingMode?: "skeleton" | "spinner"
loadingMessage?: ReactNode | string
fetchingMoreMessage?: ReactNode | string
allRowsLoadedMessage?: ReactNode | string
emptyMessage?: ReactNode | string
tableLayout?: {
dense?: boolean
cellBorder?: boolean
rowBorder?: boolean
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
columnsVisibility?: boolean
columnsResizable?: boolean
columnsResizeMode?: "onChange" | "onEnd"
columnsPinnable?: boolean
columnsMovable?: boolean
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
}
tableClassNames?: {
base?: string
header?: string
headerRow?: string
headerSticky?: string
body?: string
bodyRow?: string
footer?: string
edgeCell?: string
}
}
const DataGridContext = createContext<
DataGridContextProps<any> | undefined
>(undefined)
function useDataGrid() {
const context = useContext(DataGridContext)
if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider")
}
return context
}
function DataGridProvider<TData extends object>({
children,
table,
...props
}: DataGridProps<TData> & { table: Table<TData> }) {
const tableState = table.getState()
const resolvedColumnsResizeMode =
props.tableLayout?.columnsResizeMode ?? "onEnd"
// Keep resize mode aligned with the DataGrid contract every render so
// consumer-level useReactTable options cannot flip it back between drags.
if (props.tableLayout?.columnsResizable) {
table.options.columnResizeMode = resolvedColumnsResizeMode
}
// Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders.
const value = useMemo(
() => ({
props,
table,
recordCount: props.recordCount,
isLoading: props.isLoading || false,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
props.recordCount,
props.isLoading,
props.loadingMode,
props.loadingMessage,
props.fetchingMoreMessage,
props.allRowsLoadedMessage,
props.emptyMessage,
props.onRowClick,
props.className,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableLayout),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableClassNames),
tableState.sorting,
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
tableState.columnPinning,
tableState.globalFilter,
]
)
return (
<DataGridContext.Provider value={value}>
{children}
</DataGridContext.Provider>
)
}
function DataGrid<TData extends object>({
children,
table,
...props
}: DataGridProps<TData>) {
const defaultProps: Partial<DataGridProps<TData>> = {
loadingMode: "skeleton",
tableLayout: {
dense: false,
cellBorder: false,
rowBorder: true,
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: true,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: "onEnd",
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
},
tableClassNames: {
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
edgeCell: "",
},
}
const mergedProps: DataGridProps<TData> = {
...defaultProps,
...props,
tableLayout: {
...defaultProps.tableLayout,
...(props.tableLayout || {}),
},
tableClassNames: {
...defaultProps.tableClassNames,
...(props.tableClassNames || {}),
},
}
// Ensure table is provided
if (!table) {
throw new Error('DataGrid requires a "table" prop')
}
return (
<DataGridProvider table={table} {...mergedProps}>
{children}
</DataGridProvider>
)
}
function DataGridContainer({
children,
className,
border = true,
}: {
children: ReactNode
className?: string
border?: boolean
}) {
return (
<div
data-slot="data-grid"
className={cn(
"w-full overflow-hidden",
border &&
"border-border rounded-lg border",
className
)}
>
{children}
</div>
)
}
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,258 @@
import { createContext, ReactNode, useContext, useId } from "react"
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
import { cva, VariantProps } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
import { Label } from "@evobgp/ui/components/label"
import { MinusIcon, PlusIcon } from "lucide-react"
const NumberFieldContext = createContext<{
fieldId: string
size: "sm" | "default" | "lg"
} | null>(null)
const numberFieldGroupVariants = cva(
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
{
variants: {
size: {
sm: "h-7 text-sm",
default:
"h-8 text-sm",
lg: "h-9 text-sm",
},
},
defaultVariants: {
size: "default",
},
}
)
const numberFieldButtonVariants = cva(
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
{
variants: {
size: {
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
default:
"px-2 [&_svg:not([class*='size-'])]:size-4",
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
size: "default",
},
}
)
const numberFieldInputVariants = cva(
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
{
variants: {
size: {
sm: "px-2 py-0.5",
default:
"px-2.5 py-1",
lg: "px-2.5 py-1.5",
},
},
defaultVariants: {
size: "default",
},
}
)
function NumberField({
id,
className,
size = "default",
...props
}: NumberFieldPrimitive.Root.Props &
VariantProps<typeof numberFieldGroupVariants>) {
const generatedId = useId()
const fieldId = id ?? generatedId
const sizeValue = size ?? "default"
return (
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
<NumberFieldPrimitive.Root
className={cn("flex w-full flex-col items-start gap-2", className)}
data-size={sizeValue}
data-slot="number-field"
id={fieldId}
{...props}
/>
</NumberFieldContext.Provider>
)
}
function NumberFieldGroup({
className,
size: sizeProp,
...props
}: NumberFieldPrimitive.Group.Props &
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldGroup must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Group
className={cn(numberFieldGroupVariants({ size }), className)}
data-slot="number-field-group"
{...props}
/>
)
}
function NumberFieldDecrement({
className,
size: sizeProp,
children,
...props
}: NumberFieldPrimitive.Decrement.Props &
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
children?: React.ReactNode
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldDecrement must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Decrement
className={cn(
numberFieldButtonVariants({ size }),
"rounded-s-lg border-e-0",
className
)}
data-slot="number-field-decrement"
{...props}
>
{children ?? (
<MinusIcon
/>
)}
</NumberFieldPrimitive.Decrement>
)
}
function NumberFieldIncrement({
className,
size: sizeProp,
children,
...props
}: NumberFieldPrimitive.Increment.Props &
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
children?: ReactNode
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldIncrement must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Increment
className={cn(
numberFieldButtonVariants({ size }),
"rounded-e-lg border-s-0",
className
)}
data-slot="number-field-increment"
{...props}
>
{children ?? (
<PlusIcon
/>
)}
</NumberFieldPrimitive.Increment>
)
}
function NumberFieldInput({
className,
size: sizeProp,
...props
}: NumberFieldPrimitive.Input.Props &
Partial<VariantProps<typeof numberFieldInputVariants>>) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldInput must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Input
className={cn(numberFieldInputVariants({ size }), className)}
data-slot="number-field-input"
{...props}
/>
)
}
function NumberFieldScrubArea({
className,
label,
...props
}: NumberFieldPrimitive.ScrubArea.Props & {
label: string
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
)
}
return (
<NumberFieldPrimitive.ScrubArea
className={cn("flex cursor-ew-resize", className)}
data-slot="number-field-scrub-area"
{...props}
>
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
{label}
</Label>
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
<CursorGrowIcon />
</NumberFieldPrimitive.ScrubAreaCursor>
</NumberFieldPrimitive.ScrubArea>
)
}
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
return (
<svg
fill="black"
height="14"
stroke="white"
viewBox="0 0 24 14"
width="26"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
</svg>
)
}
export {
NumberField,
NumberFieldScrubArea,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldGroup,
NumberFieldInput,
}
+109
View File
@@ -0,0 +1,109 @@
import type { ReactElement, ReactNode } from 'react'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { cn } from '@evobgp/ui/lib/utils'
import { TruncatedText } from '@/components/truncated-text'
export interface SectionCardItem {
label: ReactNode
value: string | number | ReactElement
hint?: ReactNode
icon?: ReactNode
badge?: ReactNode
variant?: 'default' | 'warning' | 'destructive'
active?: boolean
onClick?: () => void
}
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'border-warning/50',
destructive: 'border-destructive/50',
}
function sectionGridClass(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'sm:grid-cols-2'
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
return 'sm:grid-cols-2 lg:grid-cols-3'
}
const VALUE_VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'text-warning-foreground',
destructive: 'text-destructive',
}
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
return (
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
{items.map((item, idx) => {
const clickable = Boolean(item.onClick)
const content = (
<CardContent className="flex items-start gap-2.5 px-3 py-2.5">
{item.icon ? (
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
{item.icon}
</span>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
{typeof item.label === 'string' ? (
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
) : (
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
)}
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
</div>
<div className="flex min-w-0 items-baseline gap-1.5">
<span
className={cn(
'flex items-center gap-1 text-lg font-semibold tabular-nums',
VALUE_VARIANT_CLASS[item.variant ?? 'default'],
)}
>
{item.value}
</span>
{item.hint ? (
typeof item.hint === 'string' ? (
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
) : (
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
)
) : null}
</div>
</div>
</CardContent>
)
return (
<Card
key={typeof item.label === 'string' ? item.label : idx}
className={cn(
'gap-0',
VARIANT_CLASS[item.variant ?? 'default'],
item.active && 'border-primary ring-1 ring-primary/30',
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
)}
onClick={item.onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
item.onClick?.()
}
}
: undefined
}
>
{content}
</Card>
)
})}
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { SectionCards } from './section-cards'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { Card, CardContent } from '@evobgp/ui/components/card'
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
return (
<SectionCards
items={Array.from({ length: count }, (_, i) => ({
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
}))}
/>
)
}
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return (
<Card className="gap-0">
<CardContent className="p-0">
<div className="flex flex-col">
<div className="flex gap-2 border-b p-3">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
))}
</div>
{Array.from({ length: rows }).map((_, r) => (
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
{Array.from({ length: cols }).map((_, c) => (
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
))}
</div>
))}
</div>
</CardContent>
</Card>
)
}
+30
View File
@@ -0,0 +1,30 @@
import type { ComponentProps } from 'react'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success',
ok: 'success',
paid: 'success',
established: 'success',
succeeded: 'success',
healthy: 'success',
paused: 'secondary',
disabled: 'secondary',
archived: 'outline',
error: 'destructive',
failed: 'destructive',
running: 'info',
queued: 'info',
overdue: 'warning',
stale: 'warning',
warning: 'warning',
mismatch: 'warning',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
const variant = STATUS_VARIANT[status.toLowerCase()] ?? 'outline'
return <Badge variant={variant}>{label ?? status}</Badge>
}
@@ -0,0 +1,16 @@
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import type { ReactNode } from 'react'
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
storageKey="evobgp-theme"
>
{children}
</NextThemesProvider>
)
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@evobgp/ui/components/tooltip'
import { cn } from '@evobgp/ui/lib/utils'
interface TruncatedTextProps {
children: ReactNode
className?: string
as?: 'span' | 'p' | 'div'
/** Явный текст подсказки, если children — не строка. */
tooltip?: string
}
export function TruncatedText({ children, className, as: Tag = 'span', tooltip }: TruncatedTextProps) {
const tip =
tooltip ??
(typeof children === 'string' || typeof children === 'number' ? String(children) : null)
if (!tip) {
return <Tag className={cn('truncate', className)}>{children}</Tag>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Tag className={cn('truncate', className)} />}>{children}</TooltipTrigger>
<TooltipContent>{tip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+200
View File
@@ -0,0 +1,200 @@
import type {
AsEntriesResponse,
CdnSourcesResponse,
DomainEntriesResponse,
IpRangeEntriesResponse,
JobRow,
ModuleType,
RevisionPrefix,
RevisionPrefixesResponse,
} from '@/types/api'
export const TOKEN_STORAGE_KEY = 'evobgp_api_token'
export type Problem = {
type?: string
title?: string
status?: number
detail?: string
}
function getToken(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(TOKEN_STORAGE_KEY)
}
export function setToken(token: string | null): void {
if (typeof window === 'undefined') return
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token)
else window.localStorage.removeItem(TOKEN_STORAGE_KEY)
}
function mergeHeaders(init?: RequestInit, extraHeaders?: Record<string, string>): Headers {
const h = new Headers(init?.headers)
if (!h.has('Accept')) h.set('Accept', 'application/json')
const t = getToken()
if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`)
if (extraHeaders) {
for (const [k, v] of Object.entries(extraHeaders)) {
if (!h.has(k)) h.set(k, v)
}
}
return h
}
/**
* Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost).
* Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback.
*/
function newIdempotencyKey(): string {
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
if (c?.randomUUID) return c.randomUUID()
if (c?.getRandomValues) {
const buf = new Uint8Array(16)
c.getRandomValues(buf)
buf[6] = (buf[6]! & 0x0f) | 0x40
buf[8] = (buf[8]! & 0x3f) | 0x80
const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`
}
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly problem?: Problem,
) {
super(message)
}
}
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
if (typeof window === 'undefined') throw new Error('API is only available in the browser')
return fetch(path, { ...init, headers: mergeHeaders(init) })
}
/** GET / DELETE без тела */
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(path, init)
return parseResponse<T>(res)
}
/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */
export async function apiMutate<T = void>(
path: string,
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
body?: unknown,
opts?: { idempotent?: boolean },
): Promise<T> {
const headers: Record<string, string> = {}
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (opts?.idempotent !== false) {
headers['Idempotency-Key'] = newIdempotencyKey()
}
const res = await fetch(path, {
method,
headers: mergeHeaders({ headers }, headers),
body: body !== undefined ? JSON.stringify(body) : undefined,
})
return parseResponse<T>(res)
}
async function parseResponse<T>(res: Response): Promise<T> {
if (res.status === 204 || res.status === 205) return undefined as T
const text = await res.text()
if (!res.ok) {
let problem: Problem | undefined
let detail = `HTTP ${res.status}`
try {
problem = JSON.parse(text) as Problem
detail = problem.detail ?? problem.title ?? detail
} catch {
if (text) detail = text
}
throw new ApiError(res.status, detail, problem)
}
if (!text) return undefined as T
return JSON.parse(text) as T
}
const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled'])
/** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */
export async function waitForJob(
jobId: string,
opts?: { pollMs?: number; timeoutMs?: number },
): Promise<JobRow> {
const pollMs = opts?.pollMs ?? 400
const timeoutMs = opts?.timeoutMs ?? 120000
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const j = await apiJSON<JobRow>(`/v1/jobs/${jobId}`)
if (terminalJobStatuses.has(j.status)) return j
await new Promise((r) => setTimeout(r, pollMs))
}
throw new Error(`Таймаут ожидания задачи ${jobId}`)
}
export async function apiPageAll<T>(path: string, limit = 500): Promise<T[]> {
const items: T[] = []
let cursor: string | null = null
while (true) {
const [basePath, rawQuery = ''] = path.split('?')
const query = new URLSearchParams(rawQuery)
if (!query.has('limit')) query.set('limit', String(limit))
if (cursor) query.set('cursor', cursor)
else query.delete('cursor')
const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>(
`${basePath}?${query.toString()}`,
)
items.push(...(page.items ?? []))
if (!page.has_more || !page.next_cursor) break
cursor = page.next_cursor
}
return items
}
export async function fetchRevisionPrefixesAll(revisionId: string): Promise<RevisionPrefix[]> {
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`)
return items
}
export async function fetchRevisionPrefixesResponse(
revisionId: string,
cursor?: string | null,
limit = 500,
): Promise<RevisionPrefixesResponse> {
const query = new URLSearchParams({ limit: String(limit) })
if (cursor) query.set('cursor', cursor)
return apiJSON<RevisionPrefixesResponse>(`/v1/revisions/${revisionId}/prefixes?${query.toString()}`)
}
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
if (moduleType === 'DOMAINS') {
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/domain-entries`,
)
return { domains: entries }
}
if (moduleType === 'AS_PREFIXES') {
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/as-entries`,
)
return { asns: entries }
}
if (moduleType === 'CDN_CIDRS') {
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
`/v1/modules/${moduleId}/cdn-sources`,
)
return { cdnSources: entries }
}
if (moduleType === 'IP_RANGES') {
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/ip-range-entries`,
)
return { ipRanges: entries }
}
return {}
}
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
+25
View File
@@ -0,0 +1,25 @@
import { QueryClient } from '@tanstack/react-query'
import {
createRouter as tanstackCreateRouter,
rootRouteId,
} from '@tanstack/react-router'
import { routeTree } from '../routeTree.gen'
import { queryClient } from './queryClient'
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createRouter>
}
}
export function createRouter(opts?: { context?: { queryClient: QueryClient } }) {
return tanstackCreateRouter({
routeTree,
context: opts?.context ?? { queryClient },
defaultPreload: 'intent',
scrollRestoration: true,
})
}
export { rootRouteId }
+27
View File
@@ -0,0 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from '@evobgp/ui/components/sonner'
import '@evobgp/ui/globals.css'
import { queryClient } from '@/lib/queryClient'
import { createRouter } from '@/lib/router'
import { ThemeProvider } from '@/components/theme-provider'
const router = createRouter({ context: { queryClient } })
const rootEl = document.getElementById('root')
if (!rootEl) throw new Error('Root element #root not found')
createRoot(rootEl).render(
<StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster richColors position="top-right" />
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,
)
+19
View File
@@ -0,0 +1,19 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { ApiKey, ApiKeysResponse } from '@/types/api'
export const apiKeysKeys = {
all: ['api-keys'] as const,
list: () => [...apiKeysKeys.all, 'list'] as const,
}
export function apiKeysQueryOptions() {
return queryOptions<ApiKey[]>({
queryKey: apiKeysKeys.list(),
queryFn: async () => {
const page = await apiJSON<ApiKeysResponse>('/v1/api-keys?limit=500')
return page.items ?? []
},
staleTime: 60_000,
})
}
+17
View File
@@ -0,0 +1,17 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { AuthSession } from '@/types/api'
export const authKeys = {
all: ['auth'] as const,
session: () => [...authKeys.all, 'session'] as const,
}
export function authSessionQueryOptions() {
return queryOptions<AuthSession>({
queryKey: authKeys.session(),
queryFn: () => apiJSON<AuthSession>('/v1/auth/session'),
retry: false,
staleTime: 30_000,
})
}
+25
View File
@@ -0,0 +1,25 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api'
export const directoriesKeys = {
all: ['directories'] as const,
communities: () => [...directoriesKeys.all, 'communities'] as const,
doh: () => [...directoriesKeys.all, 'doh'] as const,
}
export function directoriesCommunitiesQueryOptions() {
return queryOptions<CommunitiesResponse>({
queryKey: directoriesKeys.communities(),
queryFn: () => apiJSON<CommunitiesResponse>('/v1/communities?limit=200'),
staleTime: 30_000,
})
}
export function directoriesDohQueryOptions() {
return queryOptions<DohProfilesResponse>({
queryKey: directoriesKeys.doh(),
queryFn: () => apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200'),
staleTime: 30_000,
})
}
+53
View File
@@ -0,0 +1,53 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type {
ModuleRow,
ModulesResponse,
Page,
} from '@/types/api'
export const modulesKeys = {
all: ['modules'] as const,
list: () => [...modulesKeys.all, 'list'] as const,
detail: (id: string) => [...modulesKeys.all, 'detail', id] as const,
domainEntries: (id: string) => [...modulesKeys.all, 'domain-entries', id] as const,
asEntries: (id: string) => [...modulesKeys.all, 'as-entries', id] as const,
cdnSources: (id: string) => [...modulesKeys.all, 'cdn-sources', id] as const,
ipRangeEntries: (id: string) => [...modulesKeys.all, 'ip-range-entries', id] as const,
}
export function modulesListQueryOptions() {
return queryOptions<ModulesResponse>({
queryKey: modulesKeys.list(),
queryFn: () => apiJSON<ModulesResponse>('/v1/modules?limit=200'),
})
}
export function moduleDetailQueryOptions(id: string) {
return queryOptions<ModuleRow>({
queryKey: modulesKeys.detail(id),
queryFn: () => apiJSON<ModuleRow>(`/v1/modules/${id}`),
})
}
export type ModuleEntriesPage = Page<Record<string, unknown>>
export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
const pathByType: Record<ModuleRow['type'], string> = {
DOMAINS: `/v1/modules/${id}/domain-entries?limit=500`,
AS_PREFIXES: `/v1/modules/${id}/as-entries?limit=500`,
CDN_CIDRS: `/v1/modules/${id}/cdn-sources?limit=500`,
IP_RANGES: `/v1/modules/${id}/ip-range-entries?limit=500`,
}
const path = pathByType[type]
const keyByType: Record<ModuleRow['type'], readonly string[]> = {
DOMAINS: modulesKeys.domainEntries(id),
AS_PREFIXES: modulesKeys.asEntries(id),
CDN_CIDRS: modulesKeys.cdnSources(id),
IP_RANGES: modulesKeys.ipRangeEntries(id),
}
return queryOptions<ModuleEntriesPage>({
queryKey: keyByType[type],
queryFn: () => apiJSON<ModuleEntriesPage>(path),
})
}
+71
View File
@@ -0,0 +1,71 @@
import { queryOptions } from '@tanstack/react-query'
import { apiFetch, apiJSON, apiMutate } from '@/lib/api-client'
export interface HealthStatus {
ok: boolean
status?: string
error?: string
}
export interface ReadyStatus {
status?: string
checks?: Record<string, boolean | { ok?: boolean; error?: string }>
}
export interface VersionInfo {
version?: string
app?: string
git_sha?: string
build_time?: string
}
export const monitoringKeys = {
all: ['monitoring'] as const,
health: () => [...monitoringKeys.all, 'health'] as const,
ready: () => [...monitoringKeys.all, 'ready'] as const,
version: () => [...monitoringKeys.all, 'version'] as const,
}
async function fetchHealth(): Promise<HealthStatus> {
const res = await apiFetch('/v1/health', { method: 'GET' })
let body: { status?: string } = {}
try {
body = (await res.json()) as { status?: string }
} catch {
/* ignore */
}
return { ok: res.ok, status: body.status, error: res.ok ? undefined : `HTTP ${res.status}` }
}
export function monitoringHealthQueryOptions() {
return queryOptions<HealthStatus>({
queryKey: monitoringKeys.health(),
queryFn: fetchHealth,
staleTime: 15_000,
})
}
export function monitoringReadyQueryOptions() {
return queryOptions<ReadyStatus>({
queryKey: monitoringKeys.ready(),
queryFn: () => apiJSON<ReadyStatus>('/v1/ready'),
staleTime: 15_000,
})
}
export function monitoringVersionQueryOptions() {
return queryOptions<VersionInfo>({
queryKey: monitoringKeys.version(),
queryFn: () => apiJSON<VersionInfo>('/v1/version'),
staleTime: 60_000,
})
}
export async function fetchLog(endpoint: string): Promise<string> {
const res = await apiFetch(endpoint, { method: 'GET' })
return await res.text()
}
export async function clearLog(endpoint: string): Promise<void> {
await apiMutate(endpoint, 'DELETE', {})
}
+36
View File
@@ -0,0 +1,36 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { BirdStatus, PeersResponse, SpeakersResponse } from '@/types/api'
export const NETWORK_AUTO_REFRESH_MS = 30_000
export const networkKeys = {
all: ['network'] as const,
peers: () => [...networkKeys.all, 'peers'] as const,
speakers: () => [...networkKeys.all, 'speakers'] as const,
bird: () => [...networkKeys.all, 'bird'] as const,
}
export function networkPeersQueryOptions() {
return queryOptions<PeersResponse>({
queryKey: networkKeys.peers(),
queryFn: () => apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
staleTime: 15_000,
})
}
export function networkSpeakersQueryOptions() {
return queryOptions<SpeakersResponse>({
queryKey: networkKeys.speakers(),
queryFn: () => apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
staleTime: 15_000,
})
}
export function networkBirdQueryOptions() {
return queryOptions<BirdStatus>({
queryKey: networkKeys.bird(),
queryFn: () => apiJSON<BirdStatus>('/v1/bird/status'),
staleTime: 15_000,
})
}
+40
View File
@@ -0,0 +1,40 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { JobsResponse, RevisionsResponse, RevisionDiff } from '@/types/api'
export const operationsKeys = {
all: ['operations'] as const,
revisions: () => [...operationsKeys.all, 'revisions'] as const,
jobs: (params?: { status?: string; kind?: string }) =>
[...operationsKeys.all, 'jobs', params ?? {}] as const,
diff: (a: string, b: string) => [...operationsKeys.all, 'diff', a, b] as const,
}
export function operationsRevisionsQueryOptions() {
return queryOptions<RevisionsResponse>({
queryKey: operationsKeys.revisions(),
queryFn: () => apiJSON<RevisionsResponse>('/v1/revisions?limit=100'),
staleTime: 30_000,
})
}
export function operationsJobsQueryOptions(params?: { status?: string; kind?: string }) {
return queryOptions<JobsResponse>({
queryKey: operationsKeys.jobs(params),
queryFn: () => {
const sp = new URLSearchParams({ limit: '200' })
if (params?.status) sp.set('status', params.status)
if (params?.kind) sp.set('kind', params.kind)
return apiJSON<JobsResponse>(`/v1/jobs?${sp.toString()}`)
},
staleTime: 10_000,
})
}
export function operationsDiffQueryOptions(a: string, b: string) {
return queryOptions<RevisionDiff>({
queryKey: operationsKeys.diff(a, b),
queryFn: () => apiJSON<RevisionDiff>(`/v1/revisions/${a}/diff/${b}`),
enabled: Boolean(a) && Boolean(b),
})
}
+115
View File
@@ -0,0 +1,115 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type {
JobRow,
JobsResponse,
ModuleRow,
ModulesResponse,
PeerRow,
PeersResponse,
RevisionRow,
RevisionsResponse,
SpeakerRow,
SpeakersResponse,
} from '@/types/api'
export const overviewKeys = {
all: ['overview'] as const,
modules: () => [...overviewKeys.all, 'modules'] as const,
peers: () => [...overviewKeys.all, 'peers'] as const,
speakers: () => [...overviewKeys.all, 'speakers'] as const,
revisions: () => [...overviewKeys.all, 'revisions'] as const,
jobs: () => [...overviewKeys.all, 'jobs'] as const,
health: () => [...overviewKeys.all, 'health'] as const,
}
export function overviewModulesQueryOptions() {
return queryOptions<ModulesResponse>({
queryKey: overviewKeys.modules(),
queryFn: () => apiJSON<ModulesResponse>('/v1/modules?limit=200'),
staleTime: 60_000,
})
}
export function overviewPeersQueryOptions() {
return queryOptions<PeersResponse>({
queryKey: overviewKeys.peers(),
queryFn: () => apiJSON<PeersResponse>('/v1/peers?limit=200&live=1'),
staleTime: 30_000,
})
}
export function overviewSpeakersQueryOptions() {
return queryOptions<SpeakersResponse>({
queryKey: overviewKeys.speakers(),
queryFn: () => apiJSON<SpeakersResponse>('/v1/speakers?limit=200&live=1'),
staleTime: 30_000,
})
}
export function overviewRevisionsQueryOptions() {
return queryOptions<RevisionsResponse>({
queryKey: overviewKeys.revisions(),
queryFn: () => apiJSON<RevisionsResponse>('/v1/revisions?limit=10'),
staleTime: 60_000,
})
}
export function overviewJobsQueryOptions() {
return queryOptions<JobsResponse>({
queryKey: overviewKeys.jobs(),
queryFn: () => apiJSON<JobsResponse>('/v1/jobs?limit=10'),
staleTime: 15_000,
})
}
export function overviewHealthQueryOptions() {
return queryOptions<boolean>({
queryKey: overviewKeys.health(),
queryFn: async () => {
const res = await fetch('/v1/health')
return res.ok
},
staleTime: 30_000,
})
}
// Selectors / helpers
export type NetworkMetrics = {
peersTotal: number
peersEnabled: number
peersEstablished: number
peersMismatch: number
speakersTotal: number
speakersOnline: number
}
export function aggregateNetworkMetrics(
peers: PeerRow[],
speakers: SpeakerRow[],
): NetworkMetrics {
const peersEnabled = peers.filter((p) => p.enabled !== false).length
const peersEstablished = peers.filter((p) => p.session_state === 'Established').length
const peersMismatch = peers.filter((p) => p.session_mismatch).length
const speakersOnline = speakers.filter((s) => s.live?.agent_ok).length
return {
peersTotal: peers.length,
peersEnabled,
peersEstablished,
peersMismatch,
speakersTotal: speakers.length,
speakersOnline,
}
}
export function runningJobCount(jobs: JobRow[]): number {
return jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
}
export function moduleNameById(modules: ModuleRow[]): Map<string, string> {
return new Map(modules.map((m) => [m.id, m.name]))
}
export function recentRevisions(revisions: RevisionRow[], n = 10): RevisionRow[] {
return revisions.slice(0, n)
}
+128
View File
@@ -0,0 +1,128 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
export type AppSettings = Record<string, unknown>
export const BIRD_SETTING_KEYS = [
'bird_router_id',
'bird_local_ipv4',
'bird_local_ipv6',
'bird_local_asn',
'bird_bgp_source_ipv4',
'bird_bgp_source_ipv6',
] as const
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const
export const RUNTIME_LOGS_SETTING_KEYS = [
'runtime_logs_auto_enabled',
'runtime_logs_max_file_mb',
'runtime_logs_auto_schedule',
'runtime_logs_auto_mode',
] as const
export const KNOWN_SETTING_KEYS = [
...BIRD_SETTING_KEYS,
...REVISION_SETTING_KEYS,
...RUNTIME_LOGS_SETTING_KEYS,
] as const
export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number]
export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number]
export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number]
export type RuntimeLogsSettingKey = (typeof RUNTIME_LOGS_SETTING_KEYS)[number]
export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
'bird_local_asn',
'revision_retention_minutes',
'runtime_logs_max_file_mb',
])
export const BOOLEAN_SETTING_KEYS = new Set<KnownSettingKey>(['runtime_logs_auto_enabled'])
export const settingsKeys = {
all: ['settings'] as const,
}
export function settingsQueryOptions() {
return queryOptions<AppSettings>({
queryKey: settingsKeys.all,
queryFn: () => apiJSON<AppSettings>('/v1/settings'),
staleTime: 30_000,
})
}
export function parseKnownValue(key: KnownSettingKey, value: unknown): string {
if (NUMERIC_SETTING_KEYS.has(key)) {
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'string') return value
return ''
}
if (typeof value === 'string') return value
return ''
}
export interface PartitionedSettings {
bird: Partial<Record<BirdSettingKey, string>>
revision: Partial<Record<RevisionSettingKey, string>>
runtimeLogs: Partial<Record<RuntimeLogsSettingKey, string>>
additional: { id: number; key: string; value: string }[]
}
export function partitionSettings(settings: AppSettings): PartitionedSettings {
const bird: Partial<Record<BirdSettingKey, string>> = {}
const revision: Partial<Record<RevisionSettingKey, string>> = {}
const runtimeLogs: Partial<Record<RuntimeLogsSettingKey, string>> = {}
const additional: { id: number; key: string; value: string }[] = []
let id = 1
for (const [key, value] of Object.entries(settings)) {
if ((BIRD_SETTING_KEYS as readonly string[]).includes(key)) {
bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value)
} else if (key === 'revision_retention_minutes') {
revision.revision_retention_minutes = parseKnownValue(
key as RevisionSettingKey,
value,
)
} else if ((RUNTIME_LOGS_SETTING_KEYS as readonly string[]).includes(key)) {
const rk = key as RuntimeLogsSettingKey
if (rk === 'runtime_logs_auto_enabled') {
runtimeLogs.runtime_logs_auto_enabled =
value === true || value === 1 || value === 'true' || value === '1'
? 'true'
: 'false'
} else if (rk === 'runtime_logs_auto_mode') {
const m = String(value ?? '').trim()
runtimeLogs.runtime_logs_auto_mode = m === 'delete' ? 'delete' : m === 'truncate' ? 'truncate' : ''
} else {
runtimeLogs[rk] = parseKnownValue(rk, value)
}
} else {
additional.push({
id: id++,
key,
value: typeof value === 'string' ? value : String(value),
})
}
}
return { bird, revision, runtimeLogs, additional }
}
export function buildPayload(
keys: readonly KnownSettingKey[],
form: Record<string, string>,
): Record<string, string | number | boolean> {
const payload: Record<string, string | number | boolean> = {}
for (const key of keys) {
const value = String(form[key] ?? '').trim()
if (BOOLEAN_SETTING_KEYS.has(key)) {
payload[key] = value === 'true'
continue
}
if (!value) continue
if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value)
else payload[key] = value
}
return payload
}
+18
View File
@@ -0,0 +1,18 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell'
interface RouterContext {
queryClient: import('@tanstack/react-query').QueryClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
})
function RootComponent() {
return (
<AppShell>
<Outlet />
</AppShell>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth')({
beforeLoad: () => {
const token =
typeof window !== 'undefined' ? window.localStorage.getItem('evobgp_api_token') : null
if (!token) {
throw redirect({ to: '/settings' })
}
},
component: AuthLayout,
})
function AuthLayout() {
return <Outlet />
}
+245
View File
@@ -0,0 +1,245 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Button } from '@evobgp/ui/components/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { authSessionQueryOptions } from '@/queries/auth'
import { apiKeysQueryOptions } from '@/queries/api-keys'
import { apiMutate } from '@/lib/api-client'
import { useMutation } from '@tanstack/react-query'
import type { ApiKeyCreated } from '@/types/api'
import { useState } from 'react'
import { Copy } from 'lucide-react'
export const Route = createFileRoute('/_auth/access')({
component: AccessComponent,
})
function AccessComponent() {
const sessionQuery = useQuery(authSessionQueryOptions())
const session = sessionQuery.data ?? null
const isOperator = session?.role === 'operator'
const keysQuery = useQuery({
...apiKeysQueryOptions(),
enabled: isOperator,
})
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6">
<PageHeader
title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена."
/>
{session ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Текущая сессия</CardTitle>
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<p className="text-muted-foreground">Tenant</p>
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
</div>
<div>
<p className="text-muted-foreground">Роль</p>
<p className="font-mono">{session.role}</p>
</div>
</CardContent>
</Card>
) : null}
{isOperator ? (
<ApiKeysCard
items={keysQuery.data ?? []}
isLoading={keysQuery.isLoading}
isError={keysQuery.isError}
error={keysQuery.error}
onRetry={() => keysQuery.refetch()}
/>
) : session ? (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
<span className="font-mono">{session.role}</span>.
</CardContent>
</Card>
) : null}
</div>
)
}
function ApiKeysCard({
items,
isLoading,
isError,
error,
onRetry,
}: {
items: import('@/types/api').ApiKey[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}) {
const [revealedToken, setRevealedToken] = useState<string | null>(null)
const revoke = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/api-keys/${id}`, 'DELETE', undefined, { idempotent: false }),
onSuccess: () => toast.success('Ключ отозван'),
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отозвать'),
})
const rotate = useMutation({
mutationFn: (id: string) =>
apiMutate<ApiKeyCreated>(`/v1/api-keys/${id}/rotate`, 'POST', undefined, {
idempotent: false,
}),
onSuccess: (created) => {
toast.success('Ключ ротирован')
setRevealedToken(created.token)
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось ротировать'),
})
async function copyToken() {
if (!revealedToken) return
try {
await navigator.clipboard.writeText(revealedToken)
toast.success('Скопировано')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<Card>
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<Button size="sm" variant="outline" onClick={onRetry} disabled={isLoading}>
<RefreshCw className={isLoading ? 'animate-spin' : ''} />
Обновить
</Button>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет ключей"
emptyDescription="Ключи можно создать через API."
onRetry={onRetry}
>
{(data) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Префикс</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((k) => (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.name}</TableCell>
<TableCell className="font-mono text-sm">{k.role}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{k.prefix}</TableCell>
<TableCell>
{k.revoked_at ? (
<span className="text-sm text-destructive">отозван</span>
) : (
<span className="text-sm text-muted-foreground">активен</span>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at}
>
<RefreshCw className="size-3.5" />
</Button>
}
title="Ротировать ключ?"
description="Старый токен перестанет работать сразу."
confirmLabel="Ротировать"
onConfirm={() => rotate.mutate(k.id)}
/>
<ConfirmDialog
trigger={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
disabled={!!k.revoked_at}
title="Отозвать"
>
<Copy className="size-3.5" />
</Button>
}
title="Отозвать API-ключ?"
description={`${k.name} (${k.prefix}…)`}
confirmLabel="Отозвать"
destructive
onConfirm={() => revoke.mutate(k.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
{revealedToken ? (
<div className="flex flex-col gap-3 border-t p-4">
<div className="text-sm font-medium">Новый токен (сохраните сейчас):</div>
<div className="break-all rounded-md border bg-muted/40 p-3 font-mono text-xs">
{revealedToken}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={copyToken}>
<Copy /> Копировать
</Button>
<Button size="sm" onClick={() => setRevealedToken(null)}>
Готово
</Button>
</div>
</div>
) : null}
</Card>
)
}
+400
View File
@@ -0,0 +1,400 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import {
Boxes,
CheckCircle,
Clock,
GitBranch,
Info,
Network,
Play,
Plus,
Radio,
RefreshCw,
Activity,
Share2,
Tags,
Gauge,
XCircle,
} from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { PageHeader } from '@/components/page-header'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import {
aggregateNetworkMetrics,
moduleNameById,
overviewHealthQueryOptions,
overviewJobsQueryOptions,
overviewModulesQueryOptions,
overviewPeersQueryOptions,
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
runningJobCount,
} from '@/queries/overview'
export const Route = createFileRoute('/_auth/dashboard')({
component: DashboardComponent,
})
function DashboardComponent() {
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const results = useQueries({
queries: [
overviewHealthQueryOptions(),
overviewModulesQueryOptions(),
overviewPeersQueryOptions(),
overviewSpeakersQueryOptions(),
overviewRevisionsQueryOptions(),
overviewJobsQueryOptions(),
],
})
const [healthQ, modulesQ, peersQ, speakersQ, revisionsQ, jobsQ] = results
const initialLoading =
modulesQ.isLoading || peersQ.isLoading || speakersQ.isLoading || revisionsQ.isLoading || jobsQ.isLoading
const refreshing = results.some((r) => r.isFetching && !r.isLoading)
if (!lastUpdated && !initialLoading && results.every((r) => r.isSuccess || r.isError)) {
setTimeout(() => setLastUpdated(new Date()), 0)
}
function refetchAll() {
setLastUpdated(null)
results.forEach((r) => r.refetch())
}
const modules = modulesQ.data?.items ?? []
const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const modulesHasMore = modulesQ.data?.has_more ?? false
const peersHasMore = peersQ.data?.has_more ?? false
const speakersHasMore = speakersQ.data?.has_more ?? false
const revisionsHasMore = revisionsQ.data?.has_more ?? false
const net = aggregateNetworkMetrics(peers, speakers)
const running = runningJobCount(jobs)
const nameById = moduleNameById(modules)
const countBadge = (n: number, hasMore: boolean, suffix: string) => (hasMore ? '200+' : suffix)
const items: SectionCardItem[] = [
{
label: 'Модули',
value: initialLoading ? '—' : String(modules.length),
icon: <Boxes className="size-4" />,
hint: countBadge(modules.length, modulesHasMore, 'AS, CDN, домены, IP'),
onClick: () => window.location.assign('/modules'),
},
{
label: 'Пиры',
value: initialLoading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
icon: <GitBranch className="size-4" />,
hint: countBadge(peers.length, peersHasMore, 'Established / включённых'),
badge: net.peersMismatch > 0 ? `mismatch ${net.peersMismatch}` : undefined,
variant: net.peersMismatch > 0 ? 'warning' : 'default',
onClick: () => window.location.assign('/network?tab=peers'),
},
{
label: 'Спикеры',
value: initialLoading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
icon: <Radio className="size-4" />,
hint: countBadge(speakers.length, speakersHasMore, 'online / всего'),
variant: net.speakersOnline < net.speakersTotal ? 'warning' : 'default',
onClick: () => window.location.assign('/network?tab=overview'),
},
{
label: 'Ревизии',
value: initialLoading ? '—' : String(revisions.length),
icon: <Activity className="size-4" />,
hint: countBadge(revisions.length, revisionsHasMore, 'configs'),
onClick: () => window.location.assign('/operations'),
},
{
label: 'Активных задач',
value: initialLoading ? '—' : String(running),
icon: <Clock className="size-4" />,
hint: 'queued и running',
onClick: () => window.location.assign('/operations?tab=jobs'),
},
]
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Обзор"
description={
lastUpdated
? `Состояние панели управления EvoBGP. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Состояние панели управления EvoBGP.'
}
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Панель управления EvoBGP</AlertTitle>
<AlertDescription>
Сводка по модулям, сети и фоновым задачам. BGP и ноды «Сеть», префиксы «Модули»,
деплой «Операции», здоровье API «Мониторинг».
</AlertDescription>
</Alert>
<HealthAlert
loading={healthQ.isLoading}
ok={healthQ.data === true}
loadError={modulesQ.isError || peersQ.isError ? 'Некоторые данные не загружены' : null}
/>
{initialLoading ? <SectionCardsSkeleton count={5} /> : <SectionCards items={items} />}
<div className="grid gap-4 lg:grid-cols-3">
<RecentJobsCard jobs={jobs} nameById={nameById} loading={refreshing} />
<RecentRevisionsCard revisions={revisions} loading={refreshing} />
<NetworkStatusCard peers={peers} speakers={speakers} loading={refreshing} />
</div>
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Быстрые действия</CardTitle>
<CardDescription>Частые переходы к настройке и деплою</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 p-4">
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<Plus className="size-4" />
Создать модуль
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/directories')}>
<Tags className="size-4" />
Добавить community
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=overview')}>
<Network className="size-4" />
Сеть
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/network?tab=peers')}>
<Share2 className="size-4" />
Добавить пира
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/operations')}>
<Play className="size-4" />
Деплой (Apply)
</Button>
<Button variant="outline" size="sm" onClick={() => (window.location.href = '/monitoring')}>
<Gauge className="size-4" />
Мониторинг
</Button>
</CardContent>
</Card>
</div>
)
}
function HealthAlert({
loading,
ok,
loadError,
}: {
loading: boolean
ok: boolean | undefined
loadError: string | null
}) {
if (loading) {
return (
<Alert>
<Skeleton className="size-5 rounded-full" />
<AlertTitle>Проверка API</AlertTitle>
<AlertDescription>
Запрос к <code className="text-xs">/v1/health</code>
</AlertDescription>
</Alert>
)
}
if (ok && !loadError) {
return (
<Alert className="border-success/30 bg-success/5">
<CheckCircle className="text-success" />
<AlertTitle>API работает</AlertTitle>
<AlertDescription>Сервер отвечает на запросы health-check.</AlertDescription>
</Alert>
)
}
if (ok && loadError) {
return (
<Alert className="border-warning/30 bg-warning/5">
<Info className="text-warning" />
<AlertTitle>API доступен, данные не загружены</AlertTitle>
<AlertDescription>{loadError}. Проверьте Bearer-токен в «Настройках».</AlertDescription>
</Alert>
)
}
return (
<Alert variant="destructive" className="border-destructive/30 bg-destructive/5">
<XCircle className="text-destructive" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Не удалось получить ответ от сервера. Проверьте, что API запущен (порт 8080) и в dev работает
прокси Vite.
</AlertDescription>
</Alert>
)
}
function RecentJobsCard({
jobs,
nameById,
loading,
}: {
jobs: import('@/types/api').JobRow[]
nameById: Map<string, string>
loading: boolean
}) {
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Недавние задачи</CardTitle>
<CardDescription>Последние фоновые операции</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && jobs.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : jobs.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет задач</p>
) : (
<ul className="flex flex-col gap-1">
{jobs.slice(0, 8).map((j) => (
<li
key={j.job_id}
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-mono text-xs text-muted-foreground">{j.kind}</span>
<span className="truncate text-xs">
{j.meta?.module_id ? nameById.get(String(j.meta.module_id)) ?? '' : ''}
</span>
</span>
<span
className={
j.status === 'succeeded'
? 'text-xs text-success'
: j.status === 'failed'
? 'text-xs text-destructive'
: 'text-xs text-muted-foreground'
}
>
{j.status}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}
function RecentRevisionsCard({
revisions,
loading,
}: {
revisions: import('@/types/api').RevisionRow[]
loading: boolean
}) {
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Последние ревизии</CardTitle>
<CardDescription>История конфигураций</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && revisions.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : revisions.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">Нет ревизий</p>
) : (
<ul className="flex flex-col gap-1">
{revisions.slice(0, 8).map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/40"
>
<span className="truncate font-mono text-xs text-muted-foreground">{r.id.slice(0, 10)}</span>
<span className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString('ru-RU')}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}
function NetworkStatusCard({
peers,
speakers,
loading,
}: {
peers: import('@/types/api').PeerRow[]
speakers: import('@/types/api').SpeakerRow[]
loading: boolean
}) {
const m = aggregateNetworkMetrics(peers, speakers)
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Состояние сети</CardTitle>
<CardDescription>BGP-сессии и спикеры</CardDescription>
</CardHeader>
<CardContent className="p-3">
{loading && peers.length === 0 && speakers.length === 0 ? (
<Skeleton className="h-24 w-full" />
) : (
<div className="flex flex-col gap-2 px-1 py-1 text-sm">
<Row label="Пиры Established" value={`${m.peersEstablished} / ${m.peersEnabled}`} />
<Row label="Спикеры online" value={`${m.speakersOnline} / ${m.speakersTotal}`} />
{m.peersMismatch > 0 ? (
<Row label="Mismatches" value={String(m.peersMismatch)} variant="warning" />
) : null}
</div>
)}
</CardContent>
</Card>
)
}
function Row({
label,
value,
variant = 'default',
}: {
label: string
value: string
variant?: 'default' | 'warning'
}) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{label}</span>
<span className={variant === 'warning' ? 'font-medium text-warning-foreground' : 'font-medium tabular-nums'}>
{value}
</span>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { BookText, Globe, Info, RefreshCw, Tags } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
export const Route = createFileRoute('/_auth/directories')({
component: DirectoriesComponent,
})
function DirectoriesComponent() {
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const communities = communitiesQ.data?.items ?? []
const dohProfiles = dohQ.data?.items ?? []
const loading = communitiesQ.isLoading || dohQ.isLoading
const items: SectionCardItem[] = [
{
label: 'Сообщества BGP',
value: communities.length,
icon: <Tags className="size-4" />,
hint: 'теги префиксов в AS- и CDN-модулях',
},
{
label: 'DoH профили',
value: dohProfiles.length,
icon: <Globe className="size-4" />,
hint: 'резолвинг доменных модулей',
},
{
label: 'Справочники',
value: 'Общие',
icon: <BookText className="size-4" />,
hint: 'используются всеми модулями tenant',
},
]
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Справочники"
description="Сообщества BGP и DoH-профили для резолвинга доменов"
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void communitiesQ.refetch()
void dohQ.refetch()
}}
disabled={loading}
>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О справочниках</AlertTitle>
<AlertDescription>
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили в
доменных модулях для DNS-over-HTTPS резолвинга.
</AlertDescription>
</Alert>
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Tabs defaultValue="communities">
<TabsList>
<TabsTrigger value="communities">Сообщества BGP</TabsTrigger>
<TabsTrigger value="doh">DoH профили</TabsTrigger>
</TabsList>
<TabsContent value="communities" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сообщества BGP</CardTitle>
<CardDescription>Теги для префиксов в фильтрах BIRD</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={communities}
isLoading={communitiesQ.isLoading}
isError={communitiesQ.isError}
error={communitiesQ.error}
empty={communities.length === 0}
emptyTitle="Нет сообществ"
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => communitiesQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Значение</TableHead>
<TableHead>Тип</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.title}</TableCell>
<TableCell className="font-mono text-xs">{c.community}</TableCell>
<TableCell>
<Badge variant="outline">community</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="doh" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">DoH профили</CardTitle>
<CardDescription>Резолверы DNS-over-HTTPS для доменных модулей</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={dohProfiles}
isLoading={dohQ.isLoading}
isError={dohQ.isError}
error={dohQ.error}
empty={dohProfiles.length === 0}
emptyTitle="Нет DoH профилей"
skeleton={<TableSkeleton rows={4} cols={3} />}
onRetry={() => dohQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>URL</TableHead>
<TableHead>По умолчанию</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name ?? p.url}</TableCell>
<TableCell className="font-mono text-xs">{p.url}</TableCell>
<TableCell>
<Badge variant="outline"></Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,202 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeft, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { StatusBadge } from '@/components/status-badge'
import { moduleDetailQueryOptions, moduleEntriesQueryOptions } from '@/queries/modules'
export const Route = createFileRoute('/_auth/modules/$moduleId')({
component: ModuleDetailComponent,
})
function ModuleDetailComponent() {
const { moduleId } = Route.useParams()
const detail = useQuery(moduleDetailQueryOptions(moduleId))
const mod = detail.data
const entriesQuery = useQuery({
...moduleEntriesQueryOptions(moduleId, mod?.type ?? 'DOMAINS'),
enabled: !!mod,
})
return (
<div className="flex flex-col gap-6">
<PageHeader
title={mod?.name ?? moduleId}
description={mod ? `Тип: ${mod.type}` : 'Загрузка модуля…'}
actions={
<>
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<ArrowLeft />
К списку
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
void detail.refetch()
void entriesQuery.refetch()
}}
disabled={detail.isFetching}
>
<RefreshCw className={detail.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
</>
}
/>
<QueryState
data={mod}
isLoading={detail.isLoading}
isError={detail.isError}
error={detail.error}
skeleton={<TableSkeleton rows={4} cols={2} />}
onRetry={() => detail.refetch()}
>
{(m) => (
<>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Параметры</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
<Field label="ID" value={<code className="font-mono text-xs">{m.id}</code>} />
<Field label="Тип" value={<Badge variant="outline">{m.type}</Badge>} />
<Field label="Приоритет" value={<span className="font-mono">{m.priority}</span>} />
<Field
label="Состояние"
value={
m.enabled ? (
<StatusBadge status="active" label="включён" />
) : (
<StatusBadge status="paused" label="выключен" />
)
}
/>
<Field
label="Интервал"
value={
m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—'
}
/>
<Field
label="Cron"
value={m.cron_expr ? <code className="font-mono text-xs">{m.cron_expr}</code> : '—'}
/>
<Field
label="Последний рефреш"
value={
m.last_refreshed_at
? new Date(m.last_refreshed_at).toLocaleString('ru-RU')
: '—'
}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Маршрутные списки</CardTitle>
<CardDescription>Источник префиксов для модуля</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={entriesQuery.data?.items}
isLoading={entriesQuery.isLoading}
isError={entriesQuery.isError}
error={entriesQuery.error}
empty={(entriesQuery.data?.items?.length ?? 0) === 0}
emptyTitle="Записей нет"
emptyDescription="Добавьте записи через API или создание ревизии."
skeleton={<TableSkeleton rows={5} cols={2} />}
onRetry={() => entriesQuery.refetch()}
>
{(items) => <EntriesTable items={items} moduleType={m.type} />}
</QueryState>
</CardContent>
</Card>
</div>
</>
)}
</QueryState>
</div>
)
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span>{value}</span>
</div>
)
}
function EntriesTable({
items,
moduleType,
}: {
items: Record<string, unknown>[]
moduleType: string
}) {
const primary = ENTRY_PRIMARY_KEY[moduleType as keyof typeof ENTRY_PRIMARY_KEY] ?? 'id'
const secondary = ENTRY_SECONDARY_KEY[moduleType as keyof typeof ENTRY_SECONDARY_KEY] ?? null
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{primary}</TableHead>
{secondary ? <TableHead>{secondary}</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => {
const id = String(item.id ?? idx)
const primaryVal = String(item[primary] ?? '—')
return (
<TableRow key={id}>
<TableCell className="font-mono text-sm">{primaryVal}</TableCell>
{secondary ? (
<TableCell className="font-mono text-xs text-muted-foreground">
{String(item[secondary] ?? '—')}
</TableCell>
) : null}
</TableRow>
)
})}
</TableBody>
</Table>
)
}
const ENTRY_PRIMARY_KEY = {
DOMAINS: 'fqdn',
AS_PREFIXES: 'asn',
CDN_CIDRS: 'url',
IP_RANGES: 'prefix',
} as const
const ENTRY_SECONDARY_KEY = {
DOMAINS: 'community_id',
AS_PREFIXES: 'community_id',
CDN_CIDRS: 'community_id',
IP_RANGES: 'community_id',
} as const
+123
View File
@@ -0,0 +1,123 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Boxes, Plus, RefreshCw } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { TruncatedText } from '@/components/truncated-text'
import { modulesListQueryOptions } from '@/queries/modules'
import type { ModuleRow } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/')({
component: ModulesListComponent,
})
function ModulesListComponent() {
const query = useQuery(modulesListQueryOptions())
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Модули"
description="Маршрутные списки: AS, CDN, домены, IP-диапазоны"
actions={
<>
<Button
variant="outline"
size="sm"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw className={query.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
</>
}
/>
<Card>
<CardHeader className="flex flex-row items-center justify-between border-b py-3">
<CardTitle className="text-base">Все модули</CardTitle>
<Button size="sm" render={<Link to="/modules/new" />}>
<Plus />
Создать
</Button>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={query.data?.items}
isLoading={query.isLoading}
isError={query.isError}
error={query.error}
empty={query.data?.items?.length === 0}
emptyTitle="Нет модулей"
emptyDescription="Создайте первый модуль (AS, CDN, домены, IP)."
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
{(items) => <ModulesTable items={items} />}
</QueryState>
</CardContent>
</Card>
</div>
)
}
function ModulesTable({ items }: { items: ModuleRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Приоритет</TableHead>
<TableHead>Состояние</TableHead>
<TableHead>Обновлено</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((m) => (
<TableRow
key={m.id}
className="cursor-pointer hover:bg-muted/40"
onClick={() => (window.location.href = `/modules/${m.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Boxes className="size-4 text-muted-foreground" />
<TruncatedText className="max-w-[280px]">{m.name}</TruncatedText>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell className="font-mono text-sm tabular-nums">{m.priority}</TableCell>
<TableCell>
{m.enabled ? (
<Badge variant="success">включён</Badge>
) : (
<Badge variant="secondary">выключен</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { Button } from '@evobgp/ui/components/button'
import { PageHeader } from '@/components/page-header'
export const Route = createFileRoute('/_auth/modules/new')({
component: NewModuleComponent,
})
function NewModuleComponent() {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader
title="Новый модуль"
description="Создание модуля — через API или будущая форма"
/>
<Card>
<CardContent className="flex flex-col gap-3 py-6 text-sm text-muted-foreground">
<p>
Форма создания модуля будет добавлена позже. Сейчас модули можно создать через API:
</p>
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{`POST /v1/modules
{ "type": "DOMAINS", "name": "Мой список" }`}
</pre>
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
Назад к списку
</Button>
</CardContent>
</Card>
</div>
)
}
+457
View File
@@ -0,0 +1,457 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Activity, AlertTriangle, Bird, Database, Gauge, HardDrive, HeartPulse, Info, ListTodo, RefreshCw, ShieldCheck } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Separator } from '@evobgp/ui/components/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import {
monitoringHealthQueryOptions,
monitoringReadyQueryOptions,
monitoringVersionQueryOptions,
type ReadyStatus,
type VersionInfo,
} from '@/queries/monitoring'
import { networkBirdQueryOptions } from '@/queries/network'
import { operationsJobsQueryOptions } from '@/queries/operations'
export const Route = createFileRoute('/_auth/monitoring')({
component: MonitoringComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'postgres' || search.tab === 'runtime-logs' ? search.tab : 'system') as
| 'system'
| 'postgres'
| 'runtime-logs',
}),
})
function MonitoringComponent() {
const search = useSearch({ from: '/_auth/monitoring' })
const healthQ = useQuery(monitoringHealthQueryOptions())
const readyQ = useQuery(monitoringReadyQueryOptions())
const versionQ = useQuery(monitoringVersionQueryOptions())
const birdQ = useQuery(networkBirdQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const refreshing =
healthQ.isFetching ||
readyQ.isFetching ||
versionQ.isFetching ||
birdQ.isFetching ||
jobsQ.isFetching
const jobs = jobsQ.data?.items ?? []
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const versionText = formatVersion(versionQ.data)
const items: SectionCardItem[] = [
{
label: 'Общий статус',
value: overallStatusLabel({ health: healthQ.data, ready: readyQ.data, jobsFailed: failed }),
icon: <Gauge className="size-4" />,
hint: overallHint({ health: healthQ.data, jobsFailed: failed }),
},
{
label: 'BGP сессии',
value: birdQ.data
? `${birdQ.data.bgp_established}/${birdQ.data.bgp_sessions_total}`
: '—',
icon: <Bird className="size-4" />,
hint: birdQ.data?.birdc_configured
? 'Established / total на API-хосте'
: 'birdc не настроен',
},
{
label: 'Задачи',
value: running,
icon: <Activity className="size-4" />,
hint: `активных из ${jobs.length}`,
variant: failed > 0 ? 'warning' : 'default',
},
{
label: 'Версия',
value: versionText,
icon: <Gauge className="size-4" />,
hint: versionQ.data?.git_sha ?? versionQ.data?.build_time ?? 'GET /v1/version',
},
]
function refetchAll() {
void healthQ.refetch()
void readyQ.refetch()
void versionQ.refetch()
void birdQ.refetch()
void jobsQ.refetch()
}
const failedJobs = jobs
.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
.slice(0, 5)
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Мониторинг"
description="Состояние API, BGP и задач для диагностики инцидентов"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="system">Система</TabsTrigger>
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-4 flex flex-col gap-6">
{refreshing ? <SectionCardsSkeleton count={4} /> : <SectionCards items={items} />}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Доступность и готовность</CardTitle>
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<QueryState
data={readyQ.data}
isLoading={readyQ.isLoading}
isError={readyQ.isError}
error={readyQ.error}
skeleton={<div className="h-40" />}
onRetry={() => readyQ.refetch()}
>
{(ready) => <ReadyTable health={healthQ.data} ready={ready} />}
</QueryState>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bird className="size-4" />
BGP на API-хосте
</CardTitle>
<CardDescription>GET /v1/bird/status</CardDescription>
</CardHeader>
<CardContent>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<div className="h-40" />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</CardContent>
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="size-4" />
Задачи
</CardTitle>
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-4 text-sm">
<Metric label="Активных" value={running} />
<Metric
label="С ошибками"
value={failed}
valueClass={failed > 0 ? 'text-warning' : 'text-success'}
/>
<Metric label="В выборке" value={jobs.length} />
</div>
<Separator />
{failedJobs.length > 0 ? (
<div className="space-y-3">
<p className="text-sm font-medium">Последние ошибки</p>
<ul className="space-y-2">
{failedJobs.map((job) => (
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{job.kind}</p>
<Badge variant="destructive">{job.status}</Badge>
</div>
{job.error ? (
<p className="mt-1 text-xs text-muted-foreground">
{job.error.slice(0, 120)}
{job.error.length > 120 ? '…' : ''}
</p>
) : null}
</li>
))}
</ul>
</div>
) : (
<p className="text-sm text-muted-foreground">
Критичных сбоев в последних 100 задачах нет.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="size-4 text-muted-foreground" />
Что проверять при деградации
</CardTitle>
<CardDescription>Короткая шпаргалка для triage</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Alert>
<HeartPulse className="size-4" />
<AlertTitle>API недоступен</AlertTitle>
<AlertDescription>
Если <code className="text-xs">/v1/health</code> возвращает ошибку проверьте процесс
API и его логи.
</AlertDescription>
</Alert>
<Alert>
<Database className="size-4" />
<AlertTitle>Readiness не «Готов»</AlertTitle>
<AlertDescription>
Сначала <code className="text-xs">postgres</code>, затем{' '}
<code className="text-xs">store</code> и <code className="text-xs">jobs</code> в checks.
</AlertDescription>
</Alert>
<Alert>
<Bird className="size-4" />
<AlertTitle>Низкий ratio BGP</AlertTitle>
<AlertDescription>
Проверьте <code className="text-xs">/v1/bird/status</code>, затем состояние пиров в Сети.
</AlertDescription>
</Alert>
<Alert>
<ListTodo className="size-4" />
<AlertTitle>Ошибки задач</AlertTitle>
<AlertDescription>
Откройте Операции и проверьте последние неуспешные jobs.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="postgres" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base">PostgreSQL</CardTitle>
<CardDescription>Статус соединения и пул</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Database className="size-4" />
<AlertTitle>Статус готовности</AlertTitle>
<AlertDescription>
PostgreSQL-соединение отображается в readiness-проверке на вкладке «Система» (check{' '}
<code className="text-xs">postgres</code>).
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="runtime-logs" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Файловые логи</CardTitle>
<CardDescription>Логи API и pipeline</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Info className="size-4" />
<AlertTitle>Логи на сервере</AlertTitle>
<AlertDescription>
Файловые логи настраиваются переменной <code className="text-xs">EVOBGP_LOG_*</code> и
управляются tenant-settings на странице «Настройки BIRD».
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function Metric({ label, value, valueClass }: { label: string; value: number | string; valueClass?: string }) {
return (
<div>
<p className="text-muted-foreground">{label}</p>
<p className={`text-2xl font-bold tabular-nums ${valueClass ?? ''}`}>{value}</p>
</div>
)
}
function formatVersion(version?: VersionInfo | null): string {
if (!version) return '—'
return version.version ?? version.app ?? '—'
}
interface OverallInput {
health?: { ok?: boolean } | null
ready?: ReadyStatus | null
jobsFailed: number
}
function overallStatusLabel(input: OverallInput): string {
if (!input.health?.ok) return 'Ошибка'
if (input.jobsFailed > 0) return 'Внимание'
if (input.ready?.status && input.ready.status !== 'ok') return 'Внимание'
return 'В норме'
}
function overallHint(input: OverallInput): string {
if (!input.health?.ok) return 'API недоступен или возвращает ошибку'
if (input.jobsFailed > 0) return `Есть провальные задачи (${input.jobsFailed})`
return 'Все системы работают в штатном режиме'
}
function ReadyTable({
health,
ready,
}: {
health?: { ok?: boolean; status?: string; error?: string } | null
ready: ReadyStatus
}) {
const checks = ready.checks ?? {}
const iconByKey: Record<string, typeof Database> = {
postgres: Database,
store: HardDrive,
jobs: ListTodo,
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[55%]">Проверка</TableHead>
<TableHead>Статус</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<HeartPulse className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Liveness</p>
<p className="text-xs text-muted-foreground">/v1/health</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={health?.ok ? 'default' : 'destructive'}>
{health?.ok ? 'OK' : 'Ошибка'}
</Badge>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Readiness</p>
<p className="text-xs text-muted-foreground">/v1/ready</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={ready.status === 'ok' ? 'default' : 'secondary'}>
{ready.status ?? '—'}
</Badge>
</TableCell>
</TableRow>
{Object.entries(checks).map(([key, value]) => {
const ok = typeof value === 'boolean' ? value : value?.ok !== false
const Icon = iconByKey[key] ?? ListTodo
return (
<TableRow key={key}>
<TableCell>
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{key}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={ok ? 'default' : 'destructive'}>{ok ? 'OK' : 'Ошибка'}</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
if (!bird.birdc_configured) {
return (
<p className="text-sm text-muted-foreground">
{bird.message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).'}
</p>
)
}
const ratio =
bird.bgp_sessions_total > 0
? Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100)
: null
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Established / total</span>
<span className="font-medium tabular-nums">
{bird.bgp_established} / {bird.bgp_sessions_total}
{ratio !== null ? <span className="text-muted-foreground"> ({ratio}%)</span> : null}
</span>
</div>
{ratio !== null ? (
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full transition-all ${
ratio >= 100 ? 'bg-success' : ratio >= 50 ? 'bg-warning' : 'bg-destructive'
}`}
style={{ width: `${ratio}%` }}
/>
</div>
) : null}
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
</div>
)
}
+273
View File
@@ -0,0 +1,273 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Info, RefreshCw } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { Badge } from '@/components/reui/badge'
import { StatusBadge } from '@/components/status-badge'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
import { aggregateNetworkMetrics } from '@/queries/overview'
export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane'
? search.tab
: 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane',
}),
})
function NetworkComponent() {
const search = useSearch({ from: '/_auth/network' })
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const refreshing = peersQ.isFetching || speakersQ.isFetching
const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const net = aggregateNetworkMetrics(peers, speakers)
function refetchAll() {
void peersQ.refetch()
void speakersQ.refetch()
void birdQ.refetch()
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Сеть"
description="BGP-пиры, спикеры и live-метрики нод"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>О сетевой конфигурации</AlertTitle>
<AlertDescription>
Вкладка «Обзор» live-статус agent и BGP на CP и репликах. Apply и ревизии на странице «Операции».
</AlertDescription>
</Alert>
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="overview">Обзор</TabsTrigger>
<TabsTrigger value="peers">Пиры ({peers.length})</TabsTrigger>
<TabsTrigger value="speakers">Спикеры ({speakers.length})</TabsTrigger>
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сводка сети</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 p-4 text-sm">
<Field label="Пиры всего" value={String(net.peersTotal)} />
<Field label="Established" value={`${net.peersEstablished} / ${net.peersEnabled}`} />
<Field label="Спикеры всего" value={String(net.speakersTotal)} />
<Field label="Online" value={`${net.speakersOnline} / ${net.speakersTotal}`} />
{net.peersMismatch > 0 ? (
<Field label="Mismatches" value={String(net.peersMismatch)} />
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">BIRD (control plane)</CardTitle>
<CardDescription>Статус birdc на хосте API</CardDescription>
</CardHeader>
<CardContent className="p-4">
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<TableSkeleton rows={3} cols={2} />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="peers" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Пиры</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={peers}
isLoading={peersQ.isLoading}
isError={peersQ.isError}
error={peersQ.error}
empty={peers.length === 0}
emptyTitle="Нет пиров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => peersQ.refetch()}
>
{(items) => <PeersTable items={items} />}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="speakers" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Спикеры</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={speakers}
isLoading={speakersQ.isLoading}
isError={speakersQ.isError}
error={speakersQ.error}
empty={speakers.length === 0}
emptyTitle="Нет спикеров"
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={() => speakersQ.refetch()}
>
{(items) => <SpeakersTable items={items} />}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="control-plane" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Настройки Control Plane (BIRD)</CardTitle>
<CardDescription>Конфигурация tenant-level в разделе «Настройки BIRD»</CardDescription>
</CardHeader>
<CardContent className="p-4 text-sm text-muted-foreground">
См. раздел «Настройки BIRD».
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
return (
<div className="flex flex-col gap-2 text-sm">
<Field
label="Состояние"
value={bird.healthy === true ? 'В норме' : bird.healthy === false ? 'Проблема' : 'Н/Д'}
/>
<Field label="Сессий BGP" value={`${bird.bgp_established} / ${bird.bgp_sessions_total}`} />
{bird.message ? <p className="text-xs text-muted-foreground">{bird.message}</p> : null}
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
</div>
)
}
function PeersTable({ items }: { items: import('@/types/api').PeerRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Neighbor</TableHead>
<TableHead>ASN</TableHead>
<TableHead>Состояние</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name ?? p.neighbor}</TableCell>
<TableCell className="font-mono text-xs">{p.neighbor}</TableCell>
<TableCell className="font-mono text-xs">{p.remote_asn ?? '—'}</TableCell>
<TableCell>
<StatusBadge status={p.session_state} />
{p.session_mismatch ? (
<Badge variant="warning" className="ml-1">
mismatch
</Badge>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function SpeakersTable({ items }: { items: import('@/types/api').SpeakerRow[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Endpoint</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Agent</TableHead>
<TableHead>BGP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-mono text-xs">{s.endpoint}</TableCell>
<TableCell>
<Badge variant="outline">{s.role}</Badge>
</TableCell>
<TableCell>
{s.live?.agent_ok === true ? (
<StatusBadge status="ok" label="online" />
) : s.live?.agent_ok === false ? (
<StatusBadge status="error" label="offline" />
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>
{s.live ? (
<span className="text-xs">
{s.live.bgp_established ?? 0} / {s.live.bgp_sessions_total ?? 0}
</span>
) : (
'—'
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+444
View File
@@ -0,0 +1,444 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Activity, Info, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { operationsJobsQueryOptions, operationsRevisionsQueryOptions, operationsDiffQueryOptions } from '@/queries/operations'
import { moduleNameById, overviewModulesQueryOptions } from '@/queries/overview'
import { apiMutate, waitForJob } from '@/lib/api-client'
import type { JobRow } from '@/types/api'
export const Route = createFileRoute('/_auth/operations')({
component: OperationsComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'diff' || search.tab === 'jobs' ? search.tab : 'revisions') as
| 'revisions'
| 'diff'
| 'jobs',
}),
})
function OperationsComponent() {
const search = useSearch({ from: '/_auth/operations' })
const qc = useQueryClient()
const revisionsQ = useQuery(operationsRevisionsQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const modulesQ = useQuery(overviewModulesQueryOptions())
const revisions = revisionsQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const nameById = moduleNameById(modulesQ.data?.items ?? [])
const refreshing = revisionsQ.isFetching || jobsQ.isFetching
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter(
(j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const items: SectionCardItem[] = [
{
label: 'Ревизий',
value: revisions.length,
icon: <Activity className="size-4" />,
hint: 'история конфигов',
},
{
label: 'Активных задач',
value: running,
icon: <Clock className="size-4" />,
hint: 'queued и running',
},
{
label: 'Задач с ошибкой',
value: failed,
icon: <AlertTriangle className="size-4" />,
hint: failed > 0 ? 'требуют внимания' : 'критичных сбоев нет',
variant: failed > 0 ? 'warning' : 'default',
},
]
function refetchAll() {
void revisionsQ.refetch()
void jobsQ.refetch()
void modulesQ.refetch()
}
const applyMutation = useMutation({
mutationFn: async () => {
const revId = revisions[0]?.id
if (!revId) throw new Error('Нет ревизий')
const res = await apiMutate<{ job_id: string }>('/v1/apply', 'POST', { revision_id: revId })
if (!res.job_id) throw new Error('Ответ API без job_id')
const job = await waitForJob(res.job_id, { timeoutMs: 180_000 })
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
return job
},
onSuccess: () => {
toast.success('Конфигурация успешно применена')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось применить'),
})
const birdReloadMutation = useMutation({
mutationFn: async () => {
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {})
if (!res.job_id) throw new Error('Ответ API без job_id')
const job = await waitForJob(res.job_id, { timeoutMs: 120_000 })
if (job.status !== 'succeeded') throw new Error(job.error ?? job.status)
return job
},
onSuccess: () => toast.success('Команда birdc configure выполнена'),
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось перезагрузить'),
})
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Ревизии и операции"
description="Деплой конфигурации, управление ревизиями и задачами"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Три раздела на одной странице</AlertTitle>
<AlertDescription>
<strong>Ревизии</strong> история конфигов и откат; <strong>Сравнение</strong> diff
префиксов; <strong>Задачи</strong> ingest, apply, rollback.
</AlertDescription>
</Alert>
<div className="flex flex-wrap gap-2">
<ConfirmDialog
trigger={
<Button variant="default" size="sm" disabled={applyMutation.isPending}>
Apply
</Button>
}
title="Применить конфигурацию на всех спикерах?"
description="Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator."
confirmLabel="Применить"
onConfirm={() => applyMutation.mutate()}
/>
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={birdReloadMutation.isPending}>
BIRD reload
</Button>
}
title="Перезагрузить BIRD?"
description="BIRD перезагрузит конфигурацию. Требуется роль operator."
confirmLabel="Перезагрузить"
onConfirm={() => birdReloadMutation.mutate()}
/>
</div>
{revisionsQ.isLoading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
<TabsTrigger value="diff">Сравнение</TabsTrigger>
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
</TabsList>
<TabsContent value="revisions" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">История ревизий</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={revisions}
isLoading={revisionsQ.isLoading}
isError={revisionsQ.isError}
error={revisionsQ.error}
empty={revisions.length === 0}
emptyTitle="Нет ревизий"
onRetry={() => revisionsQ.refetch()}
>
{(items) => <RevisionsTable items={items} qc={qc} />}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="diff" className="mt-4">
<DiffTab revisions={revisions} />
</TabsContent>
<TabsContent value="jobs" className="mt-4">
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Задачи</CardTitle>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={jobs}
isLoading={jobsQ.isLoading}
isError={jobsQ.isError}
error={jobsQ.error}
empty={jobs.length === 0}
emptyTitle="Нет задач"
onRetry={() => jobsQ.refetch()}
>
{(items) => <JobsTable items={items} nameById={nameById} qc={qc} />}
</QueryState>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function RevisionsTable({
items,
qc,
}: {
items: import('@/types/api').RevisionRow[]
qc: import('@tanstack/react-query').QueryClient
}) {
const rollbackMutation = useMutation({
mutationFn: (id: string) =>
apiMutate(`/v1/revisions/${id}/rollback`, 'POST', {}).then(() => id),
onSuccess: () => {
toast.success('Откат выполнен')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось откатить'),
})
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Префиксов</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id.slice(0, 12)}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString('ru-RU')}
</TableCell>
<TableCell className="font-mono text-sm tabular-nums">
{r.materialized_prefix_count}
</TableCell>
<TableCell>
<ConfirmDialog
trigger={
<Button variant="ghost" size="icon-sm" className="text-destructive">
<RefreshCw className="size-3.5" />
</Button>
}
title={`Откатиться к ревизии ${r.id.slice(0, 8)}…?`}
description="Будет создана новая ревизия на основе выбранной. Требуется роль operator."
confirmLabel="Откатить"
destructive
onConfirm={() => rollbackMutation.mutate(r.id)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function JobsTable({
items,
nameById,
qc,
}: {
items: JobRow[]
nameById: Map<string, string>
qc: import('@tanstack/react-query').QueryClient
}) {
const cancelMutation = useMutation({
mutationFn: (jobId: string) => apiMutate(`/v1/jobs/${jobId}/cancel`, 'POST', {}),
onSuccess: () => {
toast.success('Задача отменена')
void qc.invalidateQueries({ queryKey: ['operations'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось отменить'),
})
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Завершена</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((j) => (
<TableRow key={j.job_id}>
<TableCell className="font-medium">
<div className="flex flex-col gap-0.5">
<span>{j.kind}</span>
{j.meta?.module_id ? (
<span className="text-xs text-muted-foreground">
{nameById.get(String(j.meta.module_id)) ?? String(j.meta.module_id)}
</span>
) : null}
</div>
</TableCell>
<TableCell>
<StatusBadgeColored status={j.status} />
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell>
{j.status === 'running' || j.status === 'queued' ? (
<Button
variant="ghost"
size="icon-sm"
className="text-destructive"
onClick={() => cancelMutation.mutate(j.job_id)}
>
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
function StatusBadgeColored({ status }: { status: string }) {
const cls =
status === 'succeeded'
? 'text-success'
: status === 'failed' || status === 'cancelled'
? 'text-destructive'
: 'text-info'
return <span className={`text-sm font-medium ${cls}`}>{status}</span>
}
function DiffTab({ revisions }: { revisions: import('@/types/api').RevisionRow[] }) {
const [a, setA] = useState('')
const [b, setB] = useState('')
const diffQ = useQuery(operationsDiffQueryOptions(a, b))
return (
<Card>
<CardHeader className="border-b py-3">
<CardTitle className="text-base">Сравнение ревизий</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия A</span>
<Select value={a} onValueChange={(v) => v && setA(v)}>
<SelectTrigger>{a ? a.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-full max-w-xs flex-col gap-1">
<span className="text-xs text-muted-foreground">Ревизия B</span>
<Select value={b} onValueChange={(v) => v && setB(v)}>
<SelectTrigger>{b ? b.slice(0, 12) + '…' : 'Выберите'}</SelectTrigger>
<SelectContent>
{revisions.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.id.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => diffQ.refetch()} disabled={!a || !b || diffQ.isFetching}>
Сравнить
</Button>
</div>
<QueryState
data={diffQ.data}
isLoading={diffQ.isFetching}
isError={diffQ.isError}
error={diffQ.error}
empty={!diffQ.data}
emptyTitle="Выберите две ревизии"
onRetry={() => diffQ.refetch()}
>
{(diff) => <DiffView diff={diff} />}
</QueryState>
</CardContent>
</Card>
)
}
function DiffView({ diff }: { diff: import('@/types/api').RevisionDiff }) {
const added = diff.prefixes?.added ?? (diff.added as string[]) ?? []
const removed = diff.prefixes?.removed ?? (diff.removed as string[]) ?? []
return (
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="mb-2 text-sm font-medium text-success">Добавлено: {added.length}</p>
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{added.join('\n')}
</pre>
</div>
<div>
<p className="mb-2 text-sm font-medium text-destructive">Удалено: {removed.length}</p>
<pre className="max-h-80 overflow-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
{removed.join('\n')}
</pre>
</div>
</div>
)
}
+263
View File
@@ -0,0 +1,263 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Badge } from '@evobgp/ui/components/badge'
import { Button } from '@evobgp/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { LoadingButton } from '@/components/loading-button'
import { operationsJobsQueryOptions } from '@/queries/operations'
import { modulesListQueryOptions } from '@/queries/modules'
import { apiMutate } from '@/lib/api-client'
import type { JobRow } from '@/types/api'
export const Route = createFileRoute('/_auth/schedule')({
component: ScheduleComponent,
})
function ScheduleComponent() {
const modulesQ = useQuery(modulesListQueryOptions())
const jobsQ = useQuery(operationsJobsQueryOptions())
const qc = useQueryClient()
const [refreshing, setRefreshing] = useState<Record<string, boolean>>({})
const modules = modulesQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const loading = modulesQ.isLoading || jobsQ.isLoading
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
const items: SectionCardItem[] = [
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'queued и running' },
{
label: 'С ошибкой',
value: failed,
icon: <AlertTriangle className="size-4" />,
hint: failed > 0 ? 'требуют внимания' : 'без ошибок',
variant: failed > 0 ? 'warning' : 'default',
},
]
const refreshMutation = useMutation({
mutationFn: async (id: string) => apiMutate<{ job_id?: string }>(`/v1/modules/${id}/refresh`, 'POST'),
onMutate: (id) => setRefreshing((s) => ({ ...s, [id]: true })),
onSuccess: (data, id) => {
if (data === undefined) toast.message('Обновление не требуется (тип IP_RANGES)')
else toast.success('Задача поставлена в очередь')
void qc.invalidateQueries({ queryKey: ['operations'] })
void qc.invalidateQueries({ queryKey: ['modules'] })
setRefreshing((s) => ({ ...s, [id]: false }))
},
onError: (e, id) => {
toast.error(e instanceof Error ? e.message : 'Не удалось запустить')
setRefreshing((s) => ({ ...s, [id]: false }))
},
})
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Расписание и задачи"
description="Интервалы обновления модулей и ручной запуск"
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void modulesQ.refetch()
void jobsQ.refetch()
}}
disabled={loading}
>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Как работает расписание</AlertTitle>
<AlertDescription>
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
<code className="text-xs">cron_expr</code>. Ручной запуск {' '}
<code className="text-xs">POST /v1/modules/&#123;id&#125;/refresh</code>.
</AlertDescription>
</Alert>
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
<Card>
<CardHeader>
<CardTitle className="text-base">Модули</CardTitle>
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={modules}
isLoading={modulesQ.isLoading}
isError={modulesQ.isError}
error={modulesQ.error}
empty={modules.length === 0}
emptyTitle="Нет модулей"
onRetry={() => modulesQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Модуль</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Расписание</TableHead>
<TableHead>Обновлено</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="w-32 text-right" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.name}</TableCell>
<TableCell>
<Badge variant="outline">{m.type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell>
{m.enabled ? (
<Badge variant="default">Вкл</Badge>
) : (
<Badge variant="secondary">Выкл</Badge>
)}
</TableCell>
<TableCell className="text-right">
<LoadingButton
size="sm"
variant="secondary"
loading={!!refreshing[m.id]}
onClick={() => refreshMutation.mutate(m.id)}
>
<RefreshCw />
Обновить
</LoadingButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Задачи</CardTitle>
<CardDescription>Последние задачи из API</CardDescription>
</CardHeader>
<CardContent className="p-0">
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
</CardContent>
</Card>
</div>
)
}
function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
const refresh = jobs.filter((j) => j.kind === 'module_refresh')
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
)
return (
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-0">
<JobsTable items={jobs} loading={loading} />
</TabsContent>
<TabsContent value="refresh" className="mt-0">
<JobsTable items={refresh} loading={loading} />
</TabsContent>
<TabsContent value="failed" className="mt-0">
<JobsTable items={failed} loading={loading} />
</TabsContent>
</Tabs>
)
}
function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) {
if (loading) return <div className="p-6 text-center text-sm text-muted-foreground">Загрузка</div>
if (items.length === 0)
return <div className="p-6 text-center text-sm text-muted-foreground">Нет задач</div>
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Вид</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Создана</TableHead>
<TableHead>Завершена</TableHead>
<TableHead>Ошибка</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((j) => (
<TableRow key={j.job_id}>
<TableCell className="font-medium">{j.kind}</TableCell>
<TableCell>
<Badge
variant={
j.status === 'succeeded'
? 'default'
: j.status === 'failed'
? 'destructive'
: 'secondary'
}
>
{j.status}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
</TableCell>
<TableCell className="max-w-xs truncate text-xs text-destructive">
{j.error ?? ''}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@evobgp/ui/components/select'
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
import { setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner'
import { Save } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export const Route = createFileRoute('/_auth/settings')({
component: SettingsComponent,
})
function SettingsComponent() {
const { data: session } = useQuery(authSessionQueryOptions())
const { theme, setTheme } = useTheme()
const [token, setTokenValue] = useState('')
useEffect(() => {
const t = window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''
setTokenValue(t)
}, [])
function saveTokenHandler() {
const t = token.trim()
setToken(t || null)
toast.success('Токен сохранён')
}
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader
title="Настройки"
description="Параметры интерфейса и подключения браузера к API."
/>
<Card>
<CardHeader>
<CardTitle>Подключение к API</CardTitle>
<CardDescription>
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant в
разделе «Права доступа».
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="token">Токен для запросов</Label>
<Input
id="token"
type="password"
autoComplete="off"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
placeholder="Bearer …"
/>
</div>
<LoadingButton onClick={saveTokenHandler}>
<Save />
Сохранить токен
</LoadingButton>
{session ? (
<p className="text-xs text-muted-foreground">
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
<code className="font-mono">{session.role}</code>.
</p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Оформление</CardTitle>
<CardDescription>
Тема интерфейса. Быстрый переключатель также доступен в боковой панели.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Label htmlFor="theme-select">Тема</Label>
<Select value={theme ?? 'system'} onValueChange={(v) => v && setTheme(v)}>
<SelectTrigger id="theme-select" className="w-full max-w-xs" />
<SelectContent>
<SelectItem value="light">Светлая</SelectItem>
<SelectItem value="dark">Тёмная</SelectItem>
<SelectItem value="system">Как в системе</SelectItem>
</SelectContent>
</Select>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,379 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Info, Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@evobgp/ui/components/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evobgp/ui/components/table'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { LoadingButton } from '@/components/loading-button'
import {
BIRD_SETTING_KEYS,
REVISION_SETTING_KEYS,
RUNTIME_LOGS_SETTING_KEYS,
buildPayload,
partitionSettings,
settingsQueryOptions,
type BirdSettingKey,
} from '@/queries/settings'
import { apiMutate } from '@/lib/api-client'
export const Route = createFileRoute('/_auth/tenant-settings')({
component: TenantSettingsComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'revision' || search.tab === 'runtime-logs' || search.tab === 'additional'
? search.tab
: 'bird') as 'bird' | 'revision' | 'runtime-logs' | 'additional',
}),
})
const BIRD_LABELS: Record<BirdSettingKey, string> = {
bird_router_id: 'Router ID',
bird_local_ipv4: 'Локальный IPv4',
bird_local_ipv6: 'Локальный IPv6',
bird_local_asn: 'Локальный ASN',
bird_bgp_source_ipv4: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6',
}
function TenantSettingsComponent() {
const search = useSearch({ from: '/_auth/tenant-settings' })
const settingsQ = useQuery(settingsQueryOptions())
const qc = useQueryClient()
const partitioned = settingsQ.data ? partitionSettings(settingsQ.data) : null
const [birdForm, setBirdForm] = useState<Record<string, string>>({})
const [revisionForm, setRevisionForm] = useState<Record<string, string>>({})
const [runtimeLogsForm, setRuntimeLogsForm] = useState<Record<string, string>>({})
useEffect(() => {
if (partitioned) {
setBirdForm({ ...partitioned.bird })
setRevisionForm({ ...partitioned.revision })
setRuntimeLogsForm({ ...partitioned.runtimeLogs })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsQ.data])
const patchMutation = useMutation({
mutationFn: (payload: Record<string, string | number | boolean>) =>
apiMutate('/v1/settings', 'PATCH', payload),
onSuccess: () => {
toast.success('Параметры сохранены')
void qc.invalidateQueries({ queryKey: ['settings'] })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
function saveBird() {
patchMutation.mutate(buildPayload(BIRD_SETTING_KEYS, birdForm))
}
function saveRevision() {
patchMutation.mutate(buildPayload(REVISION_SETTING_KEYS, revisionForm))
}
function saveRuntimeLogs() {
patchMutation.mutate(buildPayload(RUNTIME_LOGS_SETTING_KEYS, runtimeLogsForm))
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Параметры tenant"
description="Параметры control plane для текущего tenant (API /v1/settings)"
/>
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Operator-only</AlertTitle>
<AlertDescription>
Изменение значений через <code className="text-xs">PATCH /v1/settings</code> требует роли
operator. При отсутствии прав API вернёт 403.
</AlertDescription>
</Alert>
<Tabs defaultValue={search.tab}>
<TabsList>
<TabsTrigger value="bird">BIRD</TabsTrigger>
<TabsTrigger value="revision">Ревизии</TabsTrigger>
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
</TabsList>
<TabsContent value="bird" className="mt-4">
<Card>
<CardHeader>
<CardTitle>BIRD control plane</CardTitle>
<CardDescription>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через{' '}
<code className="text-xs">PATCH /v1/settings</code> (роль operator).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert className="border-info/30 bg-info/5">
<Info className="text-info" />
<AlertTitle>Подстановка в конфиг</AlertTitle>
<AlertDescription>
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса).
Пиры и спикеры настраиваются в разделе «Сеть».
</AlertDescription>
</Alert>
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-64" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="grid gap-4 md:grid-cols-2">
{BIRD_SETTING_KEYS.map((key) => (
<div key={key} className="flex flex-col gap-1.5">
<Label htmlFor={key}>{BIRD_LABELS[key]}</Label>
<Input
id={key}
value={birdForm[key] ?? ''}
onChange={(e) => setBirdForm((s) => ({ ...s, [key]: e.target.value }))}
placeholder={BIRD_LABELS[key]}
/>
<p className="font-mono text-xs text-muted-foreground">{key}</p>
</div>
))}
<div className="md:col-span-2">
<LoadingButton onClick={saveBird} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
</div>
</div>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="revision" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Ревизии</CardTitle>
<CardDescription>Время хранения ревизий в БД</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="flex max-w-sm flex-col gap-1.5">
<Label htmlFor="revision_retention_minutes">Retention (минуты)</Label>
<Input
id="revision_retention_minutes"
type="number"
value={revisionForm.revision_retention_minutes ?? ''}
onChange={(e) =>
setRevisionForm((s) => ({
...s,
revision_retention_minutes: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
revision_retention_minutes
</p>
<LoadingButton
className="mt-2 w-fit"
onClick={saveRevision}
loading={patchMutation.isPending}
>
<Save />
Сохранить
</LoadingButton>
</div>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="runtime-logs" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Файловые логи</CardTitle>
<CardDescription>Автоматическая очистка логов</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<QueryState
data={partitioned}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
skeleton={<div className="h-48" />}
onRetry={() => settingsQ.refetch()}
>
{() => (
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>Авто-очистка включена</Label>
<Select
value={runtimeLogsForm.runtime_logs_auto_enabled ?? 'false'}
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_enabled: v,
}))
}
>
<SelectTrigger>
{runtimeLogsForm.runtime_logs_auto_enabled === 'true' ? 'Вкл' : 'Выкл'}
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Вкл</SelectItem>
<SelectItem value="false">Выкл</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_enabled
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_max_file_mb">Макс. размер файла (MB)</Label>
<Input
id="runtime_logs_max_file_mb"
type="number"
value={runtimeLogsForm.runtime_logs_max_file_mb ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_max_file_mb: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_max_file_mb
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="runtime_logs_auto_schedule">Расписание (cron)</Label>
<Input
id="runtime_logs_auto_schedule"
value={runtimeLogsForm.runtime_logs_auto_schedule ?? ''}
onChange={(e) =>
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_schedule: e.target.value,
}))
}
/>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_schedule
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>Режим очистки</Label>
<Select
value={runtimeLogsForm.runtime_logs_auto_mode ?? ''}
onValueChange={(v) =>
v &&
setRuntimeLogsForm((s) => ({
...s,
runtime_logs_auto_mode: v,
}))
}
>
<SelectTrigger>
{runtimeLogsForm.runtime_logs_auto_mode || 'Выберите'}
</SelectTrigger>
<SelectContent>
<SelectItem value="delete">Delete</SelectItem>
<SelectItem value="truncate">Truncate</SelectItem>
</SelectContent>
</Select>
<p className="font-mono text-xs text-muted-foreground">
runtime_logs_auto_mode
</p>
</div>
<div className="md:col-span-2">
<LoadingButton onClick={saveRuntimeLogs} loading={patchMutation.isPending}>
<Save />
Сохранить
</LoadingButton>
</div>
</div>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="additional" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Дополнительные параметры</CardTitle>
<CardDescription>
Параметры вне стандартных групп (readonly изменяются только через API)
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<QueryState
data={partitioned?.additional ?? []}
isLoading={settingsQ.isLoading}
isError={settingsQ.isError}
error={settingsQ.error}
empty={(partitioned?.additional ?? []).length === 0}
emptyTitle="Нет дополнительных параметров"
skeleton={<div className="h-32" />}
onRetry={() => settingsQ.refetch()}
>
{(items) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Ключ</TableHead>
<TableHead>Значение</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.key}</TableCell>
<TableCell className="font-mono text-xs">{row.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
beforeLoad: () => {
throw redirect({ to: '/dashboard' })
},
})
+345
View File
@@ -0,0 +1,345 @@
// ---- Pagination ----
export type Page<T> = {
items: T[]
next_cursor: string | null
has_more: boolean
}
// ---- Modules ----
export type ModuleType = 'AS_PREFIXES' | 'CDN_CIDRS' | 'DOMAINS' | 'IP_RANGES'
export type DohResolverPolicy = 'primary_only' | 'failover' | 'union'
export type ModuleRow = {
id: string
type: ModuleType
name: string
enabled: boolean
priority: number
refresh_interval_sec: number | null
cron_expr: string | null
default_community_id: string | null
/** @deprecated use doh_profile_ids */
doh_profile_id: string | null
doh_profile_ids: string[]
doh_resolver_policy: DohResolverPolicy
last_refreshed_at: string | null
}
export type ModulesResponse = Page<ModuleRow>
export type RouterListsCatalogResponse = {
modules: { items: ModuleRow[] }
domains: { items: { module_id: string; entry: DomainEntry }[] }
asns: { items: { module_id: string; entry: AsEntry }[] }
ip_ranges: { items: { module_id: string; entry: IpRangeEntry }[] }
communities: { items: BgpCommunity[] }
}
export type ModuleCreate = {
type: ModuleType
name: string
enabled?: boolean
priority?: number
doh_profile_id?: string | null
doh_profile_ids?: string[]
doh_resolver_policy?: DohResolverPolicy
refresh_interval_sec?: number | null
cron_expr?: string | null
default_community_id?: string | null
}
export type ModulePatch = Partial<Omit<ModuleCreate, 'type'>>
// ---- AS Entries ----
export type AsEntry = {
id: string
asn: number
community_id: string | null
/** Имя/держатель AS (RIPEstat), после успешного обновления модуля */
asn_name?: string | null
/** Число объявленных префиксов на момент последнего резолва */
prefix_count?: number | null
/** ISO-время последнего успешного резолва ASN */
asn_resolved_at?: string | null
}
export type AsEntryCreate = {
asn: number
community_id?: string | null
}
export type AsEntryPatch = {
asn?: number
community_id?: string | null
}
export type AsEntriesResponse = Page<AsEntry>
// ---- CDN Sources ----
export type CdnSource = {
id: string
url: string
source_kind: string
prefix_path: string
community_id: string | null
refresh_interval_sec: number | null
last_refreshed_at: string | null
}
export type CdnSourceCreate = {
url: string
source_kind: string
prefix_path?: string
community_id?: string | null
}
export type CdnSourcePatch = Partial<CdnSourceCreate> & { refresh_interval_sec?: number | null }
export type CdnSourcesResponse = Page<CdnSource>
export type CdnPreviewResponse = {
items: string[]
total: number
truncated: boolean
source_url: string
}
// ---- Domain Entries ----
export type DomainEntry = {
id: string
fqdn: string
community_id: string | null
}
export type DomainEntryCreate = {
fqdn: string
community_id?: string | null
}
export type DomainEntriesResponse = Page<DomainEntry>
// ---- IP Range Entries ----
export type IpRangeEntry = {
id: string
prefix: string
community_id: string
}
export type IpRangeEntryCreate = {
prefix: string
community_id: string
}
export type IpRangeEntriesResponse = Page<IpRangeEntry>
// ---- DoH Profiles ----
export type DohProfile = {
id: string
name?: string
url: string
timeout_ms: number | null
vault_secret_ref: string | null
}
export type DohProfileCreate = {
name?: string
url: string
timeout_ms?: number | null
vault_secret_ref?: string | null
}
export type DohProfilePatch = Partial<DohProfileCreate>
export type DohProfilesResponse = Page<DohProfile>
// ---- Communities ----
export type BgpCommunity = {
id: string
community: string
title: string
}
export type BgpCommunityCreate = {
community: string
title?: string
}
export type BgpCommunityPatch = Partial<BgpCommunityCreate>
export type CommunitiesResponse = Page<BgpCommunity>
// ---- Peers ----
export type PeerSessionOnSpeaker = {
speaker_id: string
label: string
state: string
poll_error?: string
}
export type PeerRow = {
id: string
name?: string
neighbor: string
remote_asn?: number
enabled?: boolean
session_state: string
bgp_speaker_id: string | null
connected_speaker_id?: string | null
connected_speaker_label?: string
session_on_speakers?: PeerSessionOnSpeaker[]
established_on_speakers?: PeerSessionOnSpeaker[]
session_mismatch?: boolean
}
export type LiveSpeakerPoll = {
speaker_id: string
label: string
ok: boolean
session_count: number
poll_error?: string
}
export type PeersResponse = Page<PeerRow> & { live_speaker_poll?: LiveSpeakerPoll[] }
export type BgpPeerCreate = {
name?: string
neighbor: string
remote_asn: number
bgp_speaker_id?: string | null
enabled?: boolean
}
export type BgpPeerPatch = Partial<BgpPeerCreate>
// ---- Speakers ----
export type BgpSessionLive = {
name: string
neighbor?: string
state: string
}
export type SpeakerLiveStatus = {
label?: string
agent_ok?: boolean
agent_error?: string
agent_last_sync_at?: string
agent_last_applied_revision_id?: string
bgp_poll_ok?: boolean
bgp_poll_error?: string
bgp_sessions_total?: number
bgp_established?: number
sessions?: BgpSessionLive[]
}
export type SpeakerRow = {
id: string
role: string
endpoint: string
last_applied_revision_id: string | null
published_revision_id?: string | null
published_at?: string | null
agent_domain?: string
node_ipv4?: string
bird_bgp_source_ipv4?: string
dispatch_status?: string
sync_status?: string
last_dispatch_at?: string | null
last_dispatch_error?: string | null
meta_json?: Record<string, unknown>
agent_secret?: string
live?: SpeakerLiveStatus
}
export type SpeakersResponse = Page<SpeakerRow>
export type BgpSpeakerCreate = {
endpoint: string
role?: string
meta_json?: string
}
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>
export type BundleSigningPublicKey = {
public_key_base64: string
}
// ---- Revisions ----
export type RevisionRow = {
id: string
content_hash: string
created_at: string
parent_revision_id: string | null
materialized_prefix_count: number
module_id: string | null
}
export type RevisionsResponse = Page<RevisionRow>
export type RevisionPrefix = {
/** Обычно CIDR; для AS-модуля в снимке ревизии — строка вида `as:<номер_asn>`. */
prefix: string
/** Источник материализации (например, domain:<fqdn>, as:<asn>, cdn:<source_id>, ip_range). */
source?: string
community_id?: string | null
}
export type RevisionPrefixesResponse = Page<RevisionPrefix>
export type RevisionPreview = {
id: string
prefixes?: RevisionPrefix[]
[key: string]: unknown
}
/** Ответ GET /v1/revisions/{a}/diff/{b}: префиксы в `prefixes` (источник правды в бэкенде). */
export type RevisionDiff = {
revision_a?: string
revision_b?: string
prefixes?: {
added: string[]
removed: string[]
unchanged_count?: number
}
/** Устаревший/нормализованный вид — см. нормализацию в UI */
added?: (string | RevisionPrefix)[]
removed?: (string | RevisionPrefix)[]
[key: string]: unknown
}
// ---- BIRD (локальный birdc на хосте с API, если задан EVOBGP_BIRDC_SOCKET) ----
export type BirdStatus = {
birdc_configured: boolean
message?: string
error?: string
protocols_excerpt?: string
bgp_sessions_total: number
bgp_established: number
healthy: boolean | null
}
// ---- Jobs ----
export type JobRow = {
job_id: string
kind: string
status: string
idempotency_key?: string | null
created_at?: string
started_at?: string | null
finished_at?: string | null
error?: string | null
meta?: Record<string, unknown>
}
export type JobsResponse = Page<JobRow>
// ---- Settings ----
export type AppSettings = Record<string, unknown>
// ---- Auth / API keys ----
export type AuthSession = {
tenant_id: string
role: 'viewer' | 'editor' | 'operator' | 'node'
}
export type ApiKeyRole = AuthSession['role']
export type ApiKey = {
id: string
name: string
role: ApiKeyRole
prefix: string
created_at: string
updated_at: string
expires_at: string | null
revoked_at: string | null
last_used_at: string | null
}
export type ApiKeysResponse = Page<ApiKey>
export type ApiKeyCreate = {
name: string
role: ApiKeyRole
expires_at?: string | null
}
export type ApiKeyCreated = ApiKey & { token: string }
// ---- AsyncJobAccepted (RFC 9457 companion: 202 with job_id) ----
export type AsyncJobAccepted = {
job_id: string
}