feat: update dependencies and enhance UI components
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m7s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added new dependencies for drag-and-drop functionality with @dnd-kit packages.
- Updated package versions for @tanstack/react-virtual and date-fns.
- Refactored AppShell component to utilize AppSidebar and SiteHeader for improved layout.
- Enhanced Frame component with new theming capabilities and improved structure.
- Introduced filtering capabilities in Agents and Lists pages with new UI elements.
- Added new utility functions for authentication claims management.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-20 21:04:02 +07:00
co-authored by Cursor
parent c5132056b3
commit 9ff1af849e
67 changed files with 11873 additions and 998 deletions
+7
View File
@@ -11,6 +11,10 @@
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@evofw/shared": "workspace:*",
"@evofw/ui": "workspace:*",
"@hookform/resolvers": "^5.0.0",
@@ -18,11 +22,14 @@
"@tanstack/react-query": "^5.80.0",
"@tanstack/react-router": "^1.120.0",
"@tanstack/react-table": "^8.21.0",
"@tanstack/react-virtual": "^3.14.7",
"@tanstack/router-plugin": "^1.120.0",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.4.0",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.0",
"recharts": "^2.15.0",
+99
View File
@@ -0,0 +1,99 @@
import { Link, useRouterState } from '@tanstack/react-router'
import {
LayoutDashboardIcon,
ServerIcon,
ListIcon,
ShieldIcon,
BarChart3Icon,
SettingsIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@evofw/ui/components/sidebar'
const overviewNav = [
{ to: '/', label: 'Дашборд', icon: LayoutDashboardIcon, exact: true },
] as const
const opsNav = [
{ to: '/agents', label: 'Агенты', icon: ServerIcon, exact: false },
{ to: '/lists', label: 'Списки IP', icon: ListIcon, exact: false },
{ to: '/rules', label: 'Правила', icon: ShieldIcon, exact: false },
{ to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false },
] as const
const systemNav = [
{ to: '/settings', label: 'Настройки', icon: SettingsIcon, exact: false },
] as const
function isNavActive(pathname: string, to: string, exact: boolean) {
if (exact) return pathname === to
return pathname === to || pathname.startsWith(`${to}/`)
}
function NavSection({
label,
items,
pathname,
}: {
label: string
items: readonly {
to: string
label: string
icon: typeof ServerIcon
exact: boolean
}[]
pathname: string
}) {
return (
<SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
tooltip={item.label}
isActive={isNavActive(pathname, item.to, item.exact)}
render={
<Link to={item.to} activeOptions={{ exact: item.exact }} />
}
>
<item.icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
export function AppSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<NavSection label="Обзор" items={overviewNav} pathname={pathname} />
<NavSection label="Операции" items={opsNav} pathname={pathname} />
<NavSection label="Система" items={systemNav} pathname={pathname} />
</SidebarContent>
<SidebarFooter />
</Sidebar>
)
}
+96
View File
@@ -0,0 +1,96 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evofw/ui/components/sidebar'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
getCurrentApp,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
export function AppSwitcher() {
const { isMobile } = useSidebar()
const { config, isLoading } = useAppSwitcherConfig()
const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon] ?? APP_SWITCHER_ICONS.server
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
aria-hidden
>
<CurrentIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{current.name}</span>
{current.subtitle ? (
<span className="truncate text-xs text-muted-foreground">
{current.subtitle}
</span>
) : null}
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{isLoading ? 'Загрузка…' : config.menuLabel}
</div>
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem key={app.id} disabled>
<Icon />
{app.name}
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
>
<Icon />
{app.name}
{app.shortcut ? (
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,56 @@
import type { ReactElement } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@evofw/ui/components/alert-dialog'
interface ConfirmDialogProps {
trigger?: ReactElement
open?: boolean
onOpenChange?: (open: boolean) => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
onConfirm: () => void
disabled?: boolean
}
export function ConfirmDialog({
trigger,
open,
onOpenChange,
title,
description,
confirmLabel = 'Удалить',
cancelLabel = 'Отмена',
onConfirm,
disabled,
}: ConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
{trigger ? (
<AlertDialogTrigger disabled={disabled} render={trigger} />
) : null}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@evofw/ui/components/tabs'
import { cn } from '@evofw/ui/lib/utils'
export interface CountedLineTab {
id: string
label: string
count?: number
}
interface CountedLineTabsProps {
tabs: CountedLineTab[]
value: string
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
export function CountedLineTabs({
tabs,
value,
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
<TabsList variant="line" className={cn('gap-5', listClassName)}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tab.count}
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { InboxIcon, type LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@evofw/ui/components/empty'
import { cn } from '@evofw/ui/lib/utils'
interface EmptyStateProps {
icon?: LucideIcon
title: string
description?: string
action?: React.ReactNode
className?: string
/** Use IconStack media (empty-state-12). Default true. */
stackedIcon?: boolean
/**
* Center in available width/height (empty-state-12).
* Preview: https://reui.io/preview/base/empty-state-12
* Set false for tight panels/sheets.
*/
centered?: boolean
}
/**
* ReUI Empty + IconStack — adapted from empty-state-12.
* @see https://reui.io/preview/base/empty-state-12
*/
export function EmptyState({
icon: Icon = InboxIcon,
title,
description,
action,
className,
stackedIcon = true,
centered = true,
}: EmptyStateProps) {
const body = (
<Empty
className={cn(
'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className,
)}
>
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
{stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
<Icon aria-hidden="true" />
</span>
)}
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
{title}
</EmptyTitle>
{description ? (
<EmptyDescription className="max-w-sm text-sm/relaxed">
{description}
</EmptyDescription>
) : null}
</div>
</EmptyHeader>
{action ? (
<EmptyContent className="mt-1 items-center justify-center">
{action}
</EmptyContent>
) : null}
</Empty>
)
if (!centered) return body
return (
<div
className={cn(
'flex w-full flex-1 items-center justify-center py-14 sm:py-16',
className,
)}
>
{body}
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react'
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from '@evofw/ui/components/field'
import { cn } from '@evofw/ui/lib/utils'
interface FormFieldSimpleProps {
label: string
htmlFor: string
error?: { message?: string }
hint?: string
className?: string
children: ReactNode
}
export function FormFieldSimple({
label,
htmlFor,
error,
hint,
className,
children,
}: FormFieldSimpleProps) {
return (
<Field data-invalid={!!error} className={cn(className)}>
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
{children}
{hint && !error ? <FieldDescription>{hint}</FieldDescription> : null}
<FieldError errors={[error]} />
</Field>
)
}
+71
View File
@@ -0,0 +1,71 @@
import type { ReactNode } from 'react'
import type { FieldValues, UseFormReturn } from 'react-hook-form'
import { FormProvider } from 'react-hook-form'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import { cn } from '@evofw/ui/lib/utils'
interface FormSheetProps<T extends FieldValues> {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
form: UseFormReturn<T>
onSubmit: (values: T) => void | Promise<void>
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
}
export function FormSheet<T extends FieldValues>({
open,
onOpenChange,
title,
description,
form,
onSubmit,
children,
footer,
className,
contentClassName,
}: FormSheetProps<T>) {
const handleSubmit = form.handleSubmit(onSubmit)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className={cn('flex flex-col gap-0 overflow-hidden', className)}
>
<SheetHeader className="shrink-0">
<SheetTitle>{title}</SheetTitle>
{description && <SheetDescription>{description}</SheetDescription>}
</SheetHeader>
<FormProvider {...form}>
<form
onSubmit={handleSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div
className={cn(
'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4',
contentClassName,
)}
>
{children}
</div>
{footer ? (
<SheetFooter className="shrink-0 border-t">{footer}</SheetFooter>
) : null}
</form>
</FormProvider>
</SheetContent>
</Sheet>
)
}
+25 -107
View File
@@ -1,112 +1,30 @@
import { Link, useRouterState } from '@tanstack/react-router'
import {
LayoutDashboard,
Server,
List,
Shield,
BarChart3,
Settings,
LogOut,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@evofw/ui/components/sidebar'
import { Button } from '@evofw/ui/components/button'
import { Separator } from '@evofw/ui/components/separator'
import { logout, CURRENT_APP_ID } from '@/lib/auth'
import type { ReactNode } from 'react'
const NAV = [
{ to: '/', label: 'Дашборд', icon: LayoutDashboard },
{ to: '/agents', label: 'Агенты', icon: Server },
{ to: '/lists', label: 'Списки IP', icon: List },
{ to: '/rules', label: 'Правила', icon: Shield },
{ to: '/stats', label: 'Статистика', icon: BarChart3 },
{ to: '/settings', label: 'Настройки', icon: Settings },
] as const
import type { CSSProperties, ReactNode } from 'react'
import { AppSidebar } from '@/components/app-sidebar'
import { SiteHeader } from '@/components/layout/site-header'
import { SearchMenu } from '@/components/layout/search-menu'
import { TooltipProvider } from '@evofw/ui/components/tooltip'
import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar'
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as React.CSSProperties
}
>
<Sidebar collapsible="icon">
<SidebarHeader className="border-b px-3 py-3">
<div className="flex items-center gap-2 px-1">
<Shield className="size-5" />
<div className="flex flex-col">
<span className="text-sm font-semibold">EvoFirewall</span>
<span className="text-muted-foreground text-[10px] uppercase">
{CURRENT_APP_ID}
</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{NAV.map((item) => {
const Icon = item.icon
const active =
item.to === '/'
? pathname === '/'
: pathname.startsWith(item.to)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
isActive={active}
render={<Link to={item.to} />}
>
<Icon />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t p-2">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => logout()}
>
<LogOut className="size-4" />
Выйти
</Button>
</SidebarFooter>
</Sidebar>
<SidebarInset>
<header className="bg-background sticky top-0 z-10 flex h-12 items-center gap-2 border-b px-4">
<SidebarTrigger />
<Separator orientation="vertical" className="h-4" />
<span className="text-muted-foreground text-sm">Control plane</span>
</header>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</SidebarInset>
</SidebarProvider>
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</SidebarInset>
<SearchMenu hotkeyOnly />
</SidebarProvider>
</TooltipProvider>
)
}
@@ -0,0 +1,105 @@
import { Link } from '@tanstack/react-router'
import { LayoutGridIcon } from 'lucide-react'
import { Button } from '@evofw/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evofw/ui/components/dropdown-menu'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Header apps grid — app-shell-12 AppsMenu, wired to auth-portal App Switcher. */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={portalAppsUrl} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" />}
className="justify-center text-sm font-medium"
>
Настройки
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,182 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import {
BarChart3Icon,
LayoutDashboardIcon,
ListIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldIcon,
} from 'lucide-react'
import { Button } from '@evofw/ui/components/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@evofw/ui/components/dialog'
import { Input } from '@evofw/ui/components/input'
import {
Item,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@evofw/ui/components/item'
const NAV_ITEMS = [
{
to: '/',
label: 'Дашборд',
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
},
{
to: '/agents',
label: 'Агенты',
keywords: ['agents', 'агенты', 'nodes'],
icon: ServerIcon,
},
{
to: '/lists',
label: 'Списки IP',
keywords: ['lists', 'списки', 'blocklist'],
icon: ListIcon,
},
{
to: '/rules',
label: 'Правила',
keywords: ['rules', 'правила', 'policy'],
icon: ShieldIcon,
},
{
to: '/stats',
label: 'Статистика',
keywords: ['stats', 'статистика', 'packets'],
icon: BarChart3Icon,
},
{
to: '/settings',
label: 'Настройки',
keywords: ['settings', 'настройки'],
icon: SettingsIcon,
},
] as const
/** Command-K search — hotkey dialog (no header chrome trigger). */
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
const navigate = useNavigate()
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen(true)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
useEffect(() => {
if (!open) setQuery('')
}, [open])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return NAV_ITEMS
return NAV_ITEMS.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.keywords.some((k) => k.includes(q)),
)
}, [query])
function goTo(to: string) {
setOpen(false)
void navigate({ to })
}
return (
<>
{hotkeyOnly ? null : (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Поиск"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<SearchIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</Button>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Поиск</DialogTitle>
<DialogDescription>
Переход к разделам приложения
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
<div className="relative flex items-center gap-3 border-b px-4 py-2">
<SearchIcon
aria-hidden="true"
className="pointer-events-none size-4 opacity-60 select-none"
/>
<Input
id={searchInputId}
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Перейти к разделу…"
aria-label="Поиск разделов"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered[0]) {
e.preventDefault()
goTo(filtered[0].to)
}
}}
/>
</div>
<ItemGroup className="max-h-72 overflow-y-auto p-2">
{filtered.length === 0 ? (
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
Ничего не найдено
</p>
) : (
filtered.map((item) => (
<Item
key={item.to}
size="sm"
variant="muted"
className="cursor-pointer border-0"
render={<Link to={item.to} onClick={() => setOpen(false)} />}
>
<ItemMedia variant="icon">
<item.icon aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>{item.label}</ItemTitle>
</ItemContent>
</Item>
))
)}
</ItemGroup>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,109 @@
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
import { useMemo } from 'react'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@evofw/ui/components/breadcrumb'
import { Separator } from '@evofw/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { ModeToggle } from '@/components/mode-toggle'
import { SidebarTrigger } from '@evofw/ui/components/sidebar'
export interface RouteBreadcrumbLoaderData {
breadcrumb?: string
}
const routeTitles: Record<string, string> = {
'/': 'Дашборд',
'/agents': 'Агенты',
'/lists': 'Списки IP',
'/rules': 'Правила',
'/stats': 'Статистика',
'/settings': 'Настройки',
}
function getBreadcrumbs(
pathname: string,
dynamicLabels: Record<string, string>,
) {
if (pathname === '/') {
return [{ label: 'Дашборд', href: '/' }]
}
if (pathname.match(/^\/agents\/[^/]+$/)) {
return [
{ label: 'Агенты', href: '/agents' },
{ label: dynamicLabels[pathname] ?? 'Агент', href: pathname },
]
}
const title = routeTitles[pathname]
if (title) {
return [{ label: title, href: pathname }]
}
return [{ label: 'Дашборд', href: '/' }]
}
function useDynamicBreadcrumbLabels() {
const matches = useMatches()
return useMemo(() => {
const labels: Record<string, string> = {}
for (const match of matches) {
const data = match.loaderData as RouteBreadcrumbLoaderData | undefined
if (data?.breadcrumb && match.pathname) {
labels[match.pathname] = data.breadcrumb
}
}
return labels
}, [matches])
}
/** Header chrome — AppsMenu + SystemMonitor + ModeToggle (EvoBGP etalon). */
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels()
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return (
<span key={crumb.href} className="contents">
{index > 0 && (
<BreadcrumbSeparator className="hidden md:block" />
)}
<BreadcrumbItem
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
>
{isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink render={<Link to={crumb.href} />}>
{crumb.label}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
)
})}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
<ModeToggle />
</div>
</header>
)
}
@@ -0,0 +1,174 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evofw/ui/lib/utils'
import { Item, ItemMedia } from '@evofw/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@evofw/ui/components/popover'
import { Progress } from '@evofw/ui/components/progress'
import { Button } from '@evofw/ui/components/button'
import {
agentsQueryOptions,
dashboardQueryOptions,
listsQueryOptions,
rulesQueryOptions,
} from '@/queries'
type MonitorMetric = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: 'success' | 'warning' | 'destructive' | 'info'
alert: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
return 'var(--color-success)'
case 'warning':
return 'var(--color-warning)'
case 'destructive':
return 'var(--color-destructive)'
default:
return 'var(--color-info)'
}
}
function MetricCell({ metric }: { metric: MonitorMetric }) {
const color = toneColor(metric.tone)
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0"
style={{ backgroundColor: `${color}18` }}
>
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
</div>
<Progress
value={metric.percent}
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
style={{ '--bar-color': color } as CSSProperties}
/>
</div>
)
}
/** Live system monitor — FW dashboard/agents. Preview: app-shell-12 */
export function SystemMonitorPopover() {
const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 })
const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 })
const listsQ = useQuery({ ...listsQueryOptions(), refetchInterval: 60_000 })
const rulesQ = useQuery({ ...rulesQueryOptions(), refetchInterval: 60_000 })
const d = dashQ.data
const agents = agentsQ.data?.items ?? []
const approved = agents.filter((a) => a.status === 'approved').length
const online = d?.agents_online ?? agents.filter((a) => a.last_seen_at).length
const pending = d?.agents_pending ?? agents.filter((a) => a.status === 'pending').length
const onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0
const listsCount = listsQ.data?.items?.length ?? d?.lists_total ?? 0
const rulesCount = rulesQ.data?.items?.length ?? 0
const apiOk = !dashQ.isError
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'api',
label: 'API',
value: apiOk ? 'OK' : 'ERR',
unit: '',
percent: apiOk ? 100 : 0,
icon: <HeartPulse aria-hidden />,
tone: apiOk ? 'success' : 'destructive',
alert: !apiOk,
},
{
id: 'agents',
label: 'Агенты online',
value: String(onlinePct),
unit: '%',
percent: onlinePct,
icon: <Server aria-hidden />,
tone: onlinePct >= 80 ? 'success' : onlinePct >= 40 ? 'warning' : 'destructive',
alert: pending > 0 || (approved > 0 && onlinePct < 50),
},
{
id: 'lists',
label: 'Списки IP',
value: String(listsCount),
unit: '',
percent: Math.min(100, listsCount * 10),
icon: <List aria-hidden />,
tone: 'info',
alert: false,
},
{
id: 'rules',
label: 'Правила',
value: String(rulesCount),
unit: '',
percent: Math.min(100, rulesCount * 10),
icon: <Shield aria-hidden />,
tone: 'info',
alert: false,
},
],
[apiOk, onlinePct, pending, approved, listsCount, rulesCount],
)
const hasAlert = metrics.some((m) => m.alert)
return (
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon"
aria-label="Мониторинг системы"
className={cn(hasAlert && 'text-warning')}
/>
}
>
<Activity className="size-4.5" aria-hidden />
{hasAlert ? (
<Badge
variant="warning"
size="sm"
className="absolute top-1 right-1 size-1.5 rounded-full p-0"
aria-hidden
/>
) : null}
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0" sideOffset={8}>
<div className="border-b px-3 py-2">
<div className="text-sm font-medium">Система</div>
<div className="text-muted-foreground text-xs">
Агенты и политики EvoFirewall
</div>
</div>
<div className="grid grid-cols-2 divide-x divide-y">
{metrics.map((m) => (
<MetricCell key={m.id} metric={m} />
))}
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,32 @@
import type { ComponentProps } from 'react'
import { Button } from '@evofw/ui/components/button'
import { Spinner } from '@evofw/ui/components/spinner'
import { cn } from '@evofw/ui/lib/utils'
interface LoadingButtonProps extends ComponentProps<typeof Button> {
isLoading?: boolean
loadingLabel?: string
}
export function LoadingButton({
isLoading = false,
loadingLabel,
children,
disabled,
className,
...props
}: LoadingButtonProps) {
const label =
isLoading && loadingLabel != null ? loadingLabel : children
return (
<Button
disabled={disabled ?? isLoading}
className={cn(className)}
{...props}
>
{isLoading && <Spinner data-icon="inline-start" />}
{label}
</Button>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@evofw/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evofw/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>
)
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: ReactNode
actions?: ReactNode
}
/** Page-level section header — etalon EvoBGP / Domains list. */
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-px">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react'
import { cn } from '@evofw/ui/lib/utils'
interface PageShellProps {
children: ReactNode
className?: string
}
export function PageShell({ children, className }: PageShellProps) {
return (
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>
{children}
</div>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { CircleAlertIcon } from 'lucide-react'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
interface QueryStateProps {
isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
skeleton?: React.ReactNode
children: React.ReactNode
}
function DefaultSkeleton() {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
</div>
)
}
export function QueryState({
isLoading,
isError,
error,
onRetry,
skeleton,
children,
}: QueryStateProps) {
if (isLoading) {
return skeleton ?? <DefaultSkeleton />
}
if (isError) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Не удалось загрузить данные</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Произошла ошибка при загрузке.'}</span>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
return children
}
@@ -0,0 +1,116 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@evofw/ui/lib/utils'
export interface DetailMetricCard {
id: string
icon: ReactNode
label: string
description: string
footer?: ReactNode
}
interface DetailPanelProps {
children: ReactNode
className?: string
}
function DetailPanelRoot({ children, className }: DetailPanelProps) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
interface DetailPanelHeaderProps {
title: string
description?: string
actions?: ReactNode
children?: ReactNode
}
function DetailPanelHeader({
title,
description,
actions,
children,
}: DetailPanelHeaderProps) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-px">
<FrameTitle>{title}</FrameTitle>
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null}
</Frame>
)
}
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
return (
<div className="@container w-full">
<div className="grid gap-4 @2xl:grid-cols-3">
{cards.map((card) => (
<Frame key={card.id} spacing="sm">
<FrameHeader className="px-1! py-1!">
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
{card.icon}
<span className="text-foreground text-sm font-medium">
{card.label}
</span>
</div>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs leading-relaxed">
{card.description}
</p>
{card.footer}
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
function DetailPanelSection({
title,
description,
children,
}: {
title?: string
description?: string
children: ReactNode
}) {
return (
<section className="flex flex-col gap-3">
{title ? (
<header className="px-1">
<h2 className="text-sm font-semibold">{title}</h2>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
) : null}
{children}
</section>
)
}
export const DetailPanel = Object.assign(DetailPanelRoot, {
Header: DetailPanelHeader,
Metrics: DetailPanelMetrics,
Section: DetailPanelSection,
})
@@ -0,0 +1,91 @@
import type { Filter } from '@/components/reui/filters'
export function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
return false
}
if (values.every((value) => value === null || value === undefined)) {
return false
}
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
return false
}
return true
})
}
export function applyFiltersToData<T>(
data: T[],
filters: Filter[],
getFieldValue: (item: T, field: string) => unknown,
): T[] {
const active = getActiveFilters(filters)
let result = [...data]
for (const filter of active) {
const { field, operator, values } = filter
result = result.filter((item) => {
const raw = getFieldValue(item, field)
const fieldValue = raw != null ? raw : ''
switch (operator) {
case 'is':
return values.includes(fieldValue)
case 'is_not':
return !values.includes(fieldValue)
case 'is_any_of':
return values.some((value) => fieldValue === value)
case 'is_not_any_of':
return !values.some((value) => fieldValue === value)
case 'contains': {
const tokens = values
.map((value) => String(value).trim())
.filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
)
}
case 'not_contains':
return !values.some((value) =>
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
)
case 'starts_with':
return values.some((value) =>
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
)
case 'ends_with':
return values.some((value) =>
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
)
case 'empty':
return fieldValue === '' || fieldValue == null
case 'not_empty':
return fieldValue !== '' && fieldValue != null
default:
return true
}
})
}
return result
}
export function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
return null
}
export function renderSingleSelectedLabel(
values: unknown[],
options: { value: string; label: string }[],
) {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
return option?.label ?? String(values[0])
}
+24
View File
@@ -0,0 +1,24 @@
export {
applyFiltersToData,
getActiveFilters,
renderSingleSelectedLabel,
} from './filter-utils'
export {
ResourcePage,
type ResourcePageProps,
type ResourcePageTab,
} from './resource-page'
export {
KpiStatGrid,
type KpiStatCard,
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export { PageShell } from '@/components/page-shell'
export { PageHeader } from '@/components/page-header'
export { EmptyState } from '@/components/empty-state'
-104
View File
@@ -1,104 +0,0 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame } from '@/components/reui/frame'
import { cn } from '@evofw/ui/lib/utils'
export type KpiItem = {
id: string
label: string
value: string | number
hint?: string
to?: string
}
/** KPI grid — preview: https://reui.io/preview/base/stats-12 */
export function KpiStatGrid({
items,
className,
}: {
items: KpiItem[]
className?: string
}) {
return (
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-4', className)}>
{items.map((item) => {
const inner = (
<Frame dense className="h-full transition-colors hover:bg-muted/40">
<div className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{item.label}
</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">{item.value}</div>
{item.hint ? (
<div className="text-muted-foreground mt-1 text-xs">{item.hint}</div>
) : null}
</Frame>
)
return item.to ? (
<Link key={item.id} to={item.to} className="block no-underline">
{inner}
</Link>
) : (
<div key={item.id}>{inner}</div>
)
})}
</div>
)
}
export function PageShell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
)
}
export function PageHeader({
title,
description,
actions,
}: {
title: string
description?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-px">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</div>
)
}
export function EmptyState({
title,
description,
action,
}: {
title: string
description?: string
action?: ReactNode
}) {
return (
<Frame className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<div className="font-medium">{title}</div>
{description ? (
<p className="text-muted-foreground max-w-md text-sm">{description}</p>
) : null}
{action}
</Frame>
)
}
@@ -0,0 +1,205 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@evofw/ui/components/item'
import { cn } from '@evofw/ui/lib/utils'
import { Skeleton } from '@evofw/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
export interface KpiStatCard {
id: string
label: string
value: string | number
hint?: string
to?: string
search?: Record<string, unknown>
onSelect?: () => void
selected?: boolean
icon?: ReactNode
iconClassName?: string
/** Semantic color for the primary value */
variant?: KpiStatVariant
footer?: ReactNode
}
/** @deprecated Use KpiStatCard */
export type OpsKpiCard = KpiStatCard
interface KpiStatGridProps {
cards: KpiStatCard[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: 'text-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
}
function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function resolveFooter(card: KpiStatCard): ReactNode {
if (card.footer) return card.footer
if (card.hint) {
return (
<Badge variant="outline" size="sm">
{card.hint}
</Badge>
)
}
return null
}
function KpiStatCardBody({ card }: { card: KpiStatCard }) {
const footer = resolveFooter(card)
const valueVariant = card.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
{card.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
card.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
</ItemMedia>
</Item>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground text-sm font-medium">{card.label}</span>
{footer ? <div className="shrink-0">{footer}</div> : null}
</div>
<span
className={cn(
'text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{card.value}
</span>
</div>
</div>
)
}
function KpiStatCardItem({ card }: { card: KpiStatCard }) {
const interactive = Boolean(card.to || card.onSelect)
const panelClass = cn(
'relative isolate flex h-full flex-col',
card.selected && 'ring-primary/30 bg-muted/30 ring-1',
interactive &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
)
if (card.to) {
return (
<FramePanel className={panelClass}>
<Link to={card.to} search={card.search} className="focus-visible:outline-none">
<KpiStatCardBody card={card} />
</Link>
</FramePanel>
)
}
if (card.onSelect) {
return (
<FramePanel className={panelClass}>
<button
type="button"
onClick={card.onSelect}
className="w-full text-start focus-visible:outline-none"
>
<KpiStatCardBody card={card} />
</button>
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody card={card} />
</FramePanel>
)
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn('grid gap-2', kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
</Frame>
)
}
/**
* Hybrid KPI — EvoBGP visual (colored icon + Badge) + horizontal compact layout.
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
if (cards.length === 0) {
return (
<Frame dense spacing="sm" className={cn('w-full', className)}>
<FramePanel className="flex items-center gap-3 p-4">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
return (
<Frame className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(cards.length))}>
{cards.map((card) => (
<KpiStatCardItem key={card.id} card={card} />
))}
</div>
</Frame>
)
}
@@ -0,0 +1,79 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
export type OpsKpiCard = KpiStatCard
interface OpsDashboardProps {
kpiCards: OpsKpiCard[]
/** Optional slot under KPI (e.g. QuickActionGrid) */
afterKpi?: ReactNode
charts: ReactNode
queue: ReactNode
isLoading?: boolean
}
/** Denser stack for KPI / charts / queue. PageHeader lives outside via PageShell. */
const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
function OpsDashboardSkeleton() {
return (
<div className={rootClassName}>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="grid gap-2 @3xl:grid-cols-2">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
<Skeleton className="h-48 w-full rounded-xl" />
</div>
)
}
export function OpsDashboard({
kpiCards,
afterKpi,
charts,
queue,
isLoading = false,
}: OpsDashboardProps) {
if (isLoading) {
return <OpsDashboardSkeleton />
}
return (
<div className={rootClassName}>
<section aria-label="Ключевые метрики">
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
</section>
{afterKpi}
<section
aria-label="Аналитика"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
{charts}
</section>
<section aria-label="Требуют внимания">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription>
Pending-агенты, ошибки apply и списки с last_error
</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
</section>
</div>
)
}
@@ -0,0 +1,113 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@evofw/ui/components/item'
import { cn } from '@evofw/ui/lib/utils'
export interface QuickActionItem {
id: string
title: string
description: string
to: string
search?: Record<string, unknown>
icon?: ReactNode
iconClassName?: string
}
interface QuickActionGridProps {
actions: QuickActionItem[]
title?: string
description?: string
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
action.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{action.icon}
</ItemMedia>
</Item>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
Перейти
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
</div>
</div>
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12
*/
export function QuickActionGrid({
actions,
title = 'Быстрые действия',
description,
className,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div className={cn('grid gap-2', kpiCols(actions.length))}>
{actions.map((action) => (
<FramePanel
key={action.id}
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
>
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`}
>
<QuickActionBody action={action} />
</Link>
</FramePanel>
))}
</div>
</Frame>
)
}
@@ -0,0 +1,391 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
Filters,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@evofw/ui/components/button'
import { Separator } from '@evofw/ui/components/separator'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils'
export interface ResourcePageTab {
id: string
label: string
count?: number
}
export interface ResourcePageProps<T extends object> {
title: string
description?: string
tabs?: ResourcePageTab[]
activeTab?: string
onTabChange?: (tabId: string) => void
tabFilter?: (item: T, tabId: string) => boolean
filterFields: FilterFieldConfig[]
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
onClearFilters?: () => void
getFilterFieldValue: (item: T, field: string) => unknown
columns: ColumnDef<T, unknown>[]
data: T[]
getRowId: (row: T) => string
isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode }
pageSize?: number
enableRowSelection?: boolean
selectionToolbar?: (ctx: {
selectedIds: string[]
selectedCount: number
clearSelection: () => void
}) => ReactNode
toolbarExtra?: ReactNode
hideHeader?: boolean
}
function ResourcePageSkeleton() {
return (
<Frame dense variant="default" spacing="sm" className="w-full">
<FrameHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-1 h-4 w-72" />
</FrameHeader>
<FramePanel className="p-0">
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-9 w-full max-w-md" />
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</FramePanel>
</Frame>
)
}
export function ResourcePage<T extends object>({
title,
description,
tabs,
activeTab: controlledTab,
onTabChange,
tabFilter,
filterFields,
filters,
onFiltersChange,
onClearFilters,
getFilterFieldValue,
columns,
data,
getRowId,
isLoading = false,
isError = false,
error = null,
onRetry,
primaryAction,
emptyState,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
toolbarExtra,
hideHeader = false,
}: ResourcePageProps<T>) {
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize,
})
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
)
}, [])
const filteredData = useMemo(() => {
let result = applyFiltersToData(data, filters, getFilterFieldValue)
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
const base = applyFiltersToData(data, filters, getFilterFieldValue)
const counts: Record<string, number> = {}
for (const tab of tabs) {
counts[tab.id] =
tab.id === 'all'
? base.length
: base.filter((item) => tabFilter(item, tab.id)).length
}
return counts
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
)
const selectedCount = selectedIds.length
const clearSelection = useCallback(() => {
setRowSelection({})
}, [])
const table = useReactTable({
data: filteredData,
columns,
getRowId,
state: { sorting, rowSelection, pagination },
enableRowSelection,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const handleTabChange = useCallback(
(value: string) => {
if (onTabChange) onTabChange(value)
else setInternalTab(value)
resetPagination()
},
[onTabChange, resetPagination],
)
const handleFiltersChange = useCallback(
(next: Filter[]) => {
onFiltersChange(next)
resetPagination()
},
[onFiltersChange, resetPagination],
)
const handleClear = useCallback(() => {
onClearFilters?.()
resetPagination()
}, [onClearFilters, resetPagination])
const countedTabs = useMemo(
() =>
(tabs ?? []).map((tab) => ({
id: tab.id,
label: tab.label,
count: tabCounts[tab.id] ?? tab.count ?? 0,
})),
[tabs, tabCounts],
)
if (isLoading) {
return <ResourcePageSkeleton />
}
if (isError) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
{onRetry ? (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
if (data.length === 0 && emptyState) {
return (
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="text-xs text-pretty">
{description}
</FrameDescription>
) : null}
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-stretch justify-center p-0">
<EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
/>
</FramePanel>
</Frame>
)
}
const emptyMessage = 'Нет записей по выбранным фильтрам.'
return (
<div className="w-full">
{selectionToolbar && selectedCount > 0
? selectionToolbar({
selectedIds,
selectedCount,
clearSelection,
})
: null}
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage={emptyMessage}
tableLayout={{ dense: true }}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="tabular-nums">
{filteredData.length} записей
</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
) : null}
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!">
{countedTabs.length > 0 ? (
<>
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={countedTabs}
value={activeTab}
onValueChange={handleTabChange}
/>
</div>
<Separator />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{onClearFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</div>
</div>
<Separator />
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
</div>
)
}
@@ -0,0 +1,98 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { PaletteIcon, SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@evofw/ui/hooks/use-mobile'
import { cn } from '@evofw/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
}
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'appearance',
to: '/settings/appearance',
label: 'Внешний вид',
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
label: 'Интеграции',
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
}
export function SettingsShell({
title = 'Настройки',
description = 'Внешний вид и интеграции',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<PageHeader title={title} description={description} />
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
</div>
</PageShell>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evofw/ui/lib/utils"
const alertVariants = cva(
[
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
"rounded-lg",
"px-3",
"py-2.5",
"has-[>svg]:gap-x-2.5",
],
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
info: "border-info/30 bg-info/4 [&>svg]:text-info",
success: "border-success/30 bg-success/4 [&>svg]:text-success",
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
invert:
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn(
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+102
View File
@@ -0,0 +1,102 @@
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 "@evofw/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":
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
"warning-light":
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
"success-light":
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
"info-light":
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
"destructive-light":
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
"invert-light":
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
"focus-light":
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
"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`: active style radius. `full`: pill radius. */
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,187 @@
"use client"
"use no memo"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { Input } from "@evofw/ui/components/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@evofw/ui/components/popover"
import { Separator } from "@evofw/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 filterValue = column?.getFilterValue()
const selectedValues = new Set(
Array.isArray(filterValue) ? (filterValue 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="px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="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)
const facetCount = facets?.get(option.value)
const toggleOption = () => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
return (
<div
key={option.value}
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={toggleOption}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggleOption()
}
}}
className={cn(
"rounded-md relative flex cursor-pointer items-center gap-2 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 rounded-sm flex h-4 w-4 items-center justify-center 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 h-4 w-4" />
)}
<span>{option.label}</span>
{facetCount !== undefined && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facetCount}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
role="button"
tabIndex={0}
onClick={() => column?.setFilterValue(undefined)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
column?.setFilterValue(undefined)
}
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center 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,355 @@
"use no memo"
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 "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@evofw/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
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
// back to the definition order so Move Left/Right work out of the box.
const columnOrderState = table.getState().columnOrder
const columnOrder =
columnOrderState.length > 0
? columnOrderState
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? 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 px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
))
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="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading}
>
{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="rounded-lg -me-1 size-7"
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="-ms-2 flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading}
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,54 @@
"use client"
"use no memo"
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 "@evofw/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,227 @@
"use no memo"
import React, { ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evofw/ui/components/select"
import { Skeleton } from "@evofw/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],
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
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 = "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 = recordCount === 0 ? 0 : 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
.replaceAll("{from}", from.toString())
.replaceAll("{to}", to.toString())
.replaceAll("{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-16" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="min-w-(--anchor-width)"
>
{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 order-2 text-sm text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1">
<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,469 @@
"use client"
"use no memo"
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 "@evofw/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
const SCROLLBAR_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"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
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, table } = 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
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
// track is inset to span only the scrollable center region between them.
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
const scrollbarInsetStart = isColumnsPinnable ? table.getLeftTotalSize() : 0
const scrollbarInsetEnd = isColumnsPinnable ? table.getRightTotalSize() : 0
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
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
const observed = new Set<HTMLElement>()
const observeElement = (element: HTMLElement | null) => {
if (element && observer && !observed.has(element)) {
observer.observe(element)
observed.add(element)
}
}
const resolveObservedElements = () => {
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,
}
observeElement(observedElementsRef.current.header)
observeElement(observedElementsRef.current.table)
observeElement(observedElementsRef.current.tableViewport)
return !!(
observedElementsRef.current.header && observedElementsRef.current.table
)
}
observeElement(viewport)
const resolvedOnMount = resolveObservedElements()
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
// A table that mounts after this effect (empty state swapped for data)
// would otherwise never be observed and the custom scrollbar would
// overlap the sticky header. One-shot: disconnects once resolved.
let mutationObserver: MutationObserver | null = null
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
mutationObserver = new MutationObserver(() => {
if (resolveObservedElements()) {
mutationObserver?.disconnect()
mutationObserver = null
scheduleSync()
}
})
mutationObserver.observe(container, { childList: true, subtree: true })
}
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
mutationObserver?.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={SCROLLBAR_CLASSNAME}
style={
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
? {
marginInlineStart: scrollbarInsetStart || undefined,
marginInlineEnd: scrollbarInsetEnd || undefined,
}
: undefined
}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</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,346 @@
"use no memo"
import {
createContext,
CSSProperties,
memo,
ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
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,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
HeaderGroup,
Row,
Table,
} from "@tanstack/react-table"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/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
)}
aria-label="Drag to reorder row"
disabled
>
<GripHorizontalIcon aria-hidden="true" />
</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
)}
aria-label="Drag to reorder row"
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon aria-hidden="true" />
</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}>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
return (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</SortableRowContext.Provider>
)
}
function DataGridTableDndRowsBody<TData>({
table,
dataIds,
}: {
table: Table<TData>
dataIds: UniqueIdentifier[]
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{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>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<SortableContext items={dataIds} strategy={verticalListSortingStrategy}>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
)
}
/**
* Memoized body rows: 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 MemoizedDataGridTableDndRowsBody = memo(
DataGridTableDndRowsBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableDndRowsBody
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, props } = useDataGrid()
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
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 key={index} rowId={headerGroup.id}>
{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>
)
})}
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndRowsBody table={table} dataIds={dataIds} />
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -0,0 +1,350 @@
"use client"
"use no memo"
import {
CSSProperties,
Fragment,
memo,
ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
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,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
Table,
} from "@tanstack/react-table"
import { Button } from "@evofw/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>
)}
<div className="grow">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{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 DataGridTableDndBodyRows<TData>({ table }: { table: Table<TData> }) {
const { isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{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>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<>
{table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</Fragment>
)
})}
</>
)
}
/**
* Memoized body rows: 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 MemoizedDataGridTableDndBodyRows = memo(
DataGridTableDndBodyRows,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableDndBodyRows
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, props } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
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 modifiers = useMemo(() => {
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 [restrictToTableBounds]
}, [])
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={modifiers}
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 key={index} rowId={headerGroup.id}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndBodyRows table={table} />
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -0,0 +1,633 @@
"use no memo"
import {
CSSProperties,
memo,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridScrollAreaViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table"
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@evofw/ui/lib/utils"
import { Spinner } from "@evofw/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>
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 DataGridTableVirtualPinnedPlaceholderCell<TData>({
column,
}: {
column: Column<TData>
}) {
const { props } = useDataGrid()
const isPinned = column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right")
return (
<td
aria-hidden="true"
style={{
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(props.tableLayout?.columnsResizable && {
width: `calc(var(--col-${column.id}-size) * 1px)`,
}),
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
className={cn(
"p-0",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
)}
/>
)
}
function DataGridTableVirtualUtilityRow<TData>({
table,
children,
centerCellClassName,
centerCellStyle,
rowClassName,
ariaHidden,
}: {
table: Table<TData>
children: ReactNode
centerCellClassName?: string
centerCellStyle?: CSSProperties
rowClassName?: string
ariaHidden?: boolean
}) {
const { props } = useDataGrid()
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
{leftVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
<td
colSpan={Math.max(centerVisibleColumns.length, 1)}
className={centerCellClassName}
style={centerCellStyle}
>
{children}
</td>
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</tr>
)
}
function DataGridTableVirtualSpacer<TData>({
table,
height,
}: {
table: Table<TData>
height: number
}) {
if (height <= 0) return null
return (
<DataGridTableVirtualUtilityRow
table={table}
ariaHidden
centerCellClassName="p-0"
centerCellStyle={{ height, padding: 0 }}
>
{null}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualStatusRow<TData>({
table,
children,
className,
}: {
table: Table<TData>
children: ReactNode
className?: string
}) {
return (
<DataGridTableVirtualUtilityRow
table={table}
centerCellClassName={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualBody<TData>({
table,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const { isLoading } = useDataGrid()
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) {
// Initial load must not flash the empty state as if the query returned
// nothing.
if (isLoading) {
return (
<DataGridTableVirtualStatusRow table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
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"
table={table}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
rowIndex={virtualRow.index}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
table={table}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
<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"
table={table}
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 mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
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
? (getDataGridScrollAreaViewport(node) ?? node)
: null,
})
}, [])
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 = Math.max(0, fetchMoreOffset)
// Latch onFetchMore per row count: virtualItems gets a new identity every
// scroll frame, so without it the effect fires duplicate page requests
// before the consumer flips isFetchingMore, and loops at end-of-data when
// hasMore is never set.
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
fetchMoreFiredAtCountRef.current = centerRows.length
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",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
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,357 @@
"use no memo"
import { createContext, ReactNode, useContext, useMemo, useRef } from "react"
import {
Column,
ColumnFiltersState,
RowData,
SortingState,
Table,
} from "@tanstack/react-table"
import { cn } from "@evofw/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
autoSize?: boolean
}
}
/** 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
/**
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
* so every table variant and viewport instance shares one application state.
*/
autoSize?: DataGridAutoSizeController
}
export type DataGridAutoSizeController = {
/**
* Grows the first visible `meta.autoSize` column by the given free space.
* Applies at most once per column id; safe to call from every viewport
* measurement. Returns true when a sizing update was dispatched.
*/
apply: (fillWidth: number) => boolean
}
function createDataGridAutoSizeController<TData extends object>(
table: Table<TData>
): DataGridAutoSizeController {
let applied: { columnId: string; base: number; grown: number } | null = null
return {
apply(fillWidth: number) {
const columnSizing = table.getState().columnSizing
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
// controlled state replacement) so the column re-fills instead of
// leaving a dead blank strip.
if (applied && columnSizing[applied.columnId] === undefined) {
applied = null
}
if (fillWidth <= 0) return false
const autoSizeColumn = table
.getVisibleLeafColumns()
.find(
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
)
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
return false
}
// Candidate switched (e.g. the grown column was hidden and another
// meta.autoSize column took over): revert the previous growth if the
// user hasn't manually resized that column since, so visibility
// toggles cannot ratchet the table wider than its container forever.
const revert =
applied && columnSizing[applied.columnId] === applied.grown
? applied
: null
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
const grown = base + fillWidth
applied = { columnId: autoSizeColumn.id, base, grown }
table.setColumnSizing((old) => {
const next = { ...old, [autoSizeColumn.id]: grown }
if (revert && next[revert.columnId] === revert.grown) {
next[revert.columnId] = revert.base
}
return next
})
return true
},
}
}
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
footerBackground?: 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<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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()
// Latest-props ref: context reads always resolve fresh props through the
// getter below without the memoized context value depending on unstable
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
// otherwise publish a new context value on every consumer render - at
// mousemove rate during a resize drag, piercing the body-rows memo).
const propsRef = useRef(props)
propsRef.current = props
// Re-assert an explicit tableLayout resize mode every render so
// consumer-level useReactTable options cannot flip it back between drags.
// Without one, the consumer's own tanstack columnResizeMode (default
// "onEnd") is honored.
if (
props.tableLayout?.columnsResizable &&
props.tableLayout.columnsResizeMode
) {
table.options.columnResizeMode = props.tableLayout.columnsResizeMode
}
// One autoSize coordinator per table instance so split header/body viewports
// cannot apply the growth twice.
const autoSize = useMemo(
() => createDataGridAutoSizeController(table),
[table]
)
// 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.
// ReactNode/function props (messages, onRowClick) are also excluded: they
// are served fresh through the props getter, so unstable inline identities
// cannot invalidate the context value.
const value = useMemo(
() => ({
get props() {
return propsRef.current
},
table,
recordCount: props.recordCount,
isLoading: props.isLoading || false,
autoSize,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
autoSize,
props.recordCount,
props.isLoading,
props.loadingMode,
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.rowPinning,
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: false,
footerBackground: false,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
// columnsResizeMode has no default on purpose: when unset, the
// consumer's tanstack columnResizeMode (default "onEnd") is honored.
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,
}: {
children: ReactNode
className?: string
/** Accepted for backwards compatibility; currently has no effect. */
border?: boolean
}) {
return (
<div
data-slot="data-grid"
className={cn("w-full overflow-hidden", className)}
>
{children}
</div>
)
}
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
File diff suppressed because it is too large Load Diff
+157 -41
View File
@@ -1,59 +1,175 @@
import type { ReactNode } from 'react'
import { cn } from '@evofw/ui/lib/utils'
import { cva, type VariantProps } from "class-variance-authority"
/** Minimal Frame surface (ReUI Frame contract) — preview: https://reui.io/docs/components/base/frame */
export function Frame({
children,
import { cn } from "@evofw/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame → all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
* :not() or !important needed
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
},
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
function Frame({
className,
variant,
spacing,
stacked,
dense,
}: {
children: ReactNode
className?: string
dense?: boolean
}) {
...props
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
return (
<div
className={cn(
'bg-card text-card-foreground rounded-xl border shadow-xs',
dense ? 'p-3' : 'p-4 md:p-5',
className,
frameVariants({ variant, spacing, stacked, dense }),
className
)}
>
{children}
</div>
data-slot="frame"
data-spacing={spacing}
{...props}
/>
)
}
export function FrameHeader({
children,
function FramePanel({
className,
}: {
children: ReactNode
className?: string
}) {
fit,
...props
}: React.ComponentProps<"div"> & { fit?: boolean }) {
return (
<div className={cn('mb-3 flex flex-wrap items-start justify-between gap-2', className)}>
{children}
</div>
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
)}
data-slot="frame-panel"
{...props}
/>
)
}
export function FrameTitle({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return <h2 className={cn('text-base font-semibold tracking-tight', className)}>{children}</h2>
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
return (
<header
className={cn(
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
className
)}
data-slot="frame-panel-header"
{...props}
/>
)
}
export function FrameDescription({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return <p className={cn('text-muted-foreground text-sm', className)}>{children}</p>
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm font-semibold", className)}
data-slot="frame-panel-title"
{...props}
/>
)
}
function FrameDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-panel-description"
{...props}
/>
)
}
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
className
)}
data-slot="frame-panel-footer"
{...props}
/>
)
}
export {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
frameVariants,
}
@@ -0,0 +1,90 @@
import { cn } from "@evofw/ui/lib/utils"
type IconStackProps = React.ComponentProps<"div">
function IconStack({ className, children, style, ...props }: IconStackProps) {
return (
<div
data-slot="icon-stack"
className={cn(
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
className
)}
style={
{
"--icon-stack-content-x": "71%",
"--icon-stack-content-y": "58%",
...style,
} as React.CSSProperties
}
{...props}
>
<svg
aria-hidden="true"
viewBox="0 0 72 81"
fill="none"
className="h-full w-full overflow-visible"
>
<ellipse
cx="36"
cy="76"
rx="30"
ry="7"
fill="currentColor"
fillOpacity="0.055"
className="blur-[4px]"
/>
<IconStackLayer opacity="0.4" />
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
</svg>
{children ? (
<div
data-slot="icon-stack-content"
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
>
{children}
</div>
) : null}
</div>
)
}
function IconStackLayer({
active = false,
opacity,
x = 0,
y = 0,
}: {
active?: boolean
opacity: string
x?: number
y?: number
}) {
return (
<g opacity={opacity} transform={`translate(${x} ${y})`}>
<path
data-slot="icon-stack-layer"
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
data-slot="icon-stack-layer"
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
)
}
export { IconStack, type IconStackProps }
+88
View File
@@ -0,0 +1,88 @@
import { type ReactNode } from 'react'
import { cn } from '@evofw/ui/lib/utils'
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSeparator,
FieldTitle,
} from '@evofw/ui/components/field'
export interface SettingRowProps {
title: string
description?: ReactNode
children: ReactNode
last?: boolean
/** Opt-in FieldSeparator after the row (default off — ReUI settings use Frame+gap). */
separated?: boolean
compact?: boolean
stacked?: boolean
labelFor?: string
contentClassName?: string
className?: string
titleAddon?: ReactNode
}
/** Compact settings row (REUI PRO profile-1 / settings-9 pattern). */
export function SettingRow({
title,
description,
children,
last,
separated = false,
compact,
stacked,
labelFor,
contentClassName,
className,
titleAddon,
}: SettingRowProps) {
return (
<>
<Field
orientation={stacked ? 'vertical' : 'responsive'}
className={cn('gap-4 px-5 py-4', className)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
<div className="flex flex-wrap items-center gap-2">
{labelFor ? (
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
) : (
<FieldTitle>{title}</FieldTitle>
)}
{titleAddon}
</div>
{description ? (
<FieldDescription className="text-sm">{description}</FieldDescription>
) : null}
</div>
<FieldContent
className={cn(
'w-full min-w-0 @md/field-group:flex-1',
stacked
? 'max-w-none'
: compact
? '@md/field-group:max-w-[17rem] @md/field-group:shrink-0'
: '@md/field-group:max-w-[34rem]',
contentClassName,
)}
>
<div
className={cn(
'flex w-full justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
{children}
</div>
</FieldContent>
</Field>
{separated && !last ? <FieldSeparator /> : null}
</>
)
}
+68
View File
@@ -0,0 +1,68 @@
import type { ComponentProps } from 'react'
import { cn } from '@evofw/ui/lib/utils'
import { Badge } from '@/components/reui/badge'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success-light',
synced: 'success-light',
ok: 'success-light',
up: 'success-light',
pending_push: 'secondary',
warning: 'warning-light',
degraded: 'warning-light',
conflict: 'destructive-light',
error: 'destructive-light',
expired: 'destructive-light',
down: 'destructive-light',
unknown: 'outline',
}
const DOT_COLOR: Record<string, string> = {
'success-light': 'bg-success',
success: 'bg-success',
'warning-light': 'bg-warning',
warning: 'bg-warning',
'destructive-light': 'bg-destructive',
destructive: 'bg-destructive',
'info-light': 'bg-info',
secondary: 'bg-muted-foreground',
outline: 'bg-muted-foreground',
}
const STATUS_LABELS: Record<string, string> = {
active: 'Активен',
synced: 'Синхронизировано',
pending_push: 'Ожидает отправки',
conflict: 'Конфликт',
error: 'Ошибка',
ok: 'OK',
up: 'OK',
warning: 'Предупреждение',
degraded: 'Slow',
down: 'Down',
expired: 'Истёк',
unknown: 'Неизвестно',
}
export function StatusBadge({
status,
label,
className,
}: {
status: string
label?: string
className?: string
}) {
const variant = STATUS_VARIANT[status] ?? 'outline'
const dotColor = DOT_COLOR[variant] ?? 'bg-muted-foreground'
return (
<Badge variant={variant} size="sm" radius="full" className={cn('gap-1.5', className)}>
<span className={cn('size-1.5 shrink-0 rounded-full', dotColor)} aria-hidden />
{label ?? STATUS_LABELS[status] ?? status}
</Badge>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import type { AppSwitcherConfig } from '@evofw/shared'
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
import {
DEFAULT_APP_SWITCHER_CONFIG,
getAppUrl as getAppUrlFromConfig,
} from '@/lib/app-switcher-config'
import { getClaims } from '@/lib/auth'
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
isLoading: boolean
} {
const { data, isLoading } = useQuery(appSwitcherQueryOptions())
const claims = getClaims()
const config = useMemo(() => {
const raw = data ?? DEFAULT_APP_SWITCHER_CONFIG
const apps = raw.apps.filter((a) => (a as { enabled?: boolean }).enabled !== false)
const allowed = claims?.apps
if (!allowed?.length) {
return { ...raw, apps }
}
const set = new Set(allowed)
return {
...raw,
apps: apps.filter((a) => set.has(a.id)),
}
}, [data, claims?.apps])
return { config, isLoading }
}
export function useAppUrl(appId: string): string | undefined {
const { config } = useAppSwitcherConfig()
return getAppUrlFromConfig(appId, config)
}
+73
View File
@@ -0,0 +1,73 @@
import {
ChartBarIcon,
CloudIcon,
GlobeIcon,
LayoutDashboardIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import type { AppSwitcherConfig, AppSwitcherEntry } from '@evofw/shared'
/** JWT / portal app id for this product */
export const CURRENT_APP_ID = 'fw'
export type AppSwitcherIconName = keyof typeof APP_SWITCHER_ICONS
export const APP_SWITCHER_ICONS: Record<
'server' | 'cloud' | 'globe' | 'dashboard' | 'chart',
LucideIcon
> = {
server: ServerIcon,
cloud: CloudIcon,
globe: GlobeIcon,
dashboard: LayoutDashboardIcon,
chart: ChartBarIcon,
}
/** Offline fallback when auth-portal is unreachable */
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
menuLabel: 'Приложения',
apps: [
{
id: 'fw',
name: 'EvoFirewall',
subtitle: 'Firewall controller',
url: 'http://localhost:5177',
icon: 'server',
},
{
id: 'vps',
name: 'VPS Tracker',
subtitle: 'Учёт виртуальных серверов',
url: 'https://vps.shnt.top',
icon: 'server',
},
{
id: 'cfdm',
name: 'CF Domain Manager',
subtitle: 'Управление доменами',
url: 'https://cfdm.shnt.top',
icon: 'cloud',
},
{
id: 'bgp',
name: 'EvoBGP',
subtitle: 'BGP маршрутизация',
url: 'https://bgp.shnt.top',
icon: 'globe',
},
],
}
export function getAppUrl(
appId: string,
config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG,
): string | undefined {
return config.apps.find((app) => app.id === appId)?.url
}
export function getCurrentApp(
config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG,
): AppSwitcherEntry {
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
}
+6
View File
@@ -118,6 +118,12 @@ export function redirectToPortalLogin(returnTo: string) {
window.location.href = url
}
export function getClaims(): AccessClaims | null {
const t = getToken()
if (!t) return null
return parseClaims(t)
}
export function logout() {
clearToken()
window.location.href = `${authPortalUrl()}/logout`
+28
View File
@@ -0,0 +1,28 @@
import { queryOptions } from '@tanstack/react-query'
import type { AppSwitcherConfig } from '@evofw/shared'
import { ensureAuthConfig } from '@/lib/auth'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const
async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
const { portalUrl } = await ensureAuthConfig()
const base = portalUrl.replace(/\/$/, '')
const res = await fetch(`${base}/api/v1/app-switcher`, {
headers: { Accept: 'application/json' },
})
if (!res.ok) {
throw new Error(`app-switcher ${res.status}`)
}
return (await res.json()) as AppSwitcherConfig
}
export function appSwitcherQueryOptions() {
return queryOptions({
queryKey: appSwitcherQueryKey,
queryFn: fetchPortalAppSwitcher,
staleTime: 60_000,
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
retry: 1,
})
}
+244 -150
View File
@@ -1,9 +1,23 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import {
BanIcon,
CheckCircle2Icon,
CpuIcon,
ClockIcon,
} from 'lucide-react'
import { PageHeader, PageShell, DetailPanel } from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import {
agentQueryOptions,
agentsQueryOptions,
@@ -20,14 +34,11 @@ import {
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { PolicyRule } from '@evofw/shared'
import { Skeleton } from '@evofw/ui/components/skeleton'
export const Route = createFileRoute('/_auth/agents/$id')({
component: AgentDetailPage,
@@ -83,154 +94,237 @@ function AgentDetailPage() {
onError: (e: Error) => toast.error(e.message),
})
const ruleColumns: ColumnDef<PolicyRule>[] = useMemo(
() => [
{ accessorKey: 'priority', header: 'Prio' },
{
accessorKey: 'action',
header: 'Action',
cell: ({ row }) => (
<Badge
variant={
row.original.action === 'deny'
? 'destructive-light'
: 'success-light'
}
size="sm"
>
{row.original.action}
</Badge>
),
},
{
id: 'source',
header: 'Source',
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.cidr ?? row.original.list_id ?? '—'}
</span>
),
},
],
[],
)
const rulesTable = useReactTable({
data: rulesQ.data?.items ?? [],
columns: ruleColumns,
getCoreRowModel: getCoreRowModel(),
getRowId: (r) => r.id,
})
const a = agentQ.data
if (!a) {
return <PageShell><PageHeader title="Агент" description="Загрузка…" /></PageShell>
if (agentQ.isLoading || !a) {
return (
<PageShell>
<PageHeader title="Агент" description="Загрузка…" />
<Skeleton className="h-40 w-full rounded-xl" />
</PageShell>
)
}
return (
<PageShell>
<PageHeader
title={a.name}
description={`${a.platform} · ${a.status} · gen ${a.policy_generation}`}
actions={
<Link to="/agents" className="inline-flex">
<Button variant="outline" type="button">
К списку
</Button>
</Link>
}
/>
<div className="grid gap-4 lg:grid-cols-2">
<Frame>
<FrameHeader>
<FrameTitle>Политика</FrameTitle>
<FrameDescription>
blacklist = deny set; whitelist = allow set + default drop
</FrameDescription>
</FrameHeader>
<div className="flex flex-wrap gap-2">
<Button
variant={a.policy_mode === 'blacklist' ? 'default' : 'outline'}
onClick={() => patchMode.mutate('blacklist')}
>
Blacklist
</Button>
<Button
variant={a.policy_mode === 'whitelist' ? 'default' : 'outline'}
onClick={() => patchMode.mutate('whitelist')}
>
Whitelist
</Button>
</div>
<dl className="mt-4 grid grid-cols-2 gap-2 text-sm">
<dt className="text-muted-foreground">Dropped</dt>
<dd className="tabular-nums">{a.last_apply_packets_dropped ?? 0}</dd>
<dt className="text-muted-foreground">Accepted</dt>
<dd className="tabular-nums">{a.last_apply_packets_accepted ?? 0}</dd>
<dt className="text-muted-foreground">Kernel</dt>
<dd>{a.last_apply_kernel_method ?? '—'}</dd>
<dt className="text-muted-foreground">Last apply</dt>
<dd className="text-xs">{a.last_apply_at ?? '—'}</dd>
</dl>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Мгновенный IP override</FrameTitle>
<FrameDescription>
Обновится на агенте на следующей итерации sync (~1 мин)
</FrameDescription>
</FrameHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label>CIDR / IP</Label>
<Input
placeholder="1.2.3.4/32"
value={cidr}
onChange={(e) => setCidr(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Действие</Label>
<Select
value={action}
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
<DetailPanel>
<DetailPanel.Header
title={a.name}
description={`${a.platform} · gen ${a.policy_generation}`}
actions={
<div className="flex items-center gap-2">
<Badge
variant={
a.status === 'approved'
? 'success-light'
: a.status === 'pending'
? 'warning-light'
: 'secondary'
}
size="sm"
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
{a.status}
</Badge>
<Link to="/agents" className="inline-flex">
<Button variant="outline" type="button">
К списку
</Button>
</Link>
</div>
<Button
onClick={() => addOverride.mutate()}
disabled={!cidr || addOverride.isPending}
>
Добавить override
</Button>
</div>
</Frame>
}
/>
<DetailPanel.Metrics
cards={[
{
id: 'dropped',
icon: <BanIcon aria-hidden />,
label: 'Dropped',
description: String(a.last_apply_packets_dropped ?? 0),
},
{
id: 'accepted',
icon: <CheckCircle2Icon aria-hidden />,
label: 'Accepted',
description: String(a.last_apply_packets_accepted ?? 0),
},
{
id: 'kernel',
icon: <CpuIcon aria-hidden />,
label: 'Kernel',
description: a.last_apply_kernel_method ?? '—',
},
{
id: 'apply',
icon: <ClockIcon aria-hidden />,
label: 'Last apply',
description: a.last_apply_at ?? '—',
},
]}
/>
<Frame>
<FrameHeader>
<FrameTitle>Копировать правила</FrameTitle>
</FrameHeader>
<div className="flex flex-col gap-3">
<Select value={cloneFrom} onValueChange={setCloneFrom}>
<SelectTrigger>
<SelectValue placeholder="Источник" />
</SelectTrigger>
<SelectContent>
{(agentsQ.data?.items ?? [])
.filter((x) => x.id !== id)
.map((x) => (
<SelectItem key={x.id} value={x.id}>
{x.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
disabled={!cloneFrom || clone.isPending}
onClick={() => clone.mutate()}
>
Клонировать (с overrides)
</Button>
</div>
</Frame>
<DetailPanel.Section>
<div className="grid gap-4 lg:grid-cols-2">
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Политика</FrameTitle>
<FrameDescription>
blacklist = deny set; whitelist = allow set + default drop
</FrameDescription>
</FrameHeader>
<FramePanel>
<div className="flex flex-wrap gap-2">
<Button
variant={
a.policy_mode === 'blacklist' ? 'default' : 'outline'
}
onClick={() => patchMode.mutate('blacklist')}
>
Blacklist
</Button>
<Button
variant={
a.policy_mode === 'whitelist' ? 'default' : 'outline'
}
onClick={() => patchMode.mutate('whitelist')}
>
Whitelist
</Button>
</div>
</FramePanel>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Правила агента</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Prio</TableHead>
<TableHead>Action</TableHead>
<TableHead>Source</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(rulesQ.data?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>{r.action}</TableCell>
<TableCell className="font-mono text-xs">
{r.cidr ?? r.list_id ?? '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
</div>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Мгновенный IP override</FrameTitle>
<FrameDescription>
Обновится на агенте на следующей итерации sync (~1 мин)
</FrameDescription>
</FrameHeader>
<FramePanel>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label>CIDR / IP</Label>
<Input
placeholder="1.2.3.4/32"
value={cidr}
onChange={(e) => setCidr(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Действие</Label>
<Select
value={action}
onValueChange={(v) => {
if (v) setAction(v as 'allow' | 'deny')
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
</div>
<Button
onClick={() => addOverride.mutate()}
disabled={!cidr || addOverride.isPending}
>
Добавить override
</Button>
</div>
</FramePanel>
</Frame>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Копировать правила</FrameTitle>
</FrameHeader>
<FramePanel>
<div className="flex flex-col gap-3">
<Select
value={cloneFrom || null}
onValueChange={(v) => setCloneFrom(v ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="Источник" />
</SelectTrigger>
<SelectContent>
{(agentsQ.data?.items ?? [])
.filter((x) => x.id !== id)
.map((x) => (
<SelectItem key={x.id} value={x.id}>
{x.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
disabled={!cloneFrom || clone.isPending}
onClick={() => clone.mutate()}
>
Клонировать (с overrides)
</Button>
</div>
</FramePanel>
</Frame>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Правила агента</FrameTitle>
</FrameHeader>
<FramePanel className="p-0">
<DataGrid
table={rulesTable}
recordCount={rulesQ.data?.items?.length ?? 0}
>
<DataGridTable />
</DataGrid>
</FramePanel>
</Frame>
</div>
</DetailPanel.Section>
</DetailPanel>
</PageShell>
)
}
+236 -146
View File
@@ -2,26 +2,28 @@ import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Copy, Check } from 'lucide-react'
import { useMemo, useState } from 'react'
import { PageHeader, PageShell, EmptyState } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
agentsQueryOptions,
installContextQueryOptions,
} from '@/queries'
PageHeader,
PageShell,
ResourcePage,
} from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { agentsQueryOptions, installContextQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
import { Badge } from '@evofw/ui/components/badge'
import type { Agent } from '@evofw/shared'
export const Route = createFileRoute('/_auth/agents')({
component: AgentsPage,
@@ -32,6 +34,8 @@ function AgentsPage() {
const agentsQ = useQuery(agentsQueryOptions())
const installQ = useQuery(installContextQueryOptions())
const [name, setName] = useState('web-01')
const [filters, setFilters] = useState<Filter[]>([])
const [activeTab, setActiveTab] = useState('all')
const approve = useMutation({
mutationFn: (id: string) =>
@@ -70,14 +74,132 @@ function AgentsPage() {
const items = agentsQ.data?.items ?? []
const pending = items.filter((a) => a.status === 'pending')
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'name',
label: 'Имя',
type: 'text',
placeholder: 'Поиск…',
},
{
key: 'status',
label: 'Статус',
type: 'select',
options: [
{ value: 'approved', label: 'approved' },
{ value: 'pending', label: 'pending' },
{ value: 'revoked', label: 'revoked' },
],
},
{
key: 'platform',
label: 'Платформа',
type: 'select',
options: [
{ value: 'linux', label: 'linux' },
{ value: 'mikrotik', label: 'mikrotik' },
],
},
],
[],
)
const getFilterFieldValue = useCallback((item: Agent, field: string) => {
if (field === 'name') return item.name
if (field === 'status') return item.status
if (field === 'platform') return item.platform
return undefined
}, [])
const tabFilter = useCallback((item: Agent, tabId: string) => {
if (tabId === 'all') return true
return item.status === tabId
}, [])
const columns: ColumnDef<Agent>[] = useMemo(
() => [
{
accessorKey: 'name',
header: 'Имя',
cell: ({ row }) => (
<Link
to="/agents/$id"
params={{ id: row.original.id }}
className="font-medium underline-offset-4 hover:underline"
>
{row.original.name}
</Link>
),
},
{ accessorKey: 'platform', header: 'Платформа' },
{
accessorKey: 'status',
header: 'Статус',
cell: ({ row }) => (
<Badge
variant={
row.original.status === 'approved'
? 'success-light'
: row.original.status === 'pending'
? 'warning-light'
: 'secondary'
}
size="sm"
>
{row.original.status}
</Badge>
),
},
{ accessorKey: 'policy_mode', header: 'Режим' },
{
accessorKey: 'last_seen_at',
header: 'Seen',
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{row.original.last_seen_at ?? '—'}
</span>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const a = row.original
return (
<div className="flex justify-end gap-1">
{a.status === 'approved' ? (
<Button
size="sm"
variant="outline"
onClick={() => revoke.mutate(a.id)}
>
Revoke
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(a.id)}
>
Удалить
</Button>
</div>
)
},
},
],
[revoke, remove],
)
return (
<PageShell>
<PageHeader
title="Агенты"
description="Linux / MikroTik — enroll, approve, policy mode. Preview: data-grid-filtering-2"
description="Linux / MikroTik — enroll, approve, policy mode"
/>
<Frame>
<Frame dense spacing="sm">
<FrameHeader>
<div>
<FrameTitle>Установка Linux</FrameTitle>
@@ -86,146 +208,114 @@ function AgentsPage() {
</FrameDescription>
</div>
</FrameHeader>
<div className="mb-3 flex max-w-sm flex-col gap-2">
<Label htmlFor="cname">Имя клиента</Label>
<Input
id="cname"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs">
{installCmd}
</pre>
<Button
className="mt-2"
variant="outline"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(installCmd.replace(/\\\n\s*/g, ' '))
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
{installQ.data?.mikrotik_url ? (
<p className="text-muted-foreground mt-3 text-sm">
MikroTik:{' '}
<a className="underline" href={installQ.data.mikrotik_url}>
mikrotik-install.rsc
</a>
</p>
) : null}
<FramePanel>
<div className="mb-3 flex max-w-sm flex-col gap-2">
<Label htmlFor="cname">Имя клиента</Label>
<Input
id="cname"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs">
{installCmd}
</pre>
<Button
className="mt-2"
variant="outline"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(
installCmd.replace(/\\\n\s*/g, ' '),
)
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
{installQ.data?.mikrotik_url ? (
<p className="text-muted-foreground mt-3 text-sm">
MikroTik:{' '}
<a className="underline" href={installQ.data.mikrotik_url}>
mikrotik-install.rsc
</a>
</p>
) : null}
</FramePanel>
</Frame>
{pending.length > 0 ? (
<Frame>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Запросы ({pending.length})</FrameTitle>
</FrameHeader>
<div className="flex flex-col gap-2">
{pending.map((a) => (
<div
key={a.id}
className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-0"
>
<div>
<div className="font-medium">{a.name}</div>
<div className="text-muted-foreground text-xs">
{a.platform} · {a.hostname ?? '—'} · {a.token_prefix}
<FramePanel>
<div className="flex flex-col gap-2">
{pending.map((a) => (
<div
key={a.id}
className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-0"
>
<div>
<div className="font-medium">{a.name}</div>
<div className="text-muted-foreground text-xs">
{a.platform} · {a.hostname ?? '—'} · {a.token_prefix}
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => approve.mutate(a.id)}
disabled={approve.isPending}
>
<Check data-icon="inline-start" />
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => remove.mutate(a.id)}
>
Удалить
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => approve.mutate(a.id)}
disabled={approve.isPending}
>
<Check data-icon="inline-start" />
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => remove.mutate(a.id)}
>
Удалить
</Button>
</div>
</div>
))}
</div>
))}
</div>
</FramePanel>
</Frame>
) : null}
<Frame>
<FrameHeader>
<FrameTitle>Клиенты</FrameTitle>
</FrameHeader>
{items.length === 0 ? (
<EmptyState
title="Нет агентов"
description="Установите agent на сервер и одобрите запрос."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Платформа</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Режим</TableHead>
<TableHead>Seen</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((a) => (
<TableRow key={a.id}>
<TableCell>
<Link
to="/agents/$id"
params={{ id: a.id }}
className="font-medium underline-offset-4 hover:underline"
>
{a.name}
</Link>
</TableCell>
<TableCell>{a.platform}</TableCell>
<TableCell>
<Badge variant="secondary">{a.status}</Badge>
</TableCell>
<TableCell>{a.policy_mode}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{a.last_seen_at ?? '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{a.status === 'approved' ? (
<Button
size="sm"
variant="outline"
onClick={() => revoke.mutate(a.id)}
>
Revoke
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(a.id)}
>
Удалить
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Frame>
<ResourcePage
title="Клиенты"
hideHeader
data={items}
columns={columns}
getRowId={(r) => r.id}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
tabs={[
{ id: 'all', label: 'Все' },
{ id: 'approved', label: 'Approved' },
{ id: 'pending', label: 'Pending' },
{ id: 'revoked', label: 'Revoked' },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={tabFilter}
isLoading={agentsQ.isLoading}
isError={agentsQ.isError}
error={agentsQ.error}
onRetry={() => void agentsQ.refetch()}
emptyState={{
title: 'Нет агентов',
description: 'Установите agent на сервер и одобрите запрос.',
}}
/>
</PageShell>
)
}
+232 -102
View File
@@ -1,17 +1,40 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { PageHeader, PageShell, KpiStatGrid } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { dashboardQueryOptions, agentsQueryOptions, recentStatsQueryOptions } from '@/queries'
import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
import { Skeleton } from '@evofw/ui/components/skeleton'
ServerIcon,
BanIcon,
CheckCircle2Icon,
ListIcon,
} from 'lucide-react'
import {
PageHeader,
PageShell,
OpsDashboard,
QuickActionGrid,
type KpiStatCard,
type QuickActionItem,
} from '@/components/reui-kit'
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import {
dashboardQueryOptions,
agentsQueryOptions,
recentStatsQueryOptions,
} from '@/queries'
import type { Agent } from '@evofw/shared'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
export const Route = createFileRoute('/_auth/')({
component: DashboardPage,
@@ -23,111 +46,218 @@ function DashboardPage() {
const stats = useQuery(recentStatsQueryOptions())
const d = dash.data
const items = [
const items = agents.data?.items ?? []
const pending = items.filter((a) => a.status === 'pending')
const applyErrors = items.filter((a) => a.last_apply_error)
const kpiCards: KpiStatCard[] = useMemo(
() => [
{
id: 'agents',
label: 'Агенты',
value: d?.agents_approved ?? '—',
hint: `${d?.agents_online ?? 0} online / ${d?.agents_pending ?? 0} pending`,
to: '/agents',
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-info',
},
{
id: 'dropped',
label: 'Dropped',
value: d?.packets_dropped ?? '—',
hint: 'сумма counters',
to: '/stats',
icon: <BanIcon aria-hidden />,
iconClassName: 'text-warning',
variant: 'warning',
},
{
id: 'accepted',
label: 'Accepted',
value: d?.packets_accepted ?? '—',
hint: 'сумма counters',
to: '/stats',
icon: <CheckCircle2Icon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'lists',
label: 'Списки IP',
value: d?.lists_total ?? '—',
to: '/lists',
icon: <ListIcon aria-hidden />,
iconClassName: 'text-primary',
},
],
[d],
)
const quickActions: QuickActionItem[] = [
{
id: 'agents',
label: 'Агенты',
value: d?.agents_approved ?? '—',
hint: `${d?.agents_online ?? 0} online / ${d?.agents_pending ?? 0} pending`,
title: 'Агенты',
description: 'Enroll и approve',
to: '/agents',
},
{
id: 'dropped',
label: 'Dropped',
value: d?.packets_dropped ?? '—',
hint: 'сумма counters',
to: '/stats',
},
{
id: 'accepted',
label: 'Accepted',
value: d?.packets_accepted ?? '—',
hint: 'сумма counters',
to: '/stats',
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-info',
},
{
id: 'lists',
label: 'Списки IP',
value: d?.lists_total ?? '—',
title: 'Списки',
description: 'Blocklists / sources',
to: '/lists',
icon: <ListIcon aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'rules',
title: 'Правила',
description: 'Allow / deny policy',
to: '/rules',
icon: <BanIcon aria-hidden />,
iconClassName: 'text-warning',
},
]
const agentColumns: ColumnDef<Agent>[] = useMemo(
() => [
{
accessorKey: 'name',
header: 'Имя',
cell: ({ row }) => (
<Link
to="/agents/$id"
params={{ id: row.original.id }}
className="font-medium underline-offset-4 hover:underline"
>
{row.original.name}
</Link>
),
},
{
accessorKey: 'status',
header: 'Статус',
cell: ({ row }) => (
<Badge
variant={
row.original.status === 'approved'
? 'success-light'
: row.original.status === 'pending'
? 'warning-light'
: 'secondary'
}
size="sm"
>
{row.original.status}
</Badge>
),
},
{ accessorKey: 'policy_mode', header: 'Режим' },
{
accessorKey: 'last_apply_packets_dropped',
header: 'Dropped',
cell: ({ row }) => (
<span className="tabular-nums">
{row.original.last_apply_packets_dropped ?? 0}
</span>
),
},
],
[],
)
const agentsTable = useReactTable({
data: items.slice(0, 8),
columns: agentColumns,
getCoreRowModel: getCoreRowModel(),
getRowId: (r) => r.id,
})
const samples = (stats.data?.items ?? []).slice(0, 10)
return (
<PageShell>
<PageHeader
title="Дашборд"
description="Обзор агентов и пакетной статистики — ReUI stats-12 / dashboard-1"
description="Обзор агентов и пакетной статистики"
/>
<OpsDashboard
isLoading={dash.isLoading}
kpiCards={kpiCards}
afterKpi={<QuickActionGrid actions={quickActions} />}
charts={
<>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Агенты</FrameTitle>
</FrameHeader>
<FramePanel className="p-0">
<DataGrid table={agentsTable} recordCount={items.length}>
<DataGridTable />
</DataGrid>
</FramePanel>
</Frame>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Последние samples</FrameTitle>
</FrameHeader>
<FramePanel>
<div className="flex flex-col gap-2">
{samples.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет данных</p>
) : (
samples.map((s, i) => (
<div
key={`${s.agent_id}-${s.recorded_at}-${i}`}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="text-muted-foreground text-xs">
{s.recorded_at}
</span>
<span className="tabular-nums">
{s.packets_dropped} / {s.packets_accepted}
</span>
</div>
))
)}
</div>
</FramePanel>
</Frame>
</>
}
queue={
<div className="flex flex-col gap-2">
{pending.length === 0 && applyErrors.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет проблем, требующих внимания
</p>
) : null}
{pending.map((a) => (
<div
key={a.id}
className="flex items-center justify-between gap-2 text-sm"
>
<span>
Pending: <strong>{a.name}</strong>
</span>
<Link
to="/agents/$id"
params={{ id: a.id }}
className="underline-offset-4 hover:underline"
>
Открыть
</Link>
</div>
))}
{applyErrors.map((a) => (
<div key={`err-${a.id}`} className="text-sm">
<span className="text-destructive font-medium">{a.name}</span>
<span className="text-muted-foreground"> {a.last_apply_error}</span>
</div>
))}
</div>
}
/>
{dash.isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
) : (
<KpiStatGrid items={items} />
)}
<div className="grid gap-4 lg:grid-cols-2">
<Frame>
<FrameHeader>
<FrameTitle>Агенты</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Режим</TableHead>
<TableHead className="text-right">Dropped</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(agents.data?.items ?? []).slice(0, 8).map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.name}</TableCell>
<TableCell>{a.status}</TableCell>
<TableCell>{a.policy_mode}</TableCell>
<TableCell className="text-right tabular-nums">
{a.last_apply_packets_dropped ?? 0}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Последние samples</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Время</TableHead>
<TableHead className="text-right">Drop</TableHead>
<TableHead className="text-right">Accept</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(stats.data?.items ?? []).slice(0, 10).map((s, i) => (
<TableRow key={`${s.agent_id}-${s.recorded_at}-${i}`}>
<TableCell className="text-muted-foreground text-xs">
{s.recorded_at}
</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_dropped}
</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_accepted}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
</div>
</PageShell>
)
}
+175 -116
View File
@@ -1,9 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useState } from 'react'
import { PageHeader, PageShell, EmptyState } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import { listsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
@@ -18,13 +19,14 @@ import {
SelectValue,
} from '@evofw/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import type { IpList } from '@evofw/shared'
export const Route = createFileRoute('/_auth/lists')({
component: ListsPage,
@@ -33,11 +35,13 @@ export const Route = createFileRoute('/_auth/lists')({
function ListsPage() {
const qc = useQueryClient()
const listsQ = useQuery(listsQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false)
const [name, setName] = useState('')
const [type, setType] = useState<
'static' | 'json_url' | 'domains' | 'evobgp_community'
>('static')
const [extra, setExtra] = useState('')
const [filters, setFilters] = useState<Filter[]>([])
const create = useMutation({
mutationFn: async () => {
@@ -67,6 +71,7 @@ function ListsPage() {
toast.success('Список создан')
setName('')
setExtra('')
setSheetOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
@@ -91,120 +96,174 @@ function ListsPage() {
const items = listsQ.data?.items ?? []
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
{
key: 'type',
label: 'Тип',
type: 'select',
options: [
{ value: 'static', label: 'static' },
{ value: 'json_url', label: 'json_url' },
{ value: 'domains', label: 'domains' },
{ value: 'evobgp_community', label: 'evobgp_community' },
],
},
],
[],
)
const getFilterFieldValue = useCallback((item: IpList, field: string) => {
if (field === 'name') return item.name
if (field === 'type') return item.type
return undefined
}, [])
const columns: ColumnDef<IpList>[] = useMemo(
() => [
{ accessorKey: 'name', header: 'Имя' },
{ accessorKey: 'type', header: 'Тип' },
{
accessorKey: 'entry_count',
header: 'Entries',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.entry_count ?? 0}</span>
),
},
{
id: 'refresh',
header: 'Refresh',
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{row.original.last_error ?? row.original.refreshed_at ?? '—'}
</span>
),
},
{
id: 'actions',
cell: ({ row }) => {
const l = row.original
return (
<div className="flex justify-end gap-1">
{l.type !== 'static' ? (
<Button
size="sm"
variant="outline"
onClick={() => refresh.mutate(l.id)}
>
Refresh
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(l.id)}
>
Удалить
</Button>
</div>
)
},
},
],
[refresh, remove],
)
return (
<PageShell>
<PageHeader
title="Списки IP"
description="static · JSON URL · domains · EvoBGP community"
actions={
<Button onClick={() => setSheetOpen(true)}>Новый список</Button>
}
/>
<Frame>
<FrameHeader>
<FrameTitle>Новый список</FrameTitle>
</FrameHeader>
<div className="grid max-w-xl gap-3">
<div className="flex flex-col gap-2">
<Label>Имя</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label>Тип</Label>
<Select
value={type}
onValueChange={(v) =>
setType(v as typeof type)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">static</SelectItem>
<SelectItem value="json_url">json_url</SelectItem>
<SelectItem value="domains">domains</SelectItem>
<SelectItem value="evobgp_community">evobgp_community</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>
{type === 'static'
? 'CIDR (через пробел/запятую)'
: type === 'json_url'
? 'URL JSON'
: type === 'domains'
? 'Домены'
: 'Community ID'}
</Label>
<Textarea
value={extra}
onChange={(e) => setExtra(e.target.value)}
rows={3}
/>
</div>
<Button
disabled={!name || create.isPending}
onClick={() => create.mutate()}
>
Создать
</Button>
</div>
</Frame>
<ResourcePage
title="Списки"
hideHeader
data={items}
columns={columns}
getRowId={(r) => r.id}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
isLoading={listsQ.isLoading}
isError={listsQ.isError}
error={listsQ.error}
onRetry={() => void listsQ.refetch()}
emptyState={{
title: 'Пусто',
description: 'Создайте первый список.',
action: (
<Button onClick={() => setSheetOpen(true)}>Новый список</Button>
),
}}
/>
<Frame>
<FrameHeader>
<FrameTitle>Списки</FrameTitle>
</FrameHeader>
{items.length === 0 ? (
<EmptyState title="Пусто" description="Создайте первый список." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Entries</TableHead>
<TableHead>Refresh</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((l) => (
<TableRow key={l.id}>
<TableCell className="font-medium">{l.name}</TableCell>
<TableCell>{l.type}</TableCell>
<TableCell className="tabular-nums">
{l.entry_count ?? 0}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{l.last_error ?? l.refreshed_at ?? '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{l.type !== 'static' ? (
<Button
size="sm"
variant="outline"
onClick={() => refresh.mutate(l.id)}
>
Refresh
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(l.id)}
>
Удалить
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Frame>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
<SheetHeader>
<SheetTitle>Новый список</SheetTitle>
<SheetDescription>Источник префиксов для правил</SheetDescription>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-1">
<div className="flex flex-col gap-2">
<Label>Имя</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label>Тип</Label>
<Select
value={type}
onValueChange={(v) => setType(v as typeof type)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">static</SelectItem>
<SelectItem value="json_url">json_url</SelectItem>
<SelectItem value="domains">domains</SelectItem>
<SelectItem value="evobgp_community">
evobgp_community
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>
{type === 'static'
? 'CIDR (через пробел/запятую)'
: type === 'json_url'
? 'URL JSON'
: type === 'domains'
? 'Домены'
: 'Community ID'}
</Label>
<Textarea
value={extra}
onChange={(e) => setExtra(e.target.value)}
rows={3}
/>
</div>
</div>
<SheetFooter>
<Button variant="outline" onClick={() => setSheetOpen(false)}>
Отмена
</Button>
<Button
disabled={!name || create.isPending}
onClick={() => create.mutate()}
>
Создать
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</PageShell>
)
}
+205 -118
View File
@@ -1,10 +1,16 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { rulesQueryOptions, listsQueryOptions, agentsQueryOptions } from '@/queries'
import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import {
rulesQueryOptions,
listsQueryOptions,
agentsQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
@@ -17,13 +23,14 @@ import {
SelectValue,
} from '@evofw/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import type { PolicyRule } from '@evofw/shared'
export const Route = createFileRoute('/_auth/rules')({
component: RulesPage,
@@ -34,10 +41,12 @@ function RulesPage() {
const rulesQ = useQuery(rulesQueryOptions())
const listsQ = useQuery(listsQueryOptions())
const agentsQ = useQuery(agentsQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false)
const [priority, setPriority] = useState('100')
const [action, setAction] = useState<'allow' | 'deny'>('deny')
const [listId, setListId] = useState('')
const [agentId, setAgentId] = useState('tenant')
const [filters, setFilters] = useState<Filter[]>([])
const create = useMutation({
mutationFn: () =>
@@ -52,6 +61,7 @@ function RulesPage() {
}),
onSuccess: () => {
toast.success('Правило создано')
setSheetOpen(false)
void qc.invalidateQueries({ queryKey: ['rules'] })
},
onError: (e: Error) => toast.error(e.message),
@@ -63,120 +73,197 @@ function RulesPage() {
onSuccess: () => void qc.invalidateQueries({ queryKey: ['rules'] }),
})
const items = rulesQ.data?.items ?? []
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'action',
label: 'Action',
type: 'select',
options: [
{ value: 'deny', label: 'deny' },
{ value: 'allow', label: 'allow' },
],
},
],
[],
)
const getFilterFieldValue = useCallback((item: PolicyRule, field: string) => {
if (field === 'action') return item.action
return undefined
}, [])
const columns: ColumnDef<PolicyRule>[] = useMemo(
() => [
{ accessorKey: 'priority', header: 'Prio' },
{
accessorKey: 'action',
header: 'Action',
cell: ({ row }) => (
<Badge
variant={
row.original.action === 'deny'
? 'destructive-light'
: 'success-light'
}
size="sm"
>
{row.original.action}
</Badge>
),
},
{
accessorKey: 'agent_id',
header: 'Agent',
cell: ({ row }) => (
<span className="text-xs">{row.original.agent_id ?? 'tenant'}</span>
),
},
{
id: 'source',
header: 'List / CIDR',
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.cidr ?? row.original.list_id ?? '—'}
</span>
),
},
{
id: 'actions',
cell: ({ row }) => (
<div className="flex justify-end">
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(row.original.id)}
>
Удалить
</Button>
</div>
),
},
],
[remove],
)
return (
<PageShell>
<PageHeader
title="Правила"
description="Упорядоченные allow/deny по списку или CIDR (tenant + per-agent)"
description="Упорядоченные allow/deny по списку или CIDR"
actions={
<Button onClick={() => setSheetOpen(true)}>Новое правило</Button>
}
/>
<Frame>
<FrameHeader>
<FrameTitle>Новое правило</FrameTitle>
</FrameHeader>
<div className="grid max-w-xl gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label>Priority</Label>
<Input
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Action</Label>
<Select
value={action}
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Список</Label>
<Select value={listId} onValueChange={setListId}>
<SelectTrigger>
<SelectValue placeholder="IP list" />
</SelectTrigger>
<SelectContent>
{(listsQ.data?.items ?? []).map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Scope</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tenant">Tenant default</SelectItem>
{(agentsQ.data?.items ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
className="sm:col-span-2"
disabled={!listId || create.isPending}
onClick={() => create.mutate()}
>
Создать
</Button>
</div>
</Frame>
<ResourcePage
title="Правила"
hideHeader
data={items}
columns={columns}
getRowId={(r) => r.id}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
isLoading={rulesQ.isLoading}
isError={rulesQ.isError}
error={rulesQ.error}
onRetry={() => void rulesQ.refetch()}
emptyState={{
title: 'Нет правил',
description: 'Создайте первое правило политики.',
action: (
<Button onClick={() => setSheetOpen(true)}>Новое правило</Button>
),
}}
/>
<Frame>
<FrameHeader>
<FrameTitle>Все правила</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Prio</TableHead>
<TableHead>Action</TableHead>
<TableHead>Agent</TableHead>
<TableHead>List / CIDR</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{(rulesQ.data?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>{r.action}</TableCell>
<TableCell className="text-xs">
{r.agent_id ?? 'tenant'}
</TableCell>
<TableCell className="font-mono text-xs">
{r.cidr ?? r.list_id ?? '—'}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(r.id)}
>
Удалить
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
<SheetHeader>
<SheetTitle>Новое правило</SheetTitle>
<SheetDescription>Priority + action + list scope</SheetDescription>
</SheetHeader>
<div className="grid flex-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label>Priority</Label>
<Input
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Action</Label>
<Select
value={action}
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label>Список</Label>
<Select
value={listId || null}
onValueChange={(v) => setListId(v ?? '')}
>
<SelectTrigger>
<SelectValue placeholder="IP list" />
</SelectTrigger>
<SelectContent>
{(listsQ.data?.items ?? []).map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<Label>Scope</Label>
<Select
value={agentId}
onValueChange={(v) => {
if (v) setAgentId(v)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tenant">Tenant default</SelectItem>
{(agentsQ.data?.items ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<SheetFooter>
<Button variant="outline" onClick={() => setSheetOpen(false)}>
Отмена
</Button>
<Button
disabled={!listId || create.isPending}
onClick={() => create.mutate()}
>
Создать
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</PageShell>
)
}
+36 -22
View File
@@ -3,12 +3,18 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useEffect, useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { SettingRow } from '@/components/setting-row'
import { settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
export const Route = createFileRoute('/_auth/settings')({
component: SettingsPage,
@@ -66,7 +72,7 @@ function SettingsPage() {
title="Настройки"
description="Интеграции и enroll — settings-16"
/>
<Frame>
<Frame dense spacing="sm">
<FrameHeader>
<div>
<FrameTitle>Control plane</FrameTitle>
@@ -75,26 +81,34 @@ function SettingsPage() {
</FrameDescription>
</div>
</FrameHeader>
<div className="flex max-w-xl flex-col gap-4">
{fields.map((f) => (
<div key={f.key} className="flex flex-col gap-2">
<Label htmlFor={f.key}>{f.label}</Label>
<Input
id={f.key}
type={f.key.includes('token') ? 'password' : 'text'}
value={form[f.key] ?? ''}
onChange={(e) =>
setForm((prev) => ({ ...prev, [f.key]: e.target.value }))
}
/>
<p className="text-muted-foreground text-xs">{f.hint}</p>
</div>
))}
<Button onClick={() => save.mutate()} disabled={save.isPending}>
Сохранить
</Button>
</div>
<FramePanel className="p-0">
<div className="flex flex-col">
{fields.map((f, i) => (
<SettingRow
key={f.key}
title={f.label}
description={f.hint}
labelFor={f.key}
last={i === fields.length - 1}
>
<Input
id={f.key}
type={f.key.includes('token') ? 'password' : 'text'}
value={form[f.key] ?? ''}
onChange={(e) =>
setForm((prev) => ({ ...prev, [f.key]: e.target.value }))
}
/>
</SettingRow>
))}
</div>
</FramePanel>
</Frame>
<div className="flex justify-end">
<Button onClick={() => save.mutate()} disabled={save.isPending}>
Сохранить
</Button>
</div>
</PageShell>
)
}
+170 -88
View File
@@ -1,16 +1,27 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { PageHeader, PageShell, KpiStatGrid } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
BanIcon,
CheckCircle2Icon,
ServerIcon,
} from 'lucide-react'
import {
PageHeader,
PageShell,
KpiStatGrid,
ResourcePage,
type KpiStatCard,
} from '@/components/reui-kit'
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
import {
ChartContainer,
ChartTooltip,
@@ -19,6 +30,14 @@ import {
} from '@evofw/ui/components/chart'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
type StatRow = {
id: string
agent_id: string
packets_dropped: number
packets_accepted: number
recorded_at: string
}
export const Route = createFileRoute('/_auth/stats')({
component: StatsPage,
})
@@ -31,6 +50,7 @@ const chartConfig = {
function StatsPage() {
const dash = useQuery(dashboardQueryOptions())
const stats = useQuery(recentStatsQueryOptions())
const [filters, setFilters] = useState<Filter[]>([])
const series = [...(stats.data?.items ?? [])]
.reverse()
@@ -41,95 +61,157 @@ function StatsPage() {
accepted: s.packets_accepted,
}))
const rows: StatRow[] = useMemo(
() =>
(stats.data?.items ?? []).slice(0, 50).map((s, i) => ({
id: `${s.agent_id}-${s.recorded_at}-${i}`,
agent_id: s.agent_id,
packets_dropped: s.packets_dropped,
packets_accepted: s.packets_accepted,
recorded_at: s.recorded_at,
})),
[stats.data],
)
const kpiCards: KpiStatCard[] = [
{
id: 'd',
label: 'Dropped (sum)',
value: dash.data?.packets_dropped ?? 0,
icon: <BanIcon aria-hidden />,
iconClassName: 'text-warning',
variant: 'warning',
},
{
id: 'a',
label: 'Accepted (sum)',
value: dash.data?.packets_accepted ?? 0,
icon: <CheckCircle2Icon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'o',
label: 'Online agents',
value: dash.data?.agents_online ?? 0,
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-info',
},
]
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'agent_id',
label: 'Agent',
type: 'text',
placeholder: 'agent id…',
},
],
[],
)
const getFilterFieldValue = useCallback((item: StatRow, field: string) => {
if (field === 'agent_id') return item.agent_id
return undefined
}, [])
const columns: ColumnDef<StatRow>[] = useMemo(
() => [
{
accessorKey: 'agent_id',
header: 'Agent',
cell: ({ row }) => (
<span className="font-mono text-xs">
{row.original.agent_id.slice(0, 8)}
</span>
),
},
{
accessorKey: 'recorded_at',
header: 'Time',
cell: ({ row }) => (
<span className="text-xs">{row.original.recorded_at}</span>
),
},
{
accessorKey: 'packets_dropped',
header: 'Drop',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.packets_dropped}</span>
),
},
{
accessorKey: 'packets_accepted',
header: 'Accept',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.packets_accepted}</span>
),
},
],
[],
)
return (
<PageShell>
<PageHeader
title="Статистика"
description="История apply-report counters — dashboard-1 / charts"
/>
<KpiStatGrid
items={[
{
id: 'd',
label: 'Dropped (sum)',
value: dash.data?.packets_dropped ?? 0,
},
{
id: 'a',
label: 'Accepted (sum)',
value: dash.data?.packets_accepted ?? 0,
},
{
id: 'o',
label: 'Online agents',
value: dash.data?.agents_online ?? 0,
},
]}
description="История apply-report counters"
/>
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
<Frame>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Тренд (последние samples)</FrameTitle>
</FrameHeader>
{series.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет данных дождитесь apply-report от агентов.
</p>
) : (
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
<AreaChart data={series}>
<CartesianGrid vertical={false} />
<XAxis dataKey="t" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
dataKey="dropped"
type="monotone"
fill="var(--color-dropped)"
stroke="var(--color-dropped)"
fillOpacity={0.3}
/>
<Area
dataKey="accepted"
type="monotone"
fill="var(--color-accepted)"
stroke="var(--color-accepted)"
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
)}
<FramePanel>
{series.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет данных дождитесь apply-report от агентов.
</p>
) : (
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
<AreaChart data={series}>
<CartesianGrid vertical={false} />
<XAxis dataKey="t" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
dataKey="dropped"
type="monotone"
fill="var(--color-dropped)"
stroke="var(--color-dropped)"
fillOpacity={0.3}
/>
<Area
dataKey="accepted"
type="monotone"
fill="var(--color-accepted)"
stroke="var(--color-accepted)"
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Сырые samples</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Agent</TableHead>
<TableHead>Time</TableHead>
<TableHead className="text-right">Drop</TableHead>
<TableHead className="text-right">Accept</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(stats.data?.items ?? []).slice(0, 50).map((s, i) => (
<TableRow key={`${s.agent_id}-${i}`}>
<TableCell className="font-mono text-xs">{s.agent_id.slice(0, 8)}</TableCell>
<TableCell className="text-xs">{s.recorded_at}</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_dropped}
</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_accepted}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
<ResourcePage
title="Samples"
hideHeader
data={rows}
columns={columns}
getRowId={(r) => r.id}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
isLoading={stats.isLoading}
emptyState={{
title: 'Нет samples',
description: 'Агенты ещё не отправили apply-report.',
}}
/>
</PageShell>
)
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string
readonly VITE_AUTH_ENABLED?: string
readonly VITE_AUTH_PORTAL_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod'
export const appSwitcherIconSchema = z.enum([
'server',
'cloud',
'globe',
'dashboard',
'chart',
])
export const appSwitcherEntrySchema = z.object({
id: z.string(),
name: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
icon: appSwitcherIconSchema.default('server'),
enabled: z.boolean().optional(),
sort: z.number().optional(),
shortcut: z.string().optional(),
})
export const appSwitcherConfigSchema = z.object({
menuLabel: z.string().default('Приложения'),
apps: z.array(appSwitcherEntrySchema).min(1),
})
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
+1
View File
@@ -1,2 +1,3 @@
export * from './contracts.js'
export * from './permissions.js'
export * from './app-switcher.js'
+1 -2
View File
@@ -2,7 +2,6 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
import { cn } from "@evofw/ui/lib/utils"
@@ -166,7 +165,7 @@ function ChartTooltipContent({
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
number | string,
TooltipNameType
>,
"accessibilityLayer"
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
+201
View File
@@ -0,0 +1,201 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@evofw/ui/lib/utils"
import { Separator } from "@evofw/ui/components/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props
),
render,
state: {
slot: "item",
variant,
size,
},
})
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "@evofw/ui/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+81
View File
@@ -0,0 +1,81 @@
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@evofw/ui/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}
@@ -1,3 +1,6 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@evofw/ui/lib/utils"
-2
View File
@@ -1,5 +1,3 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@evofw/ui/lib/utils"
+98
View File
@@ -79,6 +79,18 @@ importers:
'@base-ui/react':
specifier: ^1.5.0
version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@dnd-kit/modifiers':
specifier: ^9.0.0
version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.7)
'@evofw/shared':
specifier: workspace:*
version: link:../../packages/shared
@@ -100,12 +112,18 @@ importers:
'@tanstack/react-table':
specifier: ^8.21.0
version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tanstack/react-virtual':
specifier: ^3.14.7
version: 3.14.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tanstack/router-plugin':
specifier: ^1.120.0
version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(esbuild@0.28.1)(rollup@4.62.2)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0))
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
date-fns:
specifier: ^4.4.0
version: 4.4.0
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.2.7)
@@ -115,6 +133,9 @@ importers:
react:
specifier: ^19.1.0
version: 19.2.7
react-day-picker:
specifier: ^9.14.0
version: 9.14.0(react@19.2.7)
react-dom:
specifier: ^19.1.0
version: 19.2.7(react@19.2.7)
@@ -389,6 +410,34 @@ packages:
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/modifiers@9.0.0':
resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -1705,6 +1754,12 @@ packages:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/react-virtual@3.14.7':
resolution: {integrity: sha512-11uSrj77IDijNBqizD4lY4y1laMyRrqMLSxjnWy5CvWkCjyRDW+gGmxYq0lwQKVas/sq7zyzYWXbL/BvBzR32g==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/router-core@1.171.15':
resolution: {integrity: sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==}
engines: {node: '>=20.19'}
@@ -1745,6 +1800,9 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/virtual-core@3.17.5':
resolution: {integrity: sha512-AXfBC3sq6PuYSwyxYORqqgHCNjPGAvKJvZuBBJ1klhztWBB5cgqgwsq8+fNfaQJG7/K4xYBja9S90QFn2zmQAg==}
'@tanstack/virtual-file-routes@1.162.0':
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
engines: {node: '>=20.19'}
@@ -3457,6 +3515,38 @@ snapshots:
'@date-fns/tz@1.5.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.7)
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
tslib: 2.8.1
'@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.7)':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@drizzle-team/brocli@0.10.2': {}
'@esbuild-kit/core-utils@3.3.2':
@@ -4431,6 +4521,12 @@ snapshots:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@tanstack/react-virtual@3.14.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@tanstack/virtual-core': 3.17.5
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@tanstack/router-core@1.171.15':
dependencies:
'@tanstack/history': 1.162.0
@@ -4492,6 +4588,8 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.17.5': {}
'@tanstack/virtual-file-routes@1.162.0': {}
'@turbo/darwin-64@2.10.5':