From 9ff1af849ecf768ef965c7dc93759c5e55e4b311 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 20 Jul 2026 21:04:02 +0700 Subject: [PATCH] feat: update dependencies and enhance UI components - 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 --- apps/web/package.json | 7 + apps/web/src/components/app-sidebar.tsx | 99 + apps/web/src/components/app-switcher.tsx | 96 + apps/web/src/components/confirm-dialog.tsx | 56 + apps/web/src/components/counted-line-tabs.tsx | 50 + apps/web/src/components/empty-state.tsx | 92 + apps/web/src/components/form-field.tsx | 35 + apps/web/src/components/form-sheet.tsx | 71 + apps/web/src/components/layout/app-shell.tsx | 132 +- apps/web/src/components/layout/apps-menu.tsx | 105 + .../web/src/components/layout/search-menu.tsx | 182 ++ .../web/src/components/layout/site-header.tsx | 109 + .../layout/system-monitor-popover.tsx | 174 ++ apps/web/src/components/loading-button.tsx | 32 + apps/web/src/components/mode-toggle.tsx | 29 + apps/web/src/components/page-header.tsx | 22 + apps/web/src/components/page-shell.tsx | 15 + apps/web/src/components/query-state.tsx | 59 + .../src/components/reui-kit/detail-panel.tsx | 116 + .../src/components/reui-kit/filter-utils.ts | 91 + apps/web/src/components/reui-kit/index.ts | 24 + apps/web/src/components/reui-kit/index.tsx | 104 - .../src/components/reui-kit/kpi-stat-grid.tsx | 205 ++ .../src/components/reui-kit/ops-dashboard.tsx | 79 + .../components/reui-kit/quick-action-grid.tsx | 113 + .../src/components/reui-kit/resource-page.tsx | 391 +++ .../components/reui-kit/settings-shell.tsx | 98 + apps/web/src/components/reui/alert.tsx | 92 + apps/web/src/components/reui/badge.tsx | 102 + .../data-grid/data-grid-column-filter.tsx | 187 ++ .../data-grid/data-grid-column-header.tsx | 355 +++ .../data-grid/data-grid-column-visibility.tsx | 54 + .../reui/data-grid/data-grid-pagination.tsx | 227 ++ .../reui/data-grid/data-grid-scroll-area.tsx | 469 ++++ .../data-grid/data-grid-table-dnd-rows.tsx | 346 +++ .../reui/data-grid/data-grid-table-dnd.tsx | 350 +++ .../data-grid/data-grid-table-virtual.tsx | 633 +++++ .../reui/data-grid/data-grid-table.tsx | 1852 ++++++++++++++ .../components/reui/data-grid/data-grid.tsx | 357 +++ apps/web/src/components/reui/filters.tsx | 2174 +++++++++++++++++ apps/web/src/components/reui/frame.tsx | 198 +- apps/web/src/components/reui/icon-stack.tsx | 90 + apps/web/src/components/setting-row.tsx | 88 + apps/web/src/components/status-badge.tsx | 68 + apps/web/src/hooks/use-app-switcher.ts | 38 + apps/web/src/lib/app-switcher-config.ts | 73 + apps/web/src/lib/auth.ts | 6 + apps/web/src/queries/app-switcher.ts | 28 + apps/web/src/routes/_auth/agents.$id.tsx | 394 +-- apps/web/src/routes/_auth/agents.tsx | 382 +-- apps/web/src/routes/_auth/index.tsx | 334 ++- apps/web/src/routes/_auth/lists.tsx | 291 ++- apps/web/src/routes/_auth/rules.tsx | 323 ++- apps/web/src/routes/_auth/settings.tsx | 58 +- apps/web/src/routes/_auth/stats.tsx | 258 +- apps/web/src/vite-env.d.ts | 11 + packages/shared/src/app-switcher.ts | 28 + packages/shared/src/index.ts | 1 + packages/ui/src/components/chart.tsx | 3 +- packages/ui/src/components/dropdown-menu.tsx | 2 + packages/ui/src/components/input-group.tsx | 2 + packages/ui/src/components/item.tsx | 201 ++ packages/ui/src/components/kbd.tsx | 26 + packages/ui/src/components/progress.tsx | 81 + packages/ui/src/components/scroll-area.tsx | 3 + packages/ui/src/components/tooltip.tsx | 2 - pnpm-lock.yaml | 98 + 67 files changed, 11873 insertions(+), 998 deletions(-) create mode 100644 apps/web/src/components/app-sidebar.tsx create mode 100644 apps/web/src/components/app-switcher.tsx create mode 100644 apps/web/src/components/confirm-dialog.tsx create mode 100644 apps/web/src/components/counted-line-tabs.tsx create mode 100644 apps/web/src/components/empty-state.tsx create mode 100644 apps/web/src/components/form-field.tsx create mode 100644 apps/web/src/components/form-sheet.tsx create mode 100644 apps/web/src/components/layout/apps-menu.tsx create mode 100644 apps/web/src/components/layout/search-menu.tsx create mode 100644 apps/web/src/components/layout/site-header.tsx create mode 100644 apps/web/src/components/layout/system-monitor-popover.tsx create mode 100644 apps/web/src/components/loading-button.tsx create mode 100644 apps/web/src/components/mode-toggle.tsx create mode 100644 apps/web/src/components/page-header.tsx create mode 100644 apps/web/src/components/page-shell.tsx create mode 100644 apps/web/src/components/query-state.tsx create mode 100644 apps/web/src/components/reui-kit/detail-panel.tsx create mode 100644 apps/web/src/components/reui-kit/filter-utils.ts create mode 100644 apps/web/src/components/reui-kit/index.ts delete mode 100644 apps/web/src/components/reui-kit/index.tsx create mode 100644 apps/web/src/components/reui-kit/kpi-stat-grid.tsx create mode 100644 apps/web/src/components/reui-kit/ops-dashboard.tsx create mode 100644 apps/web/src/components/reui-kit/quick-action-grid.tsx create mode 100644 apps/web/src/components/reui-kit/resource-page.tsx create mode 100644 apps/web/src/components/reui-kit/settings-shell.tsx create mode 100644 apps/web/src/components/reui/alert.tsx create mode 100644 apps/web/src/components/reui/badge.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-column-header.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-pagination.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid-table.tsx create mode 100644 apps/web/src/components/reui/data-grid/data-grid.tsx create mode 100644 apps/web/src/components/reui/filters.tsx create mode 100644 apps/web/src/components/reui/icon-stack.tsx create mode 100644 apps/web/src/components/setting-row.tsx create mode 100644 apps/web/src/components/status-badge.tsx create mode 100644 apps/web/src/hooks/use-app-switcher.ts create mode 100644 apps/web/src/lib/app-switcher-config.ts create mode 100644 apps/web/src/queries/app-switcher.ts create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 packages/shared/src/app-switcher.ts create mode 100644 packages/ui/src/components/item.tsx create mode 100644 packages/ui/src/components/kbd.tsx create mode 100644 packages/ui/src/components/progress.tsx diff --git a/apps/web/package.json b/apps/web/package.json index b473668..5267b81 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx new file mode 100644 index 0000000..eb2e1ee --- /dev/null +++ b/apps/web/src/components/app-sidebar.tsx @@ -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 ( + + {label} + + + {items.map((item) => ( + + + } + > + + {item.label} + + + ))} + + + + ) +} + +export function AppSidebar() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + + return ( + + + + + + + + + + + + ) +} diff --git a/apps/web/src/components/app-switcher.tsx b/apps/web/src/components/app-switcher.tsx new file mode 100644 index 0000000..e11f4d3 --- /dev/null +++ b/apps/web/src/components/app-switcher.tsx @@ -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 ( + + + + + } + > +
+ +
+
+ {current.name} + {current.subtitle ? ( + + {current.subtitle} + + ) : null} +
+ +
+ +
+ {isLoading ? 'Загрузка…' : config.menuLabel} +
+ {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 ( + + + {app.name} + + + ) + } + + return ( + } + > + + {app.name} + {app.shortcut ? ( + {app.shortcut} + ) : null} + + ) + })} +
+
+
+
+ ) +} diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..3f25a8f --- /dev/null +++ b/apps/web/src/components/confirm-dialog.tsx @@ -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 ( + + {trigger ? ( + + ) : null} + + + {title} + {description} + + + {cancelLabel} + + {confirmLabel} + + + + + ) +} diff --git a/apps/web/src/components/counted-line-tabs.tsx b/apps/web/src/components/counted-line-tabs.tsx new file mode 100644 index 0000000..cf5bacf --- /dev/null +++ b/apps/web/src/components/counted-line-tabs.tsx @@ -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.map((tab) => ( + + {tab.label} + {tab.count !== undefined ? ( + + {tab.count} + + ) : null} + + ))} + + {children} + + ) +} diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx new file mode 100644 index 0000000..da531cf --- /dev/null +++ b/apps/web/src/components/empty-state.tsx @@ -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 = ( + + + + {stackedIcon ? ( + + ) : ( + + + )} + +
+ + {title} + + {description ? ( + + {description} + + ) : null} +
+
+ {action ? ( + + {action} + + ) : null} +
+ ) + + if (!centered) return body + + return ( +
+ {body} +
+ ) +} diff --git a/apps/web/src/components/form-field.tsx b/apps/web/src/components/form-field.tsx new file mode 100644 index 0000000..78b0365 --- /dev/null +++ b/apps/web/src/components/form-field.tsx @@ -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 ( + + {label} + {children} + {hint && !error ? {hint} : null} + + + ) +} diff --git a/apps/web/src/components/form-sheet.tsx b/apps/web/src/components/form-sheet.tsx new file mode 100644 index 0000000..c9af3f4 --- /dev/null +++ b/apps/web/src/components/form-sheet.tsx @@ -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 { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + form: UseFormReturn + onSubmit: (values: T) => void | Promise + children: ReactNode + footer?: ReactNode + className?: string + contentClassName?: string +} + +export function FormSheet({ + open, + onOpenChange, + title, + description, + form, + onSubmit, + children, + footer, + className, + contentClassName, +}: FormSheetProps) { + const handleSubmit = form.handleSubmit(onSubmit) + + return ( + + + + {title} + {description && {description}} + + +
+
+ {children} +
+ {footer ? ( + {footer} + ) : null} +
+
+
+
+ ) +} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 083e261..d0cd556 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -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 ( - - - -
- -
- EvoFirewall - - {CURRENT_APP_ID} - -
-
-
- - - - - {NAV.map((item) => { - const Icon = item.icon - const active = - item.to === '/' - ? pathname === '/' - : pathname.startsWith(item.to) - return ( - - } - > - - {item.label} - - - ) - })} - - - - - - - -
- -
- - - Control plane -
-
- {children} -
-
-
+ + + + + +
+ {children} +
+
+ +
+
) } diff --git a/apps/web/src/components/layout/apps-menu.tsx b/apps/web/src/components/layout/apps-menu.tsx new file mode 100644 index 0000000..66d5e74 --- /dev/null +++ b/apps/web/src/components/layout/apps-menu.tsx @@ -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 ( + + + } + > + + + + + + {isLoading ? 'Загрузка…' : config.menuLabel} + +
+ {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 ( + + + + {app.name} + + ) + } + + return ( + } + className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5" + > + + + {app.name} + + ) + })} +
+ + {isAuthEnabled() ? ( + } + className="justify-center text-sm font-medium" + > + Настроить на портале + + ) : ( + } + className="justify-center text-sm font-medium" + > + Настройки + + )} +
+
+
+ ) +} diff --git a/apps/web/src/components/layout/search-menu.tsx b/apps/web/src/components/layout/search-menu.tsx new file mode 100644 index 0000000..b188dbe --- /dev/null +++ b/apps/web/src/components/layout/search-menu.tsx @@ -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 : ( + + )} + + + + Поиск + + Переход к разделам приложения + + + +
+
+ + {filtered.length === 0 ? ( +

+ Ничего не найдено +

+ ) : ( + filtered.map((item) => ( + setOpen(false)} />} + > + + + + {item.label} + + + )) + )} +
+
+
+ + ) +} diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx new file mode 100644 index 0000000..1ed7ebb --- /dev/null +++ b/apps/web/src/components/layout/site-header.tsx @@ -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 = { + '/': 'Дашборд', + '/agents': 'Агенты', + '/lists': 'Списки IP', + '/rules': 'Правила', + '/stats': 'Статистика', + '/settings': 'Настройки', +} + +function getBreadcrumbs( + pathname: string, + dynamicLabels: Record, +) { + 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 = {} + 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 ( +
+ + + + + {crumbs.map((crumb, index) => { + const isLast = index === crumbs.length - 1 + return ( + + {index > 0 && ( + + )} + + {isLast ? ( + {crumb.label} + ) : ( + }> + {crumb.label} + + )} + + + ) + })} + + +
+ + + +
+
+ ) +} diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx new file mode 100644 index 0000000..d49f1e5 --- /dev/null +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -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 ( +
+
+
+ + + {metric.icon} + + + {metric.label} +
+ + {metric.value} + {metric.unit} + +
+ +
+ ) +} + +/** 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( + () => [ + { + id: 'api', + label: 'API', + value: apiOk ? 'OK' : 'ERR', + unit: '', + percent: apiOk ? 100 : 0, + icon: , + tone: apiOk ? 'success' : 'destructive', + alert: !apiOk, + }, + { + id: 'agents', + label: 'Агенты online', + value: String(onlinePct), + unit: '%', + percent: onlinePct, + icon: , + 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: , + tone: 'info', + alert: false, + }, + { + id: 'rules', + label: 'Правила', + value: String(rulesCount), + unit: '', + percent: Math.min(100, rulesCount * 10), + icon: , + tone: 'info', + alert: false, + }, + ], + [apiOk, onlinePct, pending, approved, listsCount, rulesCount], + ) + + const hasAlert = metrics.some((m) => m.alert) + + return ( + + + } + > + + {hasAlert ? ( + + ) : null} + + +
+
Система
+
+ Агенты и политики EvoFirewall +
+
+
+ {metrics.map((m) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/loading-button.tsx b/apps/web/src/components/loading-button.tsx new file mode 100644 index 0000000..224ab65 --- /dev/null +++ b/apps/web/src/components/loading-button.tsx @@ -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 { + isLoading?: boolean + loadingLabel?: string +} + +export function LoadingButton({ + isLoading = false, + loadingLabel, + children, + disabled, + className, + ...props +}: LoadingButtonProps) { + const label = + isLoading && loadingLabel != null ? loadingLabel : children + + return ( + + ) +} diff --git a/apps/web/src/components/mode-toggle.tsx b/apps/web/src/components/mode-toggle.tsx new file mode 100644 index 0000000..019ad42 --- /dev/null +++ b/apps/web/src/components/mode-toggle.tsx @@ -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 ( + + }> + + + Сменить тему + + + setTheme('light')}>Светлая + setTheme('dark')}>Тёмная + setTheme('system')}>Системная + + + ) +} diff --git a/apps/web/src/components/page-header.tsx b/apps/web/src/components/page-header.tsx new file mode 100644 index 0000000..67b397d --- /dev/null +++ b/apps/web/src/components/page-header.tsx @@ -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 ( +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {actions ?
{actions}
: null} +
+ ) +} diff --git a/apps/web/src/components/page-shell.tsx b/apps/web/src/components/page-shell.tsx new file mode 100644 index 0000000..a8504a5 --- /dev/null +++ b/apps/web/src/components/page-shell.tsx @@ -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 ( +
+ {children} +
+ ) +} diff --git a/apps/web/src/components/query-state.tsx b/apps/web/src/components/query-state.tsx new file mode 100644 index 0000000..861d441 --- /dev/null +++ b/apps/web/src/components/query-state.tsx @@ -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 ( +
+ + + +
+ ) +} + +export function QueryState({ + isLoading, + isError, + error, + onRetry, + skeleton, + children, +}: QueryStateProps) { + if (isLoading) { + return skeleton ?? + } + + if (isError) { + return ( + + + Не удалось загрузить данные + + {error?.message ?? 'Произошла ошибка при загрузке.'} + {onRetry ? ( + + ) : null} + + + ) + } + + return children +} diff --git a/apps/web/src/components/reui-kit/detail-panel.tsx b/apps/web/src/components/reui-kit/detail-panel.tsx new file mode 100644 index 0000000..3975740 --- /dev/null +++ b/apps/web/src/components/reui-kit/detail-panel.tsx @@ -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
{children}
+} + +interface DetailPanelHeaderProps { + title: string + description?: string + actions?: ReactNode + children?: ReactNode +} + +function DetailPanelHeader({ + title, + description, + actions, + children, +}: DetailPanelHeaderProps) { + return ( + + +
+ {title} + {description ? ( + {description} + ) : null} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ {children ? {children} : null} + + ) +} + +function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) { + return ( +
+
+ {cards.map((card) => ( + + +
+ {card.icon} + + {card.label} + +
+
+ +

+ {card.description} +

+ {card.footer} +
+ + ))} +
+
+ ) +} + +function DetailPanelSection({ + title, + description, + children, +}: { + title?: string + description?: string + children: ReactNode +}) { + return ( +
+ {title ? ( +
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ ) : null} + {children} +
+ ) +} + +export const DetailPanel = Object.assign(DetailPanelRoot, { + Header: DetailPanelHeader, + Metrics: DetailPanelMetrics, + Section: DetailPanelSection, +}) diff --git a/apps/web/src/components/reui-kit/filter-utils.ts b/apps/web/src/components/reui-kit/filter-utils.ts new file mode 100644 index 0000000..a896d19 --- /dev/null +++ b/apps/web/src/components/reui-kit/filter-utils.ts @@ -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( + 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]) +} diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts new file mode 100644 index 0000000..762569f --- /dev/null +++ b/apps/web/src/components/reui-kit/index.ts @@ -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' diff --git a/apps/web/src/components/reui-kit/index.tsx b/apps/web/src/components/reui-kit/index.tsx deleted file mode 100644 index 420d7f9..0000000 --- a/apps/web/src/components/reui-kit/index.tsx +++ /dev/null @@ -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 ( -
- {items.map((item) => { - const inner = ( - -
- {item.label} -
-
{item.value}
- {item.hint ? ( -
{item.hint}
- ) : null} - - ) - return item.to ? ( - - {inner} - - ) : ( -
{inner}
- ) - })} -
- ) -} - -export function PageShell({ - children, - className, -}: { - children: ReactNode - className?: string -}) { - return ( -
{children}
- ) -} - -export function PageHeader({ - title, - description, - actions, -}: { - title: string - description?: string - actions?: ReactNode -}) { - return ( -
-
-

{title}

- {description ? ( -

{description}

- ) : null} -
- {actions ? ( -
- {actions} -
- ) : null} -
- ) -} - -export function EmptyState({ - title, - description, - action, -}: { - title: string - description?: string - action?: ReactNode -}) { - return ( - -
{title}
- {description ? ( -

{description}

- ) : null} - {action} - - ) -} diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx new file mode 100644 index 0000000..2ef6339 --- /dev/null +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -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 + 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 = { + 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 ( + + {card.hint} + + ) + } + return null +} + +function KpiStatCardBody({ card }: { card: KpiStatCard }) { + const footer = resolveFooter(card) + const valueVariant = card.variant ?? 'default' + + return ( +
+ {card.icon ? ( + + + {card.icon} + + + ) : null} + +
+
+ {card.label} + {footer ?
{footer}
: null} +
+ + {card.value} + +
+
+ ) +} + +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 ( + + + + + + ) + } + + if (card.onSelect) { + return ( + + + + ) + } + + return ( + + + + ) +} + +function KpiStatGridSkeleton({ count }: { count: number }) { + return ( + +
+ {Array.from({ length: count }).map((_, index) => ( + + +
+
+ + +
+ +
+
+ ))} +
+ + ) +} + +/** + * 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 + } + + if (cards.length === 0) { + return ( + + + {emptyIcon} + {emptyMessage ? ( +

{emptyMessage}

+ ) : null} +
+ + ) + } + + return ( + +
+ {cards.map((card) => ( + + ))} +
+ + ) +} diff --git a/apps/web/src/components/reui-kit/ops-dashboard.tsx b/apps/web/src/components/reui-kit/ops-dashboard.tsx new file mode 100644 index 0000000..30c123b --- /dev/null +++ b/apps/web/src/components/reui-kit/ops-dashboard.tsx @@ -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 ( +
+ +
+ + +
+ +
+ ) +} + +export function OpsDashboard({ + kpiCards, + afterKpi, + charts, + queue, + isLoading = false, +}: OpsDashboardProps) { + if (isLoading) { + return + } + + return ( +
+
+ +
+ + {afterKpi} + +
+ {charts} +
+ +
+ + + Требуют внимания + + Pending-агенты, ошибки apply и списки с last_error + + + {queue} + +
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/quick-action-grid.tsx b/apps/web/src/components/reui-kit/quick-action-grid.tsx new file mode 100644 index 0000000..8dbd977 --- /dev/null +++ b/apps/web/src/components/reui-kit/quick-action-grid.tsx @@ -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 + 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 ( +
+ {action.icon ? ( + + + {action.icon} + + + ) : null} + +
+
+ {action.title} + + Перейти + +
+

+ {action.description} +

+
+
+ ) +} + +/** + * 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 ( + + {(title || description) && ( + + {title ? {title} : null} + {description ? {description} : null} + + )} +
+ {actions.map((action) => ( + + + + + + ))} +
+ + ) +} diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx new file mode 100644 index 0000000..96c9c37 --- /dev/null +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -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 { + 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[] + 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 ( + + + + + + +
+ + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ + ) +} + +export function ResourcePage({ + 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) { + const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all') + const activeTab = controlledTab ?? internalTab + + const [sorting, setSorting] = useState([]) + const [rowSelection, setRowSelection] = useState({}) + const [pagination, setPagination] = useState({ + 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 = {} + 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 + } + + if (isError) { + return ( + + + Ошибка загрузки + + {error?.message ?? 'Не удалось загрузить данные'} + {onRetry ? ( + + ) : null} + + + ) + } + + if (data.length === 0 && emptyState) { + return ( + + {!hideHeader ? ( + +
+ {title} + {description ? ( + + {description} + + ) : null} +
+ {primaryAction ? ( +
+ {primaryAction} +
+ ) : null} +
+ ) : null} + + + + + ) + } + + const emptyMessage = 'Нет записей по выбранным фильтрам.' + + return ( +
+ {selectionToolbar && selectedCount > 0 + ? selectionToolbar({ + selectedIds, + selectedCount, + clearSelection, + }) + : null} + + + {!hideHeader ? ( + +
+ {title} + {description ? ( + + {description} + + ) : null} +
+ {primaryAction ? ( +
+ {primaryAction} +
+ ) : null} +
+ ) : null} + + + {countedTabs.length > 0 ? ( + <> +
+ +
+ + + ) : null} + +
+ +
+ + + + + + + + + + + + +
+ +
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx new file mode 100644 index 0000000..09e798f --- /dev/null +++ b/apps/web/src/components/reui-kit/settings-shell.tsx @@ -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: