From 3dc8e6d5e2faf119a68d030dd13d58a94d49b5a0 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 20 Sep 2026 19:36:37 +0700 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D1=82=D0=B5=D1=80=D1=8B?= =?UTF-8?q?,=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=B0=D1=8F=20=D0=BD=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8F,=20RBAC-=D0=B3=D0=B5=D0=B9?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=B3=20=D0=B8=20=D1=87=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/format.ts: ru-RU форматтеры дат/чисел в одном месте — убраны дубли из 8 компонентов (agent-card, fleet-grid, blocked-ips/ports, facts, lifecycle, host-firewall, lists-columns, system-monitor) - lib/nav.ts: единый конфиг маршрутов — sidebar, ⌘K-поиск и breadcrumbs рендерятся из одного источника (было 3 расходящихся копии) - lib/permissions.ts + useCan: клиентский RBAC — кнопки создания/подтверждения скрываются без fw:*:write/admin (сервер остаётся авторитетным) - удалён мёртвый код ~2500 строк: stepper, data-grid dnd/virtual/visibility варианты, settings-shell --- .../components/agents/agent-blocked-ips.tsx | 20 +- .../components/agents/agent-blocked-ports.tsx | 20 +- apps/web/src/components/agents/agent-card.tsx | 31 +- .../components/agents/agent-facts-panel.tsx | 5 +- .../agents/agent-fleet-data-grid.tsx | 31 +- .../components/agents/agent-host-firewall.tsx | 3 +- .../agents/agent-lifecycle-timeline.tsx | 5 +- apps/web/src/components/app-sidebar.tsx | 47 +- .../web/src/components/layout/search-menu.tsx | 50 +- .../web/src/components/layout/site-header.tsx | 44 +- .../layout/system-monitor-popover.tsx | 3 +- .../src/components/lists/lists-columns.tsx | 5 +- apps/web/src/components/reui-kit/index.ts | 1 - .../components/reui-kit/settings-shell.tsx | 89 --- .../data-grid/data-grid-column-visibility.tsx | 53 -- .../data-grid/data-grid-table-dnd-rows.tsx | 347 ---------- .../reui/data-grid/data-grid-table-dnd.tsx | 349 ---------- .../data-grid/data-grid-table-virtual.tsx | 634 ------------------ apps/web/src/components/reui/stepper.tsx | 477 ------------- apps/web/src/lib/format.ts | 74 ++ apps/web/src/lib/nav.ts | 95 +++ apps/web/src/lib/permissions.ts | 19 + apps/web/src/routes/_auth/agents/index.tsx | 8 +- apps/web/src/routes/_auth/lists/index.tsx | 12 +- apps/web/src/routes/_auth/rules/index.tsx | 6 +- apps/web/src/routes/_auth/settings.tsx | 25 +- 26 files changed, 275 insertions(+), 2178 deletions(-) delete mode 100644 apps/web/src/components/reui-kit/settings-shell.tsx delete mode 100644 apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx delete mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx delete mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx delete mode 100644 apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx delete mode 100644 apps/web/src/components/reui/stepper.tsx create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/lib/nav.ts create mode 100644 apps/web/src/lib/permissions.ts diff --git a/apps/web/src/components/agents/agent-blocked-ips.tsx b/apps/web/src/components/agents/agent-blocked-ips.tsx index a2454ed..2a80a45 100644 --- a/apps/web/src/components/agents/agent-blocked-ips.tsx +++ b/apps/web/src/components/agents/agent-blocked-ips.tsx @@ -19,6 +19,7 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import { EmptyState } from '@/components/empty-state' import { agentBlockedIpsQueryOptions } from '@/queries' import { Skeleton } from '@evofw/ui/components/skeleton' +import { formatNumber, formatStampDateTime } from '@/lib/format' /** * Per-IP blocked stats — Linux nft/ipset counters or MikroTik EVOFW_HITS. @@ -45,21 +46,6 @@ type AgentBlockedIpsProps = { platform: string } -const packetFmt = new Intl.NumberFormat('ru-RU') -const seenFmt = new Intl.DateTimeFormat('ru-RU', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', -}) - -function formatSeen(iso: string): string { - const t = Date.parse(iso) - if (Number.isNaN(t)) return '—' - return seenFmt.format(t) -} function formatPorts(ports: BlockedIpPort[] | undefined): string { if (!ports?.length) return '—' @@ -99,7 +85,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) { ), cell: ({ row }) => ( - {packetFmt.format(row.original.packets)} + {formatNumber(row.original.packets)} ), meta: { headerTitle: packetsTitle }, @@ -128,7 +114,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) { ), cell: ({ row }) => ( - {formatSeen(row.original.last_seen_at)} + {formatStampDateTime(row.original.last_seen_at)} ), meta: { headerTitle: 'Last seen' }, diff --git a/apps/web/src/components/agents/agent-blocked-ports.tsx b/apps/web/src/components/agents/agent-blocked-ports.tsx index 9d5ba78..96981f6 100644 --- a/apps/web/src/components/agents/agent-blocked-ports.tsx +++ b/apps/web/src/components/agents/agent-blocked-ports.tsx @@ -19,6 +19,7 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import { EmptyState } from '@/components/empty-state' import { agentBlockedPortsQueryOptions } from '@/queries' import { Skeleton } from '@evofw/ui/components/skeleton' +import { formatNumber, formatStampDateTime } from '@/lib/format' /** * Aggregate destination ports hit by denied sources (Linux nft). @@ -37,21 +38,6 @@ type AgentBlockedPortsProps = { agentId: string } -const packetFmt = new Intl.NumberFormat('ru-RU') -const seenFmt = new Intl.DateTimeFormat('ru-RU', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', -}) - -function formatSeen(iso: string): string { - const t = Date.parse(iso) - if (Number.isNaN(t)) return '—' - return seenFmt.format(t) -} export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) { const q = useQuery(agentBlockedPortsQueryOptions(agentId)) @@ -92,7 +78,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) { ), cell: ({ row }) => ( - {packetFmt.format(row.original.packets)} + {formatNumber(row.original.packets)} ), meta: { headerTitle: 'Packets' }, @@ -105,7 +91,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) { ), cell: ({ row }) => ( - {formatSeen(row.original.last_seen_at)} + {formatStampDateTime(row.original.last_seen_at)} ), meta: { headerTitle: 'Last seen' }, diff --git a/apps/web/src/components/agents/agent-card.tsx b/apps/web/src/components/agents/agent-card.tsx index fdc28d3..0cf5046 100644 --- a/apps/web/src/components/agents/agent-card.tsx +++ b/apps/web/src/components/agents/agent-card.tsx @@ -21,36 +21,13 @@ import { } from '@evofw/ui/components/item' import { Separator } from '@evofw/ui/components/separator' import { cn } from '@evofw/ui/lib/utils' +import { formatPackets, formatShortDateTime } from '@/lib/format' /** * Agent catalog card — hybrid card-3 header + stats strip + stats-12 values. * Preview: https://reui.io/preview/base/card-3 · https://reui.io/preview/base/stats-12 */ -const packetFmt = new Intl.NumberFormat('ru-RU', { - notation: 'compact', - maximumFractionDigits: 1, -}) - -const seenFmt = new Intl.DateTimeFormat('ru-RU', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', -}) - -function formatPackets(n: number | undefined, hasApply: boolean): string { - if (!hasApply || n === undefined) return '—' - return packetFmt.format(n) -} - -function formatShort(iso: string | null | undefined): string { - if (!iso) return '—' - const t = Date.parse(iso) - if (Number.isNaN(t)) return '—' - return seenFmt.format(t) -} - type AgentCardProps = { agent: Agent selected?: boolean @@ -65,13 +42,13 @@ export function AgentCard({ onDelete, }: AgentCardProps) { const hasApply = agentHasTrafficSample(agent) - const dropped = formatPackets(agentTrafficDropped(agent), hasApply) - const accepted = formatPackets(agentTrafficAccepted(agent), hasApply) + const dropped = hasApply ? formatPackets(agentTrafficDropped(agent)) : '—' + const accepted = hasApply ? formatPackets(agentTrafficAccepted(agent)) : '—' const traffic = dropped === '—' && accepted === '—' ? '—' : `↓${dropped} · ↑${accepted}` - const seen = formatShort(agent.last_seen_at ?? agent.last_apply_at) + const seen = formatShortDateTime(agent.last_seen_at ?? agent.last_apply_at) const defaultAction = agent.default_action === 'drop' ? 'Drop' : 'Accept' const subtitle = [ diff --git a/apps/web/src/components/agents/agent-facts-panel.tsx b/apps/web/src/components/agents/agent-facts-panel.tsx index 86af10d..ff3f0e8 100644 --- a/apps/web/src/components/agents/agent-facts-panel.tsx +++ b/apps/web/src/components/agents/agent-facts-panel.tsx @@ -18,6 +18,7 @@ import { SelectValue, } from '@evofw/ui/components/select' import { Separator } from '@evofw/ui/components/separator' +import { formatDateTime } from '@/lib/format' /** * Agent facts panel — SA3 RunFacts DNA (editable default_action). @@ -31,9 +32,7 @@ type AgentFactsPanelProps = { function formatWhen(iso?: string | null): string { if (!iso) return '—' - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return iso - return d.toLocaleString('ru-RU') + return formatDateTime(iso) } export function AgentFactsPanel({ agent }: AgentFactsPanelProps) { diff --git a/apps/web/src/components/agents/agent-fleet-data-grid.tsx b/apps/web/src/components/agents/agent-fleet-data-grid.tsx index 74031b5..53f5b62 100644 --- a/apps/web/src/components/agents/agent-fleet-data-grid.tsx +++ b/apps/web/src/components/agents/agent-fleet-data-grid.tsx @@ -26,6 +26,7 @@ import { TooltipContent, TooltipTrigger, } from '@evofw/ui/components/tooltip' +import { formatPackets, formatShortDateTime } from '@/lib/format' /** * Fleet triage DataGrid — firewall ops density. @@ -34,30 +35,6 @@ import { * · https://reui.io/preview/base/solution-agents-1 */ -const packetFmt = new Intl.NumberFormat('ru-RU', { - notation: 'compact', - maximumFractionDigits: 1, -}) - -const seenFmt = new Intl.DateTimeFormat('ru-RU', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', -}) - -function formatPackets(n: number | undefined, hasApply: boolean): string { - if (!hasApply || n === undefined) return '—' - return packetFmt.format(n) -} - -function formatAgentSeen(iso: string | null | undefined): string { - if (!iso) return '—' - const t = Date.parse(iso) - if (Number.isNaN(t)) return '—' - return seenFmt.format(t) -} - export type AgentFleetDataGridProps = { data: Agent[] filterFields: FilterFieldConfig[] @@ -231,8 +208,8 @@ export function AgentFleetDataGrid({ cell: ({ row }) => { const a = row.original const hasApply = agentHasTrafficSample(a) - const dropped = formatPackets(agentTrafficDropped(a), hasApply) - const accepted = formatPackets(agentTrafficAccepted(a), hasApply) + const dropped = hasApply ? formatPackets(agentTrafficDropped(a)) : '—' + const accepted = hasApply ? formatPackets(agentTrafficAccepted(a)) : '—' if (dropped === '—' && accepted === '—') { return } @@ -255,7 +232,7 @@ export function AgentFleetDataGrid({ ), cell: ({ row }) => { const a = row.original - const short = formatAgentSeen(a.last_seen_at) + const short = formatShortDateTime(a.last_seen_at) if (!a.last_seen_at || short === '—') { return } diff --git a/apps/web/src/components/agents/agent-host-firewall.tsx b/apps/web/src/components/agents/agent-host-firewall.tsx index d183ef0..ba78d6e 100644 --- a/apps/web/src/components/agents/agent-host-firewall.tsx +++ b/apps/web/src/components/agents/agent-host-firewall.tsx @@ -33,6 +33,7 @@ import { SelectValue, } from '@evofw/ui/components/select' import { TabsContent } from '@evofw/ui/components/tabs' +import { formatDateTime } from '@/lib/format' /** * Observed host firewall + listeners (Linux). @@ -229,7 +230,7 @@ export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) { Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign. {q.data?.collected_at - ? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}` + ? ` Обновлено: ${formatDateTime(q.data.collected_at)}` : ' Пока нет снимка — дождитесь sync агента.'} diff --git a/apps/web/src/components/agents/agent-lifecycle-timeline.tsx b/apps/web/src/components/agents/agent-lifecycle-timeline.tsx index 74e8309..a58dcfb 100644 --- a/apps/web/src/components/agents/agent-lifecycle-timeline.tsx +++ b/apps/web/src/components/agents/agent-lifecycle-timeline.tsx @@ -16,6 +16,7 @@ import { FramePanel, FrameTitle, } from '@/components/reui/frame' +import { formatDateTime } from '@/lib/format' /** * Agent lifecycle timeline. @@ -32,9 +33,7 @@ type Step = { function formatWhen(iso?: string | null): string | undefined { if (!iso) return undefined - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return iso - return d.toLocaleString('ru-RU') + return formatDateTime(iso) } export function AgentLifecycleTimeline({ agent }: { agent: Agent }) { diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index e2f4590..aada344 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -1,14 +1,7 @@ import { Link, useRouterState } from '@tanstack/react-router' -import { - LayoutDashboardIcon, - ServerIcon, - ListIcon, - ShieldIcon, - BarChart3Icon, - SettingsIcon, -} from 'lucide-react' import { AppSwitcher } from '@/components/app-switcher' import { NavUser } from '@/components/layout/nav-user' +import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav' import { Sidebar, SidebarContent, @@ -22,22 +15,7 @@ import { 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: 'Списки', 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) { +function isNavActive(pathname: string, to: string, exact?: boolean) { if (exact) return pathname === to return pathname === to || pathname.startsWith(`${to}/`) } @@ -48,12 +26,7 @@ function NavSection({ pathname, }: { label: string - items: readonly { - to: string - label: string - icon: typeof ServerIcon - exact: boolean - }[] + items: readonly NavItem[] pathname: string }) { return ( @@ -90,12 +63,18 @@ export function AppSidebar() { - - - + {NAV_SECTIONS.map((section) => ( + + ))} - + + ) } diff --git a/apps/web/src/components/layout/search-menu.tsx b/apps/web/src/components/layout/search-menu.tsx index c2d33aa..42fd7ac 100644 --- a/apps/web/src/components/layout/search-menu.tsx +++ b/apps/web/src/components/layout/search-menu.tsx @@ -1,14 +1,7 @@ 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 { SearchIcon } from 'lucide-react' +import { NAV_ITEMS } from '@/lib/nav' import { Button } from '@evofw/ui/components/button' import { Dialog, @@ -26,45 +19,6 @@ import { 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', 'наборы', 'sets'], - 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) diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index fba90d0..a9093cf 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -12,55 +12,41 @@ import { Separator } from '@evofw/ui/components/separator' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { AppsMenu } from '@/components/layout/apps-menu' import { SidebarTrigger } from '@evofw/ui/components/sidebar' +import { navLabel, navParentForDetail } from '@/lib/nav' export interface RouteBreadcrumbLoaderData { breadcrumb?: string } -const routeTitles: Record = { - '/': 'Панель управления', - '/agents': 'Агенты', - '/lists': 'Списки', - '/rules': 'Наборы правил', - '/stats': 'Статистика', - '/settings': 'Настройки', -} - function getBreadcrumbs( pathname: string, dynamicLabels: Record, ) { if (pathname === '/') { - return [{ label: 'Панель управления', href: '/' }] + return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }] } - if (pathname.match(/^\/agents\/[^/]+$/)) { + const parentTo = navParentForDetail(pathname) + if (parentTo) { + const parentLabel = navLabel(parentTo) + const fallback = + parentTo === '/agents' + ? 'Агент' + : parentTo === '/lists' + ? 'Список' + : 'Набор' return [ - { label: 'Агенты', href: '/agents' }, - { label: dynamicLabels[pathname] ?? 'Агент', href: pathname }, + { label: parentLabel ?? parentTo, href: parentTo }, + { label: dynamicLabels[pathname] ?? fallback, href: pathname }, ] } - if (pathname.match(/^\/rules\/[^/]+$/)) { - return [ - { label: 'Наборы правил', href: '/rules' }, - { label: dynamicLabels[pathname] ?? 'Набор', href: pathname }, - ] - } - - if (pathname.match(/^\/lists\/[^/]+$/)) { - return [ - { label: 'Списки', href: '/lists' }, - { label: dynamicLabels[pathname] ?? 'Список', href: pathname }, - ] - } - - const title = routeTitles[pathname] + const title = navLabel(pathname) if (title) { return [{ label: title, href: pathname }] } - return [{ label: 'Панель управления', href: '/' }] + return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }] } function useDynamicBreadcrumbLabels() { diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx index b74711d..45f7d05 100644 --- a/apps/web/src/components/layout/system-monitor-popover.tsx +++ b/apps/web/src/components/layout/system-monitor-popover.tsx @@ -1,6 +1,7 @@ import { useMemo, type CSSProperties, type ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react' +import { formatTime } from '@/lib/format' import { Badge } from '@/components/reui/badge' import { cn } from '@evofw/ui/lib/utils' @@ -201,7 +202,7 @@ export function SystemMonitorPopover() { Монитор EvoFirewall - {new Date().toLocaleTimeString('ru-RU')} + {formatTime(new Date())}
diff --git a/apps/web/src/components/lists/lists-columns.tsx b/apps/web/src/components/lists/lists-columns.tsx index f74edc3..b388954 100644 --- a/apps/web/src/components/lists/lists-columns.tsx +++ b/apps/web/src/components/lists/lists-columns.tsx @@ -10,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge' import { ListTypeIcon } from '@/components/lists/list-type-icon' import { Button } from '@evofw/ui/components/button' import { isManualListType, type IpList } from '@evofw/shared' +import { formatDateTime } from '@/lib/format' export const LIST_TABS = [ { id: 'all', label: 'Все' }, @@ -67,7 +68,7 @@ export function createListColumns(opts: { row.original.last_error ? 'Ошибка обновления' : row.original.refreshed_at - ? `Обновлён ${new Date(row.original.refreshed_at).toLocaleString('ru-RU')}` + ? `Обновлён ${formatDateTime(row.original.refreshed_at)}` : undefined } /> @@ -102,7 +103,7 @@ export function createListColumns(opts: { cell: ({ row }) => ( {row.original.updated_at - ? new Date(row.original.updated_at).toLocaleString('ru-RU') + ? formatDateTime(row.original.updated_at) : '—'} ), diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 48c5431..3aa9bb7 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -22,7 +22,6 @@ export { 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' diff --git a/apps/web/src/components/reui-kit/settings-shell.tsx b/apps/web/src/components/reui-kit/settings-shell.tsx deleted file mode 100644 index ae7ca75..0000000 --- a/apps/web/src/components/reui-kit/settings-shell.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import type { ReactNode } from 'react' -import { Link, Outlet, useRouterState } from '@tanstack/react-router' - -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' - -/** - * Multi-section settings layout — only when tabs are provided. - * Single-page settings use PageShell + Frame + SettingRow directly. - * Preview: https://reui.io/preview/base/settings-16 - */ - -export interface SettingsTabConfig { - id: string - to: string - label: string - icon?: ReactNode -} - -interface SettingsShellProps { - title?: string - description?: string - /** Required — no phantom default routes. */ - tabs: SettingsTabConfig[] -} - -export function SettingsShell({ - title = 'Настройки', - description = 'Конфигурация control plane', - tabs, -}: SettingsShellProps) { - const isMobile = useIsMobile() - const pathname = useRouterState({ select: (s) => s.location.pathname }) - - return ( - -
- - -
- {tabs.length > 1 ? ( - - ) : null} - -
- -
-
-
-
- ) -} diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx deleted file mode 100644 index 4001f3b..0000000 --- a/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"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({ - table, - trigger, -}: { - table: Table - trigger: ReactElement> -}) { - return ( - - - - - - Toggle Columns - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - event.preventDefault()} - onCheckedChange={(value) => column.toggleVisibility(!!value)} - > - {getColumnHeaderLabel(column)} - - ) - })} - - - - ) -} - -export { DataGridColumnVisibility } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx deleted file mode 100644 index da1df51..0000000 --- a/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +++ /dev/null @@ -1,347 +0,0 @@ -"use client" -"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 -const SortableRowContext = createContext | 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 ( - - ) - } - - return ( - - ) -} - -function DataGridTableDndRow({ row }: { row: Row }) { - 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 ( - - - {row.getVisibleCells().map((cell: Cell) => { - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ) - })} - - - {row.getIsExpanded() && } - - ) -} - -function DataGridTableDndRowsBody({ - table, - dataIds, -}: { - table: Table - 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) => ( - - {table.getVisibleFlatColumns().map((column, colIndex) => { - return ( - - {column.columnDef.meta?.skeleton} - - ) - })} - - - ))} - - ) - } - - if (!table.getRowModel().rows.length) return - - return ( - - {table.getRowModel().rows.map((row: Row) => { - return - })} - - ) -} - -/** - * Memoized body rows: skip re-renders during active column resize. - * Column widths update via CSS variables on the 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({ - handleDragEnd, - dataIds, - footerContent, -}: { - handleDragEnd: (event: DragEndEvent) => void - dataIds: UniqueIdentifier[] - footerContent?: ReactNode -}) { - const { table, props } = useDataGrid() - const tableContainerRef = useRef(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 ( - setIsDraggingRow(false)} - onDragEnd={(event) => { - setIsDraggingRow(false) - handleDragEnd(event) - }} - onDragStart={() => setIsDraggingRow(true)} - sensors={sensors} - > - - - - {table - .getHeaderGroups() - .map((headerGroup: HeaderGroup, index) => { - return ( - - {headerGroup.headers.map((header, index) => { - const { column } = header - - return ( - - {header.isPlaceholder ? null : props.tableLayout - ?.columnsResizable && column.getCanResize() ? ( -
- {flexRender( - header.column.columnDef.header, - header.getContext() - )} -
- ) : ( - flexRender( - header.column.columnDef.header, - header.getContext() - ) - )} - {props.tableLayout?.columnsResizable && - column.getCanResize() && ( - - )} -
- ) - })} - -
- ) - })} -
- - {(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( - - )} - - - - - - {footerContent && ( - {footerContent} - )} -
-
-
- ) -} - -export { DataGridTableDndRowHandle, DataGridTableDndRows } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx deleted file mode 100644 index 01b8880..0000000 --- a/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx +++ /dev/null @@ -1,349 +0,0 @@ -"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({ - header, -}: { - header: Header -}) { - 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 ( - -
- {canOrder && ( - - )} -
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {props.tableLayout?.columnsResizable && column.getCanResize() && ( - - )} -
-
- ) -} - -function DataGridTableDndCell({ cell }: { cell: Cell }) { - 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 ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ) -} - -function DataGridTableDndBodyRows({ table }: { table: Table }) { - const { isLoading, props } = useDataGrid() - const pagination = table.getState().pagination - - if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) { - return ( - <> - {Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( - - {table.getVisibleFlatColumns().map((column, colIndex) => { - return ( - - {column.columnDef.meta?.skeleton} - - ) - })} - - - ))} - - ) - } - - if (!table.getRowModel().rows.length) return - - return ( - <> - {table.getRowModel().rows.map((row: Row) => { - return ( - - - - {row.getVisibleCells().map((cell: Cell) => ( - - ))} - - - - {row.getIsExpanded() && } - - ) - })} - - ) -} - -/** - * Memoized body rows: skip re-renders during active column resize. - * Column widths update via CSS variables on the
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({ - handleDragEnd, - footerContent, -}: { - handleDragEnd: (event: DragEndEvent) => void - footerContent?: ReactNode -}) { - const { table, props } = useDataGrid() - const containerRef = useRef(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 ( - setIsDraggingColumn(false)} - onDragEnd={(event) => { - setIsDraggingColumn(false) - handleDragEnd(event) - }} - onDragStart={() => setIsDraggingColumn(true)} - sensors={sensors} - > - - - - {table - .getHeaderGroups() - .map((headerGroup: HeaderGroup, index) => { - return ( - - - {headerGroup.headers.map((header) => ( - - ))} - - - - ) - })} - - - {(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( - - )} - - - - - - {footerContent && ( - {footerContent} - )} - - - - ) -} - -export { DataGridTableDnd } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx deleted file mode 100644 index 22d408d..0000000 --- a/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx +++ /dev/null @@ -1,634 +0,0 @@ -"use client" -"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 = Omit< - VirtualizerOptions, - "count" | "estimateSize" | "getItemKey" | "getScrollElement" -> & { - estimateSize?: (index: number, row: Row) => number - getItemKey?: (index: number, row: Row) => string | number - getScrollElement?: ( - elements: DataGridTableVirtualScrollElements - ) => HTMLElement | null -} - -interface DataGridTableVirtualProps { - height?: number | string - estimateSize?: number - overscan?: number - footerContent?: ReactNode - renderHeader?: boolean - onFetchMore?: () => void - isFetchingMore?: boolean - hasMore?: boolean - fetchMoreOffset?: number - virtualizerOptions?: DataGridTableVirtualizerOptions -} - -interface VirtualBodyProps { - table: Table - topRows: Row[] - centerRows: Row[] - bottomRows: Row[] - virtualItems: VirtualItem[] - totalSize: number - isVirtualizationEnabled: boolean - isInfiniteMode: boolean - isFetchingMore: boolean - hasMore?: boolean - loadingMoreMessage: ReactNode - allRowsLoadedMessage: ReactNode - measureRowRef?: (element: HTMLTableRowElement | null) => void -} - -function DataGridTableVirtualPinnedPlaceholderCell({ - column, -}: { - column: Column -}) { - const { props } = useDataGrid() - const isPinned = column.getIsPinned() - const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left") - const isFirstRightPinned = - isPinned === "right" && column.getIsFirstColumn("right") - - return ( - - {leftVisibleColumns.map((column) => ( - - ))} - - {props.tableLayout?.columnsResizable && hasRightPinnedColumns ? ( - - ) : null} - {rightVisibleColumns.map((column) => ( - - ))} - {props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? ( - - ) : null} - - ) -} - -function DataGridTableVirtualSpacer({ - table, - height, -}: { - table: Table - height: number -}) { - if (height <= 0) return null - - return ( - - {null} - - ) -} - -function DataGridTableVirtualStatusRow({ - table, - children, - className, -}: { - table: Table - children: ReactNode - className?: string -}) { - return ( - - {children} - - ) -} - -function DataGridTableVirtualBody({ - table, - topRows, - centerRows, - bottomRows, - virtualItems, - totalSize, - isVirtualizationEnabled, - isInfiniteMode, - isFetchingMore, - hasMore, - loadingMoreMessage, - allRowsLoadedMessage, - measureRowRef, -}: VirtualBodyProps) { - 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 ( - -
- - {loadingMoreMessage} -
-
- ) - } - - return - } - - 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( - - ) - }) - - if (isVirtualizationEnabled) { - if (leadingSpacerHeight > 0) { - renderedRows.push( - - ) - } - - virtualItems.forEach((virtualRow) => { - const row = centerRows[virtualRow.index] - - if (!row) return - - renderedRows.push( - - ) - }) - - if (trailingSpacerHeight > 0) { - renderedRows.push( - - ) - } - } else { - centerRows.forEach((row) => { - renderedRows.push() - }) - } - - if (showFetchingRow) { - renderedRows.push( - -
- - {loadingMoreMessage} -
-
- ) - } - - if (showCompleteRow) { - renderedRows.push( - - {allRowsLoadedMessage} - - ) - } - - bottomRows.forEach((row, index) => { - renderedRows.push( - 0 || hasMiddleSection) - ? "bottom" - : undefined - } - /> - ) - }) - - return <>{renderedRows} -} - -/** - * Memoized virtual body: skip re-renders during active column resize. - * Column widths update via CSS variables on the
- {children} -
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({ - height, - estimateSize = 48, - overscan = 10, - footerContent, - renderHeader = true, - onFetchMore, - isFetchingMore = false, - hasMore, - fetchMoreOffset = 0, - virtualizerOptions, -}: DataGridTableVirtualProps) { - 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({ - 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(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 ( - - - {renderHeader && ( - - {mergedHeaderGroups.map((headerGroup) => ( - - {headerGroup.headers - .filter((header) => header.column.getIsPinned() !== "right") - .map((header) => { - const { column } = header - - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - {props.tableLayout?.columnsResizable && - column.getCanResize() && ( - - )} - - ) - })} - {props.tableLayout?.columnsResizable && - hasRightPinnedColumns ? ( - - ) : null} - {headerGroup.headers - .filter((header) => header.column.getIsPinned() === "right") - .map((header) => { - const { column } = header - - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - {props.tableLayout?.columnsResizable && - column.getCanResize() && ( - - )} - - ) - })} - {props.tableLayout?.columnsResizable && - !hasRightPinnedColumns ? ( - - ) : null} - - ))} - - )} - - {renderHeader && - (props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( - - )} - - - - - - {footerContent && ( - {footerContent} - )} - - - ) -} - -export { DataGridTableVirtual } -export type { - DataGridTableVirtualProps, - DataGridTableVirtualScrollElements, - DataGridTableVirtualizerOptions, -} \ No newline at end of file diff --git a/apps/web/src/components/reui/stepper.tsx b/apps/web/src/components/reui/stepper.tsx deleted file mode 100644 index 8080e43..0000000 --- a/apps/web/src/components/reui/stepper.tsx +++ /dev/null @@ -1,477 +0,0 @@ -import { - Children, - createContext, - HTMLAttributes, - isValidElement, - ReactElement, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react" -import { mergeProps } from "@base-ui/react/merge-props" -import { useRender } from "@base-ui/react/use-render" - -import { cn } from "@evofw/ui/lib/utils" - -// Types -type StepperOrientation = "horizontal" | "vertical" -type StepState = "active" | "completed" | "inactive" | "loading" -type StepIndicators = { - active?: React.ReactNode - completed?: React.ReactNode - inactive?: React.ReactNode - loading?: React.ReactNode -} - -interface StepperContextValue { - activeStep: number - setActiveStep: (step: number) => void - stepsCount: number - orientation: StepperOrientation - registerTrigger: (node: HTMLButtonElement | null) => void - triggerNodes: HTMLButtonElement[] - focusNext: (currentIdx: number) => void - focusPrev: (currentIdx: number) => void - focusFirst: () => void - focusLast: () => void - indicators: StepIndicators -} - -interface StepItemContextValue { - step: number - state: StepState - isDisabled: boolean - isLoading: boolean -} - -const StepperContext = createContext(undefined) -const StepItemContext = createContext( - undefined -) - -function useStepper() { - const ctx = useContext(StepperContext) - if (!ctx) throw new Error("useStepper must be used within a Stepper") - return ctx -} - -function useStepItem() { - const ctx = useContext(StepItemContext) - if (!ctx) throw new Error("useStepItem must be used within a StepperItem") - return ctx -} - -interface StepperProps extends HTMLAttributes { - defaultValue?: number - value?: number - onValueChange?: (value: number) => void - orientation?: StepperOrientation - indicators?: StepIndicators -} - -function Stepper({ - defaultValue = 1, - value, - onValueChange, - orientation = "horizontal", - className, - children, - indicators = {}, - ...props -}: StepperProps) { - const [activeStep, setActiveStep] = useState(defaultValue) - const [triggerNodes, setTriggerNodes] = useState([]) - - // Register/unregister triggers - const registerTrigger = useCallback((node: HTMLButtonElement | null) => { - setTriggerNodes((prev) => { - if (node && !prev.includes(node)) { - return [...prev, node] - } else if (!node && prev.includes(node!)) { - return prev.filter((n) => n !== node) - } else { - return prev - } - }) - }, []) - - const handleSetActiveStep = useCallback( - (step: number) => { - if (value === undefined) { - setActiveStep(step) - } - onValueChange?.(step) - }, - [value, onValueChange] - ) - - const currentStep = value ?? activeStep - - // Keyboard navigation logic - const focusTrigger = (idx: number) => { - if (triggerNodes[idx]) triggerNodes[idx].focus() - } - const focusNext = (currentIdx: number) => - focusTrigger((currentIdx + 1) % triggerNodes.length) - const focusPrev = (currentIdx: number) => - focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length) - const focusFirst = () => focusTrigger(0) - const focusLast = () => focusTrigger(triggerNodes.length - 1) - - // Context value - const contextValue = useMemo( - () => ({ - activeStep: currentStep, - setActiveStep: handleSetActiveStep, - stepsCount: Children.toArray(children).filter( - (child): child is ReactElement => - isValidElement(child) && - (child.type as { displayName?: string }).displayName === "StepperItem" - ).length, - orientation, - registerTrigger, - focusNext, - focusPrev, - focusFirst, - focusLast, - triggerNodes, - indicators, - }), - [ - currentStep, - handleSetActiveStep, - children, - orientation, - registerTrigger, - triggerNodes, - ] - ) - - return ( - -
- {children} -
-
- ) -} - -interface StepperItemProps extends React.HTMLAttributes { - step: number - completed?: boolean - disabled?: boolean - loading?: boolean -} - -function StepperItem({ - step, - completed = false, - disabled = false, - loading = false, - className, - children, - ...props -}: StepperItemProps) { - const { activeStep } = useStepper() - - const state: StepState = - completed || step < activeStep - ? "completed" - : activeStep === step - ? "active" - : "inactive" - - const isLoading = loading && step === activeStep - - return ( - -
- {children} -
-
- ) -} - -type StepperTriggerProps = useRender.ComponentProps<"button"> - -function StepperTrigger({ - className, - children, - tabIndex, - render, - ...props -}: StepperTriggerProps) { - const { state, isLoading } = useStepItem() - const stepperCtx = useStepper() - const { - setActiveStep, - activeStep, - registerTrigger, - triggerNodes, - focusNext, - focusPrev, - focusFirst, - focusLast, - } = stepperCtx - const { step, isDisabled } = useStepItem() - const isSelected = activeStep === step - const id = `stepper-tab-${step}` - const panelId = `stepper-panel-${step}` - - // Register this trigger for keyboard navigation - const btnRef = useRef(null) - useEffect(() => { - if (btnRef.current) { - registerTrigger(btnRef.current) - } - }, [btnRef.current]) - - // Find our index among triggers for navigation - const myIdx = useMemo( - () => - triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current), - [triggerNodes, btnRef.current] - ) - - const handleKeyDown = (e: React.KeyboardEvent) => { - switch (e.key) { - case "ArrowRight": - case "ArrowDown": - e.preventDefault() - if (myIdx !== -1 && focusNext) focusNext(myIdx) - break - case "ArrowLeft": - case "ArrowUp": - e.preventDefault() - if (myIdx !== -1 && focusPrev) focusPrev(myIdx) - break - case "Home": - e.preventDefault() - if (focusFirst) focusFirst() - break - case "End": - e.preventDefault() - if (focusLast) focusLast() - break - case "Enter": - case " ": - e.preventDefault() - setActiveStep(step) - break - } - } - - const defaultProps = { - role: "tab", - id, - "aria-selected": isSelected, - "aria-controls": panelId, - tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1, - "data-slot": "stepper-trigger", - "data-state": state, - "data-loading": isLoading, - className: cn( - "focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60", - "gap-2.5 rounded-full", - className - ), - onClick: () => setActiveStep(step), - onKeyDown: handleKeyDown, - disabled: isDisabled, - children, - } - - return useRender({ - defaultTagName: "button", - render, - ref: btnRef, - props: mergeProps<"button">(defaultProps, props), - }) -} - -function StepperIndicator({ - children, - className, -}: React.ComponentProps<"div">) { - const { state, isLoading } = useStepItem() - const { indicators } = useStepper() - - return ( -
-
- {indicators && - ((isLoading && indicators.loading) || - (state === "completed" && indicators.completed) || - (state === "active" && indicators.active) || - (state === "inactive" && indicators.inactive)) - ? (isLoading && indicators.loading) || - (state === "completed" && indicators.completed) || - (state === "active" && indicators.active) || - (state === "inactive" && indicators.inactive) - : children} -
-
- ) -} - -function StepperSeparator({ className }: React.ComponentProps<"div">) { - const { state } = useStepItem() - - return ( -
- ) -} - -function StepperTitle({ children, className }: React.ComponentProps<"h3">) { - const { state } = useStepItem() - - return ( -

- {children} -

- ) -} - -function StepperDescription({ - children, - className, -}: React.ComponentProps<"div">) { - const { state } = useStepItem() - - return ( -
- {children} -
- ) -} - -function StepperNav({ children, className }: React.ComponentProps<"nav">) { - const { activeStep, orientation } = useStepper() - - return ( - - ) -} - -function StepperPanel({ children, className }: React.ComponentProps<"div">) { - const { activeStep } = useStepper() - - return ( -
- {children} -
- ) -} - -interface StepperContentProps extends React.ComponentProps<"div"> { - value: number - forceMount?: boolean -} - -function StepperContent({ - value, - forceMount, - children, - className, -}: StepperContentProps) { - const { activeStep } = useStepper() - const isActive = value === activeStep - - if (!forceMount && !isActive) { - return null - } - - return ( - - ) -} - -export { - useStepper, - useStepItem, - Stepper, - StepperItem, - StepperTrigger, - StepperIndicator, - StepperSeparator, - StepperTitle, - StepperDescription, - StepperPanel, - StepperContent, - StepperNav, - type StepperProps, - type StepperItemProps, - type StepperTriggerProps, - type StepperContentProps, -} \ No newline at end of file diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts new file mode 100644 index 0000000..e1bac8d --- /dev/null +++ b/apps/web/src/lib/format.ts @@ -0,0 +1,74 @@ +/** + * Shared ru-RU formatters — single source for dates/numbers across the app. + * All timestamps are ISO strings from the API. + */ + +const packetFmt = new Intl.NumberFormat('ru-RU', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +const numberFmt = new Intl.NumberFormat('ru-RU') + +const shortDateTimeFmt = new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', +}) + +const dateTimeFmt = new Intl.DateTimeFormat('ru-RU') + +const stampDateTimeFmt = new Intl.DateTimeFormat('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}) + +const timeFmt = new Intl.DateTimeFormat('ru-RU', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}) + +/** Compact packet counter: 1,2K / 3,4M */ +export function formatPackets(n: number | undefined | null): string { + if (n === undefined || n === null) return '—' + return packetFmt.format(n) +} + +export function formatNumber(n: number | undefined | null): string { + if (n === undefined || n === null) return '—' + return numberFmt.format(n) +} + +/** dd.MM HH:mm — dense "seen" stamps in grids/cards */ +export function formatShortDateTime(iso: string | null | undefined): string { + if (!iso) return '—' + const t = Date.parse(iso) + if (Number.isNaN(t)) return '—' + return shortDateTimeFmt.format(new Date(t)) +} + +/** Full locale date-time for detail panels */ +export function formatDateTime(iso: string | null | undefined): string { + if (!iso) return '—' + const t = Date.parse(iso) + if (Number.isNaN(t)) return iso + return dateTimeFmt.format(new Date(t)) +} + +/** dd.MM.yyyy HH:mm:ss — block-stats "seen" stamps */ +export function formatStampDateTime(iso: string | null | undefined): string { + if (!iso) return '—' + const t = Date.parse(iso) + if (Number.isNaN(t)) return iso + return stampDateTimeFmt.format(new Date(t)) +} + +export function formatTime(d: Date): string { + return timeFmt.format(d) +} diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts new file mode 100644 index 0000000..9e46d8b --- /dev/null +++ b/apps/web/src/lib/nav.ts @@ -0,0 +1,95 @@ +import { + BarChart3Icon, + LayoutDashboardIcon, + ListIcon, + ServerIcon, + SettingsIcon, + ShieldIcon, +} from 'lucide-react' + +/** + * Single source of the app's route structure: sidebar sections, ⌘K search and + * header breadcrumbs all render from this config. + */ + +export type NavSectionId = 'overview' | 'ops' | 'system' + +export type NavItem = { + to: string + label: string + /** Exact active-state match (root only); prefix match otherwise. */ + exact?: boolean + keywords: string[] + icon: typeof ServerIcon + section: NavSectionId +} + +export const NAV_ITEMS: readonly NavItem[] = [ + { + to: '/', + label: 'Панель управления', + exact: true, + keywords: ['dashboard', 'панель', 'обзор'], + icon: LayoutDashboardIcon, + section: 'overview', + }, + { + to: '/agents', + label: 'Агенты', + keywords: ['agents', 'агенты', 'nodes'], + icon: ServerIcon, + section: 'ops', + }, + { + to: '/lists', + label: 'Списки', + keywords: ['lists', 'списки', 'blocklist'], + icon: ListIcon, + section: 'ops', + }, + { + to: '/rules', + label: 'Наборы правил', + keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'], + icon: ShieldIcon, + section: 'ops', + }, + { + to: '/stats', + label: 'Статистика', + keywords: ['stats', 'статистика', 'packets'], + icon: BarChart3Icon, + section: 'ops', + }, + { + to: '/settings', + label: 'Настройки', + keywords: ['settings', 'настройки'], + icon: SettingsIcon, + section: 'system', + }, +] + +export const NAV_SECTIONS = [ + { id: 'overview', label: 'Обзор' }, + { id: 'ops', label: 'Операции' }, + { id: 'system', label: 'Система' }, +] as const satisfies readonly { id: NavSectionId; label: string }[] + +export function navItemsForSection(section: NavSectionId): readonly NavItem[] { + return NAV_ITEMS.filter((item) => item.section === section) +} + +export function navLabel(to: string): string | undefined { + return NAV_ITEMS.find((item) => item.to === to)?.label +} + +/** Detail routes (…/:id) that nest under a nav route. */ +const DETAIL_PARENT_RE = /^\/(agents|lists|rules)\/[^/]+$/ + +export function navParentForDetail( + pathname: string, +): string | undefined { + const m = pathname.match(DETAIL_PARENT_RE) + return m ? `/${m[1]}` : undefined +} diff --git a/apps/web/src/lib/permissions.ts b/apps/web/src/lib/permissions.ts new file mode 100644 index 0000000..625bc56 --- /dev/null +++ b/apps/web/src/lib/permissions.ts @@ -0,0 +1,19 @@ +import { hasPermission } from '@evofw/shared' +import { getClaims, isAuthEnabled } from './auth' + +/** + * Client-side RBAC gating. The backend remains authoritative; this only hides + * actions the current portal user cannot perform (fw:
:). + * When auth is disabled (dev) everything is allowed. + */ +export function can(permission: string): boolean { + if (!isAuthEnabled()) return true + const claims = getClaims() + if (!claims) return false + if (claims.is_admin) return true + return hasPermission(claims.permissions ?? [], permission) +} + +export function useCan() { + return can +} diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index 822d455..1eedf0b 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -47,6 +47,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog' import { QueryState } from '@/components/query-state' import { agentsQueryOptions, settingsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' +import { useCan } from '@/lib/permissions' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { Button } from '@evofw/ui/components/button' import { @@ -97,6 +98,7 @@ function AgentsPage() { const agentsQ = useQuery(agentsQueryOptions()) const settingsQ = useQuery(settingsQueryOptions()) const { copyToClipboard } = useCopyToClipboard() + const canWrite = useCan()('fw:agents:write') const [createOpen, setCreateOpen] = useState(false) const [deleteAgentId, setDeleteAgentId] = useState(null) const [filters, setFilters] = useState([]) @@ -418,12 +420,12 @@ function AgentsPage() { ) - const addButton = ( + const addButton = canWrite ? ( - ) + ) : null return ( @@ -454,6 +456,7 @@ function AgentsPage() { > Показать + {canWrite ? ( + ) : null}
diff --git a/apps/web/src/routes/_auth/lists/index.tsx b/apps/web/src/routes/_auth/lists/index.tsx index b6d32fd..dd1c74c 100644 --- a/apps/web/src/routes/_auth/lists/index.tsx +++ b/apps/web/src/routes/_auth/lists/index.tsx @@ -15,6 +15,7 @@ import { import { ConfirmDialog } from '@/components/confirm-dialog' import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' +import { useCan } from '@/lib/permissions' import { Autocomplete, AutocompleteContent, @@ -67,6 +68,7 @@ const CREATE_SOURCE_ITEMS = [ function ListsPage() { const navigate = useNavigate() const qc = useQueryClient() + const canWrite = useCan()('fw:lists:write') const [createOpen, setCreateOpen] = useState(false) const [name, setName] = useState('') const [source, setSource] = useState('static') @@ -199,10 +201,12 @@ function ListsPage() { /> Обновить - + {canWrite ? ( + + ) : null} } /> diff --git a/apps/web/src/routes/_auth/rules/index.tsx b/apps/web/src/routes/_auth/rules/index.tsx index 132d348..9b5b622 100644 --- a/apps/web/src/routes/_auth/rules/index.tsx +++ b/apps/web/src/routes/_auth/rules/index.tsx @@ -13,6 +13,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog' import { PolicySetIcon } from '@/components/rules/policy-set-icon' import { policySetsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' +import { useCan } from '@/lib/permissions' import { Button } from '@evofw/ui/components/button' import { Field, FieldLabel } from '@evofw/ui/components/field' import { Input } from '@evofw/ui/components/input' @@ -200,12 +201,13 @@ function PolicySetsPage() { [navigate], ) - const addButton = ( + const canCreate = useCan()('fw:policies:write') + const addButton = canCreate ? ( - ) + ) : null return ( diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/settings.tsx index cfdc1b9..12d898c 100644 --- a/apps/web/src/routes/_auth/settings.tsx +++ b/apps/web/src/routes/_auth/settings.tsx @@ -14,12 +14,12 @@ import { LoadingButton } from '@/components/loading-button' import { SettingRow } from '@/components/setting-row' import { settingsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' +import { useCan } from '@/lib/permissions' import { Input } from '@evofw/ui/components/input' import { Switch } from '@evofw/ui/components/switch' /** * Control plane settings — single page: PageShell + Frame + SettingRow. - * SettingsShell (reui-kit) — только при 2+ секциях. * Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 */ @@ -30,6 +30,7 @@ export const Route = createFileRoute('/_auth/settings')({ function SettingsPage() { const qc = useQueryClient() const settingsQ = useQuery(settingsQueryOptions()) + const canSave = useCan()('fw:settings:admin') const [form, setForm] = useState>({}) // Seed the form once — a background refetch must not wipe in-progress edits. @@ -134,16 +135,18 @@ function SettingsPage() { -
- save.mutate()} - isLoading={save.isPending} - disabled={!initialized.current} - loadingLabel="Сохранение…" - > - Сохранить - -
+ {canSave ? ( +
+ save.mutate()} + isLoading={save.isPending} + disabled={!initialized.current} + loadingLabel="Сохранение…" + > + Сохранить + +
+ ) : null}
) }