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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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', {})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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/{id}/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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/dashboard' })
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user