From 10b5bbc9cf36a5e995673d0c44261455e96d5851 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 19:28:46 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BE=D0=B1=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0,=20KPI-=D0=BF=D0=BE=D0=BB=D0=BE=D1=81=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4=D0=B8=D0=BD=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=D1=83=20=D0=BC=D0=B0=D0=BA=D0=B5=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Боковое меню, шапка, монитор системы и палитра Ctrl+K переведены на общие блоки макета, данные из живых запросов (агенты, списки, наборы правил). KPI стали полосой в одном Frame, таблицы ResourcePage получили тулбар как в MikrotikManager (сегменты, фильтры, поиск, счётчик), подложку шапки и ячейки py-3. Старая оболочка удалена. Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/components/app-sidebar.tsx | 89 --- apps/web/src/components/layout/app-shell.tsx | 34 -- apps/web/src/components/layout/apps-menu.tsx | 105 ---- apps/web/src/components/layout/nav-user.tsx | 212 ------- .../src/components/layout/portal-shell.tsx | 174 ++++++ .../web/src/components/layout/search-menu.tsx | 220 ------- .../web/src/components/layout/site-header.tsx | 112 ---- .../layout/system-monitor-popover.tsx | 235 -------- apps/web/src/components/portal/bits.tsx | 481 +++++++++++++++ .../src/components/portal/chrome-context.tsx | 83 +++ .../web/src/components/portal/chrome-store.ts | 86 +++ apps/web/src/components/portal/chrome.tsx | 569 ++++++++++++++++++ apps/web/src/components/portal/data-page.tsx | 87 +++ apps/web/src/components/portal/format.ts | 45 ++ apps/web/src/components/portal/kpi-strip.tsx | 74 +++ apps/web/src/components/portal/kpi.tsx | 24 + apps/web/src/components/portal/tone.ts | 28 + .../src/components/reui-kit/kpi-stat-grid.tsx | 82 ++- .../src/components/reui-kit/resource-page.tsx | 127 ++-- apps/web/src/main.tsx | 1 + apps/web/src/portal-theme.css | 23 + apps/web/src/routes/_auth.tsx | 6 +- 22 files changed, 1789 insertions(+), 1108 deletions(-) delete mode 100644 apps/web/src/components/app-sidebar.tsx delete mode 100644 apps/web/src/components/layout/app-shell.tsx delete mode 100644 apps/web/src/components/layout/apps-menu.tsx delete mode 100644 apps/web/src/components/layout/nav-user.tsx create mode 100644 apps/web/src/components/layout/portal-shell.tsx delete mode 100644 apps/web/src/components/layout/search-menu.tsx delete mode 100644 apps/web/src/components/layout/site-header.tsx delete mode 100644 apps/web/src/components/layout/system-monitor-popover.tsx create mode 100644 apps/web/src/components/portal/bits.tsx create mode 100644 apps/web/src/components/portal/chrome-context.tsx create mode 100644 apps/web/src/components/portal/chrome-store.ts create mode 100644 apps/web/src/components/portal/chrome.tsx create mode 100644 apps/web/src/components/portal/data-page.tsx create mode 100644 apps/web/src/components/portal/format.ts create mode 100644 apps/web/src/components/portal/kpi-strip.tsx create mode 100644 apps/web/src/components/portal/kpi.tsx create mode 100644 apps/web/src/components/portal/tone.ts create mode 100644 apps/web/src/portal-theme.css diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx deleted file mode 100644 index ebd3eb0..0000000 --- a/apps/web/src/components/app-sidebar.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Link, useRouterState } from '@tanstack/react-router' -import { AppSwitcher } from '@/components/app-switcher' -import { NavUser } from '@/components/layout/nav-user' -import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav' -import { useCan } from '@/lib/permissions' -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from '@evofw/ui/components/sidebar' - -function isNavActive(pathname: string, to: string, exact?: boolean) { - if (exact) return pathname === to - return pathname === to || pathname.startsWith(`${to}/`) -} - -function NavSection({ - label, - items, - pathname, - can, -}: { - label: string - items: readonly NavItem[] - pathname: string - can: (permission: string) => boolean -}) { - const visible = items.filter( - (item) => !item.permission || can(item.permission), - ) - if (visible.length === 0) return null - return ( - - {label} - - - {visible.map((item) => ( - - - } - > - - {item.label} - - - ))} - - - - ) -} - -export function AppSidebar() { - const pathname = useRouterState({ select: (s) => s.location.pathname }) - const can = useCan() - - return ( - - - - - - {NAV_SECTIONS.map((section) => ( - - ))} - - - - - - ) -} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx deleted file mode 100644 index ede102d..0000000 --- a/apps/web/src/components/layout/app-shell.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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' -import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' - -/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */ -export function AppShell({ children }: { children: ReactNode }) { - return ( - - - - К содержимому - - - - -
- {children} -
-
- -
-
- ) -} diff --git a/apps/web/src/components/layout/apps-menu.tsx b/apps/web/src/components/layout/apps-menu.tsx deleted file mode 100644 index 66d5e74..0000000 --- a/apps/web/src/components/layout/apps-menu.tsx +++ /dev/null @@ -1,105 +0,0 @@ -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/nav-user.tsx b/apps/web/src/components/layout/nav-user.tsx deleted file mode 100644 index 120f175..0000000 --- a/apps/web/src/components/layout/nav-user.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { useEffect, useState } from 'react' -import { useTheme } from 'next-themes' -import { - ChevronsUpDownIcon, - LogOutIcon, - MonitorIcon, - MoonIcon, - PaletteIcon, - SettingsIcon, - SunIcon, -} from 'lucide-react' - -import { cn } from '@evofw/ui/lib/utils' -import { Avatar, AvatarFallback } from '@evofw/ui/components/avatar' -import { Button } from '@evofw/ui/components/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@evofw/ui/components/dropdown-menu' -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from '@evofw/ui/components/sidebar' - -import { - authPortalUrl, - getClaims, - isAuthEnabled, - logout, -} from '@/lib/auth' - -/** Sidebar footer NavUser — theme in footer, not header. @see https://reui.io/preview/base/app-shell-1 */ - -const THEMES = [ - { - value: 'light', - label: 'Светлая', - icon: , - }, - { - value: 'dark', - label: 'Тёмная', - icon: , - }, - { - value: 'system', - label: 'Системная', - icon: , - }, -] as const - -function ThemeSegmentedToggle() { - const { theme, setTheme } = useTheme() - const [mounted, setMounted] = useState(false) - - useEffect(() => { - setMounted(true) - }, []) - - const currentTheme = mounted ? (theme ?? 'system') : 'system' - - return ( -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - > - {THEMES.map(({ value, label, icon }) => { - const isActive = currentTheme === value - return ( - - ) - })} -
- ) -} - -function initials(source: string): string { - const base = source.trim() - if (!base) return '?' - const parts = base.split(/\s+/).filter(Boolean) - if (parts.length >= 2) { - return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase() - } - return base.slice(0, 2).toUpperCase() -} - -export function NavUser() { - const { isMobile } = useSidebar() - const authOn = isAuthEnabled() - const claims = getClaims() - - const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость') - const email = claims?.email?.trim() || (authOn ? '' : 'auth выключен') - const fallback = initials(name || email) - - return ( - - - - - } - > - - {fallback} - -
- {name} - - {email || '—'} - -
- -
- - - - - - {fallback} - - -
- {name} - - {email || '—'} - -
-
-
- - - - - } - > - - Настройки - - {authOn ? ( - } - > - Auth Portal - - ) : null} - - - Тема -
- -
-
-
- - {authOn ? ( - <> - - - logout()}> - - Выйти - - - - ) : null} -
-
-
-
- ) -} diff --git a/apps/web/src/components/layout/portal-shell.tsx b/apps/web/src/components/layout/portal-shell.tsx new file mode 100644 index 0000000..13a8ace --- /dev/null +++ b/apps/web/src/components/layout/portal-shell.tsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { PlusIcon, ServerIcon, ShieldPlusIcon } from 'lucide-react' +import { AppSwitcher } from '@/components/app-switcher' +import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome' +import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context' +import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store' +import { getClaims, isAuthEnabled, logout } from '@/lib/auth' +import { NAV_ITEMS, NAV_SECTIONS } from '@/lib/nav' +import { useCan } from '@/lib/permissions' +import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' +import { agentsQueryOptions, dashboardQueryOptions, listsQueryOptions, policySetsQueryOptions } from '@/queries' +import { Skeleton } from '@evofw/ui/components/skeleton' +import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar' +import { TooltipProvider } from '@evofw/ui/components/tooltip' + +const FRESH: Record = { + '/agents': { badge: 'Live', text: 'GET /api/v1/agents · статус и последняя связь агентов обновляются раз в минуту' }, + '/lists': { badge: 'Списки', text: 'GET /api/v1/lists · статические, JSON-URL и community EvoBGP' }, + '/rules': { badge: 'Политики', text: 'GET /api/v1/policy-sets · порядок правил задаёт приоритет' }, + '/stats': { badge: 'Live', text: 'Счётчики пакетов с агентов · накопительно до сброса' }, + '/settings': { badge: 'Настройки', text: 'Действие по умолчанию, интеграция с EvoBGP и ссылки установки' }, + '/': { badge: 'Live', text: 'GET /api/v1/dashboard · агенты, списки и пакеты' }, +} + +function freshFor(pathname: string) { + const key = Object.keys(FRESH) + .filter((k) => k !== '/') + .sort((a, b) => b.length - a.length) + .find((k) => pathname === k || pathname.startsWith(`${k}/`)) + return FRESH[key ?? '/']! +} + +function initialsOf(name: string) { + const parts = name.trim().split(/[\s@._-]+/).filter(Boolean) + return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase() +} + +/** Меню и данные оболочки EvoFirewall: пункты по правам, точки проблем и монитор из живых запросов. */ +function useChromeData(): ChromeConfig { + const can = useCan() + const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 }) + const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 }) + const listsQ = useQuery({ ...listsQueryOptions(), refetchInterval: 60_000 }) + const setsQ = useQuery({ ...policySetsQueryOptions(), refetchInterval: 60_000 }) + + return useMemo(() => { + const d = dashQ.data + const agents = agentsQ.data?.items ?? [] + const lists = listsQ.data?.items ?? [] + const sets = setsQ.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 offline = Math.max(0, approved - online) + const onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0 + const apiOk = !dashQ.isError + const failedLists = lists.filter((l) => l.last_error).length + const rulesCount = sets.reduce((n, s) => n + (s.rules_count ?? 0), 0) + + const problems: Record = { + '/agents': pending > 0 ? { tone: 'wn', label: `${pending} агентов ждут одобрения` } : offline > 0 ? { tone: 'er', label: `${offline} агентов офлайн` } : undefined, + '/lists': failedLists > 0 ? { tone: 'er', label: `Ошибок обновления списков: ${failedLists}` } : undefined, + } + const groupLabel = Object.fromEntries(NAV_SECTIONS.map((s) => [s.id, s.label])) + const visible = NAV_ITEMS.filter((item) => !item.permission || can(item.permission)) + const navItems: NavItem[] = visible.map((item) => ({ + id: item.to, + label: item.label, + to: item.to, + icon: item.icon, + group: groupLabel[item.section]!, + problem: problems[item.to], + })) + const groups = [...new Set(navItems.map((i) => i.group))] + + const monitorItems: MonitorItem[] = [] + if (!apiOk) monitorItems.push({ id: 'api', tone: 'er', title: 'API недоступен', text: 'GET /api/v1/dashboard вернул ошибку', age: 'сейчас', to: '/' }) + if (pending > 0) monitorItems.push({ id: 'pending', tone: 'wn', title: `Ждут одобрения: ${pending}`, text: 'Новые агенты не получают политику, пока их не одобрят', age: 'сейчас', to: '/agents' }) + if (offline > 0) monitorItems.push({ id: 'offline', tone: 'er', title: `Агентов офлайн: ${offline}`, text: `Онлайн ${online} из ${approved} одобренных`, age: 'сейчас', to: '/agents' }) + if (failedLists > 0) monitorItems.push({ id: 'lists', tone: 'wn', title: `Списков с ошибкой: ${failedLists}`, text: 'Проверьте источник списка и повторите обновление', age: 'недавно', to: '/lists' }) + if (monitorItems.length === 0) monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, агенты и списки без проблем', age: 'сейчас', to: '/' }) + + const metrics: MonitorMetric[] = [ + { l: 'API', v: apiOk ? 'OK' : 'ERR', p: apiOk ? 100 : 0, tone: apiOk ? 'success' : 'warning' }, + { l: 'Агенты online', v: `${onlinePct}%`, p: onlinePct, tone: onlinePct >= 80 ? 'success' : 'warning' }, + { l: 'Списки IP', v: String(d?.lists_total ?? lists.length), p: lists.length ? Math.round(((lists.length - failedLists) / lists.length) * 100) : 100, tone: failedLists > 0 ? 'warning' : 'success' }, + { l: 'Правила', v: String(rulesCount), p: Math.min(100, rulesCount), tone: 'success' }, + ] + + const claims = getClaims() + const name = claims?.name?.trim() || (isAuthEnabled() ? 'Пользователь' : 'Гость') + const email = claims?.email?.trim() || (isAuthEnabled() ? '' : 'auth выключен') + + const objects: PaletteObject[] = [ + ...agents.slice(0, 50).map((a) => ({ + id: `agent-${a.id}`, + group: 'Агенты', + label: a.name, + hint: `${a.platform}${a.last_seen_ip ? ` · ${a.last_seen_ip}` : ''}`, + dot: a.status !== 'approved' ? 'mu' : a.last_seen_at ? 'ok' : 'er', + to: `/agents/${a.id}`, + })), + ...lists.slice(0, 50).map((l) => ({ id: `list-${l.id}`, group: 'Списки', label: l.name, hint: l.type, dot: l.last_error ? 'er' : 'ok', to: `/lists/${l.id}` })), + ...sets.slice(0, 50).map((s) => ({ id: `set-${s.id}`, group: 'Наборы правил', label: s.name, hint: `${s.rules_count ?? 0} правил`, dot: s.enabled ? 'ok' : 'mu', to: `/rules/${s.id}` })), + ] + + return { + navItems, + navGroups: groups, + brand: , + user: { name, email, initials: initialsOf(name || email) }, + securityTo: '/settings', + onLogout: logout, + fresh: freshFor, + monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/agents', footerLabel: 'Агенты' }, + palette: { + actions: [ + { id: 'act-agent', label: 'Добавить агента', hint: 'A', icon: ServerIcon, to: '/agents' }, + { id: 'act-list', label: 'Создать список', hint: 'L', icon: PlusIcon, to: '/lists' }, + { id: 'act-set', label: 'Создать набор правил', hint: 'R', icon: ShieldPlusIcon, to: '/rules' }, + ], + objects, + assistantTo: '/agents', + example: 'Подсказка: «агенты офлайн»', + }, + } + }, [can, dashQ.data, dashQ.isError, agentsQ.data, listsQ.data, setsQ.data]) +} + +/** Оболочка EvoFirewall: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */ +export function PortalShell({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(true) + const [mounted, setMounted] = useState(false) + useEffect(() => { + // До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet). + const mq = window.matchMedia('(min-width: 1100px)') + const sync = () => setOpen(mq.matches) + sync() + mq.addEventListener('change', sync) + setMounted(true) + return () => mq.removeEventListener('change', sync) + }, []) + const config = useChromeData() + + if (!mounted) { + return ( +
+ + +
+ ) + } + + return ( + + + + + + К содержимому + + + + +
{children}
+
+ +
+
+
+
+ ) +} diff --git a/apps/web/src/components/layout/search-menu.tsx b/apps/web/src/components/layout/search-menu.tsx deleted file mode 100644 index f3ef27d..0000000 --- a/apps/web/src/components/layout/search-menu.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { useEffect, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' -import { useQuery } from '@tanstack/react-query' -import { - ListIcon, - ListPlusIcon, - PlusIcon, - ServerIcon, - ShieldIcon, - ShieldPlusIcon, -} from 'lucide-react' -import { NAV_ITEMS } from '@/lib/nav' -import { useCan } from '@/lib/permissions' -import { - agentsQueryOptions, - listsQueryOptions, - policySetsQueryOptions, -} from '@/queries' -import { - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from '@evofw/ui/components/command' - -/** - * Command-K palette — навигация, поиск ресурсов и действия. - * DNA: https://reui.io/components/command/c-command-7 (hotkey-only по контракту). - */ - -export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) { - void hotkeyOnly - return -} - -function SearchMenuDialog() { - const [open, setOpen] = useState(false) - const navigate = useNavigate() - const can = useCan() - - const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: open }) - const listsQ = useQuery({ ...listsQueryOptions(), enabled: open }) - const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: open }) - - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - setOpen((prev) => !prev) - } - } - - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }, []) - - const go = (run: () => void) => { - setOpen(false) - run() - } - - const navItems = NAV_ITEMS.filter( - (item) => !item.permission || can(item.permission), - ) - - const agents = (agentsQ.data?.items ?? []).slice(0, 50) - const lists = (listsQ.data?.items ?? []).slice(0, 50) - const sets = (setsQ.data?.items ?? []).slice(0, 50) - - return ( - - - - Ничего не найдено. - - - {can('fw:agents:write') ? ( - - go(() => - void navigate({ - to: '/agents', - search: { view: 'cards', add: true }, - }), - ) - } - > - - Добавить агента - - ) : null} - {can('fw:lists:write') ? ( - - go(() => - void navigate({ to: '/lists', search: { create: true } }), - ) - } - > - - Создать список - - ) : null} - {can('fw:policies:write') ? ( - - go(() => - void navigate({ to: '/rules', search: { create: true } }), - ) - } - > - - Создать набор правил - - ) : null} - - - - - - {navItems.map((item) => ( - go(() => void navigate({ to: item.to }))} - > - - {item.label} - - ))} - - - {agents.length > 0 ? ( - <> - - - {agents.map((a) => ( - - go(() => - void navigate({ - to: '/agents', - search: { view: 'cards', agent: a.id }, - }), - ) - } - > - - {a.name} - - {a.hostname ?? a.status} - - - ))} - - - ) : null} - - {lists.length > 0 ? ( - <> - - - {lists.map((l) => ( - - go(() => void navigate({ to: '/lists/$id', params: { id: l.id } })) - } - > - - {l.name} - - ))} - - - ) : null} - - {sets.length > 0 ? ( - <> - - - {sets.map((s) => ( - - go(() => - void navigate({ - to: '/rules/$setId', - params: { setId: s.id }, - }), - ) - } - > - - {s.name} - - ))} - - - ) : null} - - - ) -} diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx deleted file mode 100644 index a9093cf..0000000 --- a/apps/web/src/components/layout/site-header.tsx +++ /dev/null @@ -1,112 +0,0 @@ -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 { SidebarTrigger } from '@evofw/ui/components/sidebar' -import { navLabel, navParentForDetail } from '@/lib/nav' - -export interface RouteBreadcrumbLoaderData { - breadcrumb?: string -} - -function getBreadcrumbs( - pathname: string, - dynamicLabels: Record, -) { - if (pathname === '/') { - return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }] - } - - const parentTo = navParentForDetail(pathname) - if (parentTo) { - const parentLabel = navLabel(parentTo) - const fallback = - parentTo === '/agents' - ? 'Агент' - : parentTo === '/lists' - ? 'Список' - : 'Набор' - return [ - { label: parentLabel ?? parentTo, href: parentTo }, - { label: dynamicLabels[pathname] ?? fallback, href: pathname }, - ] - } - - const title = navLabel(pathname) - if (title) { - return [{ label: title, href: pathname }] - } - - return [{ label: navLabel('/') ?? 'Панель управления', 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; theme in NavUser. */ -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 deleted file mode 100644 index 45f7d05..0000000 --- a/apps/web/src/components/layout/system-monitor-popover.tsx +++ /dev/null @@ -1,235 +0,0 @@ -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' -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 { - agentsQueryOptions, - dashboardQueryOptions, - listsQueryOptions, - policySetsQueryOptions, -} 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 — CFDM/EvoBGP chrome pill. 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 setsQ = useQuery({ - ...policySetsQueryOptions(), - 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 = - setsQ.data?.items.reduce((n, s) => n + (s.rules_count ?? 0), 0) ?? 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 spiking = metrics.some((m) => m.alert) - - return ( - - - } - > - - - {spiking ? ( - - ) : null} - - - Система - - - {spiking ? 'Внимание' : 'Норма'} - - - - -
- - Монитор EvoFirewall - - - {formatTime(new Date())} - -
-
- {metrics.map((metric, i) => ( -
= 2 && 'border-border border-t', - )} - > - -
- ))} -
-
- Pending:{' '} - - {pending} - - {' · '} - Online:{' '} - - {online}/{approved || '—'} - -
-
-
- ) -} diff --git a/apps/web/src/components/portal/bits.tsx b/apps/web/src/components/portal/bits.tsx new file mode 100644 index 0000000..858765d --- /dev/null +++ b/apps/web/src/components/portal/bits.tsx @@ -0,0 +1,481 @@ +import { useState, type ReactNode } from 'react' +import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react' +import { toast } from 'sonner' +import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert' +import { Badge } from '@/components/reui/badge' +import { + Frame, + FrameDescription, + FrameHeader, + FramePanel, + FrameTitle, +} from '@/components/reui/frame' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@evofw/ui/components/alert-dialog' +import { Button } from '@evofw/ui/components/button' +import { cn } from '@evofw/ui/lib/utils' +import { Input } from '@evofw/ui/components/input' +import { Label } from '@evofw/ui/components/label' +import { Tabs, TabsList, TabsTrigger } from '@evofw/ui/components/tabs' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@evofw/ui/components/sheet' +import type { Tone } from './tone' +import { TONE_BADGE } from './tone' + +const TONE_GLYPH: Record = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' } + +/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */ +export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) { + const glyph = TONE_GLYPH[tone] + return ( + + {glyph ? {glyph} : null} + {children} + + ) +} + +/** Заголовок страницы: название, описание, метка свежести и действия. */ +export function PageHead({ + title, + sub, + actions, +}: { + title: string + sub: string + actions?: ReactNode +}) { + return ( +
+
+

{title}

+

{sub}

+
+ {actions ?
{actions}
: null} +
+ ) +} + +const ALERT_ICON = { + er: , + wn: , + in: , +} as const + +const ALERT_VARIANT = { + er: 'destructive', + wn: 'warning', + in: 'info', +} as const + +export function SignalAlert({ + tone, + title, + children, + action, +}: { + tone: 'er' | 'wn' | 'in' + title: string + children: ReactNode + action?: ReactNode +}) { + return ( + + {ALERT_ICON[tone]} + {title} + {children} + {action ? {action} : null} + + ) +} + +/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */ +export function PortalSheet({ + open, + onOpenChange, + title, + description, + children, + footer, + size = 'md', + dirty, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + children: ReactNode + footer?: ReactNode + /** Ширина панели: md — форма, lg — редактор с двумя колонками. */ + size?: 'md' | 'lg' + /** Явный признак изменений (иначе ловим ввод в полях и переключатели). */ + dirty?: boolean +}) { + const [touchedState, setTouched] = useState(false) + const touched = dirty ?? touchedState + const [guard, setGuard] = useState(false) + + function requestChange(next: boolean) { + if (!next && touched) { + setGuard(true) + return + } + if (!next) setTouched(false) + onOpenChange(next) + } + + return ( + <> + + + +
+ {title} + + } + > + +
+ + {description} + +
+
setTouched(true)} + onClick={(e) => { + if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true) + }} + > + {children} +
+ {footer ? ( + { + // «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение. + const label = (e.target as HTMLElement).closest('button')?.textContent?.trim() + if (touched && (label === 'Закрыть' || label === 'Отмена')) { + e.stopPropagation() + setGuard(true) + } + }} + >{footer} + ) : null} +
+
+ + + + Закрыть без сохранения? + Изменения в форме будут потеряны. + + + Остаться + + + + + + ) +} + +/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */ +export function TypedConfirm({ + open, + onOpenChange, + title, + description, + expected, + confirmLabel, + onConfirm, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: ReactNode + expected: string + confirmLabel: string + onConfirm: () => void +}) { + const [value, setValue] = useState('') + const matches = value.trim().toLowerCase() === expected.toLowerCase() + + return ( + { + if (!next) setValue('') + onOpenChange(next) + }} + > + + + {title} + {description} + +
+ + setValue(e.target.value)} + aria-invalid={value.length > 0 && !matches} + /> +
+ + Отмена + + +
+
+ ) +} + +export function KvFrame({ + title, + description, + items, +}: { + title: string + description: string + items: Array<{ label: string; value: ReactNode }> +}) { + return ( + + + {title} + {description} + + +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ + ) +} + +/** Список «значение + подпись + копировать» (параметры issuer). */ +export function CopyList({ + title, + description, + items, +}: { + title: string + description: string + items: Array<{ label: string; value: string }> +}) { + return ( + + + {title} + {description} + + +
    + {items.map((item) => ( +
  • + {item.value} + {item.label} + +
  • + ))} +
+
+ + ) +} + +/** Компактный список «ключ — значение» для боковых окон. */ +export function KvList({ items }: { items: Array<[string, string]> }) { + return ( +
+ {items.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ) +} + +export function CodeBlock({ label, children }: { label: string; children: string }) { + return ( +
+ {label} +
{children}
+
+ ) +} + +/** Сегментированный переключатель на Tabs (вид таблицы, период). */ +export function Segmented({ + label, + options, + value, + onChange, +}: { + label: string + options: string[] + value: number + onChange: (index: number) => void +}) { + return ( + onChange(Number(v))} aria-label={label} className="max-w-full"> + + {options.map((o, i) => ( + + {o} + + ))} + + + ) +} + +/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */ +export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) { + return ( + + {mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'} + + ) +} + +/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */ +export function PageTabsList({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) { + return ( + + {label} + {count !== undefined ? {count} : null} + + ) +} + +/** Строка настройки как на досках: слева название и описание, справа элемент управления. */ +export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) { + return ( +
+
+ {title} + {description} +
+
{children}
+
+ ) +} + +/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */ +export function FormFrame({ + title, + description, + badge, + tone = 'ok', + children, +}: { + title: string + description: string + badge: string + tone?: Tone + children: ReactNode +}) { + return ( + + +
+ {title} + {description} +
+ {badge} +
+ {children} + + ) +} diff --git a/apps/web/src/components/portal/chrome-context.tsx b/apps/web/src/components/portal/chrome-context.tsx new file mode 100644 index 0000000..6d8aeb7 --- /dev/null +++ b/apps/web/src/components/portal/chrome-context.tsx @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store' + +export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) { + return {children} +} + +function readStored(key: string, fallback: T): T { + try { + const raw = window.localStorage.getItem(key) + return raw ? (JSON.parse(raw) as T) : fallback + } catch { + return fallback + } +} + +function writeStored(key: string, value: unknown) { + try { + window.localStorage.setItem(key, JSON.stringify(value)) + } catch { + /* без сохранения настроек */ + } +} + +/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */ +export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) { + const [paletteOpen, setPaletteOpen] = useState(false) + const [density, setDensityState] = useState(() => readStored(`${storageKey}:density`, 'comfortable')) + const [contrast, setContrastState] = useState(() => readStored(`${storageKey}:contrast`, 'standard')) + const [pins, setPins] = useState(() => readStored(`${storageKey}:pins`, [])) + + const setDensity = useCallback( + (v: Density) => { + setDensityState(v) + writeStored(`${storageKey}:density`, v) + }, + [storageKey], + ) + const setContrast = useCallback( + (v: Contrast) => { + setContrastState(v) + writeStored(`${storageKey}:contrast`, v) + }, + [storageKey], + ) + const togglePin = useCallback( + (id: string) => { + setPins((prev) => { + const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + writeStored(`${storageKey}:pins`, next) + return next + }) + }, + [storageKey], + ) + + useEffect(() => { + const root = document.documentElement + root.dataset.density = density + root.dataset.contrast = contrast + return () => { + delete root.dataset.density + delete root.dataset.contrast + } + }, [density, contrast]) + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault() + setPaletteOpen((v) => !v) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + const value = useMemo( + () => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }), + [paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast], + ) + return {children} +} diff --git a/apps/web/src/components/portal/chrome-store.ts b/apps/web/src/components/portal/chrome-store.ts new file mode 100644 index 0000000..7763f0d --- /dev/null +++ b/apps/web/src/components/portal/chrome-store.ts @@ -0,0 +1,86 @@ +import { createContext, useContext, type ComponentType, type ReactNode } from 'react' +import type { LucideIcon } from 'lucide-react' + +export type NavItem = { + id: string + label: string + to: string + icon: LucideIcon + group: string + /** Точка проблемы в углу иконки. */ + problem?: { tone: 'er' | 'wn'; label: string } +} + +export type MonitorItem = { + id: string + tone: 'er' | 'wn' | 'ok' + title: string + text: string + age: string + to: string +} + +export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' } + +export type PaletteAction = { + id: string + label: string + /** Горячая буква справа. */ + hint: string + icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }> + to: string +} + +export type PaletteObject = { + id: string + group: string + label: string + hint: string + dot?: 'ok' | 'er' | 'mu' + to: string +} + +/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */ +export type ChromeConfig = { + navItems: NavItem[] + navGroups: string[] + /** Верх бокового меню: бренд и переключатель приложений. */ + brand: ReactNode + user: { name: string; email: string; initials: string } + securityTo: string + onLogout: () => void + fresh: (pathname: string) => { badge: string; text: string } + crumbFor?: (pathname: string, label: string | undefined) => string + monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string } + palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string } +} + +export const ChromeConfigContext = createContext(null) + +export function useChromeConfig(): ChromeConfig { + const ctx = useContext(ChromeConfigContext) + if (!ctx) throw new Error('ChromeConfigProvider отсутствует') + return ctx +} + +export type Density = 'comfortable' | 'compact' +export type Contrast = 'standard' | 'high' + +export type UiStore = { + paletteOpen: boolean + setPaletteOpen: (open: boolean) => void + pins: string[] + togglePin: (id: string) => void + density: Density + setDensity: (v: Density) => void + contrast: Contrast + setContrast: (v: Contrast) => void +} + +export const UiStoreContext = createContext(null) + +export function useUiStore(): UiStore { + const ctx = useContext(UiStoreContext) + if (!ctx) throw new Error('PortalUiProvider отсутствует') + return ctx +} diff --git a/apps/web/src/components/portal/chrome.tsx b/apps/web/src/components/portal/chrome.tsx new file mode 100644 index 0000000..b9be4d3 --- /dev/null +++ b/apps/web/src/components/portal/chrome.tsx @@ -0,0 +1,569 @@ +import { + useMemo, + useState, + type ComponentType, +} from 'react' +import { + Link, + useNavigate, + useRouterState, +} from '@tanstack/react-router' +import { + ActivityIcon, + ArrowRightIcon, + ChevronDownIcon, + ChevronsUpDownIcon, + FingerprintIcon, + InfoIcon, + LogOutIcon, + SearchIcon, + StarIcon, + MoonIcon, +} from 'lucide-react' +import { + useTheme, +} from 'next-themes' +import { + Badge, +} from '@/components/reui/badge' +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, +} from '@evofw/ui/components/breadcrumb' +import { + Button, +} from '@evofw/ui/components/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@evofw/ui/components/dialog' +import { + Kbd, +} from '@evofw/ui/components/kbd' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@evofw/ui/components/popover' +import { + Separator, +} from '@evofw/ui/components/separator' +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} from '@evofw/ui/components/sidebar' +import { + cn, +} from '@evofw/ui/lib/utils' +import { + Segmented, +} from './bits' +import { + useChromeConfig, + useUiStore, + type NavItem, +} from './chrome-store' + +const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase' + +function PortalNavUser() { + const { isMobile } = useSidebar() + const { theme, setTheme, resolvedTheme } = useTheme() + const { density, setDensity, contrast, setContrast } = useUiStore() + const { user, securityTo, onLogout } = useChromeConfig() + const navigate = useNavigate() + const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark' + + return ( + + + + + } + > +
+ {user.initials} +
+
+ {user.name} + {user.email} +
+ +
+ +
+
+ {user.initials} +
+
+ {user.name} + {user.email} +
+
+ + +
+ Тема + setTheme(i === 1 ? 'light' : 'dark')} + /> + Плотность + setDensity(i === 1 ? 'compact' : 'comfortable')} + /> + Контраст + setContrast(i === 1 ? 'high' : 'standard')} + /> +
+ + +
+
+
+
+ ) +} + +const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const + +function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) { + const Icon = item.icon + const active = pathname === item.to || pathname.startsWith(`${item.to}/`) + return ( + + } + > + + {item.label} + {item.problem ? ( + + ) : null} + + + + + + ) +} + +export function PortalSidebar() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const { pins, togglePin } = useUiStore() + const { navItems, navGroups, brand } = useChromeConfig() + const [closed, setClosed] = useState>({}) + const pinned = navItems.filter((i) => pins.includes(i.id)) + return ( + + + {brand} + + + {pinned.length > 0 ? ( + + Закреплённые + + + {pinned.map((item) => ( + togglePin(item.id)} /> + ))} + + + + ) : null} + {navGroups.map((group, gi) => ( +
+ {gi > 0 ? : null} + + setClosed((c) => ({ ...c, [group]: !c[group] }))} + /> + } + className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase" + > + {group} + + + + + {navItems.filter((i) => i.group === group).map((item) => ( + togglePin(item.id)} + /> + ))} + + + +
+ ))} +
+ + + +
+ ) +} + +/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */ +function MonitorPopover() { + const { monitor } = useChromeConfig() + const MONITOR_ITEMS = monitor.items + const MONITOR_METRICS = monitor.metrics + const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok') + const errors = bad.filter((m) => m.tone === 'er').length + const warnings = bad.length - errors + const ok = MONITOR_ITEMS.length - bad.length + return ( + + + } + > + + Внимание + + + {errors} + + {warnings} + + + {bad.length} + + + + +
+
+ Состояние системы + {monitor.updated} +
+ 0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"} +
+
+ {[ + { n: errors, l: 'сбой', c: 'text-destructive' }, + { n: warnings, l: 'предупреждения', c: 'text-warning' }, + { n: ok, l: 'в норме', c: 'text-success' }, + ].map((x) => ( + + {x.n} + {x.l} + + ))} +
+
    + {MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => ( +
  • + + + + {m.title} + {m.text} + + {m.age} + +
  • + ))} +
+
+ {MONITOR_METRICS.map((m) => ( +
+ {m.l} + {m.v} +
+ +
+
+ ))} +
+ + {monitor.footerLabel} + + +
+
+ ) +} + +function FreshnessPopover() { + const pathname = useRouterState({ select: (st) => st.location.pathname }) + const { fresh: freshFor } = useChromeConfig() + const fresh = freshFor(pathname) + return ( + + + } + > + + + + {fresh.badge} +

{fresh.text}

+
+
+ ) +} + +export function PortalHeader() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + const { setPaletteOpen } = useUiStore() + const { navItems, crumbFor } = useChromeConfig() + const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) + + return ( +
+ + + + + + {crumbFor ? crumbFor(pathname, current?.label) : (current?.label ?? 'Auth Portal')} + + + +
+ + + +
+
+ ) +} + +type PaletteEntry = { + id: string + group: string + label: string + hint: string + icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }> + dot?: 'ok' | 'er' | 'mu' + run: () => void +} + +const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const + +/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */ +export function CommandPalette() { + const { paletteOpen, setPaletteOpen } = useUiStore() + const { navItems, palette } = useChromeConfig() + const navigate = useNavigate() + const { theme, setTheme, resolvedTheme } = useTheme() + const [query, setQuery] = useState('') + const [active, setActive] = useState(0) + + const shown = useMemo(() => { + const q = query.trim().toLowerCase() + const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q)) + const go = (to: string) => () => void navigate({ to }) + const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark' + const list: PaletteEntry[] = [ + ...[ + ...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })), + { id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') }, + ] + .filter((e) => match(e.label)) + .map((e) => ({ ...e, group: 'Действия' })), + ...navItems.filter((i) => match(i.label, i.group)) + .slice(0, 7) + .map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })), + ...Object.values( + palette.objects + .filter((o) => match(o.label, o.hint)) + .reduce>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}), + ).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))), + ] + // Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»). + if (query.trim().split(/\s+/).filter(Boolean).length >= 3) { + return [ + { + id: 'ai', + group: 'Ассистент · запрос на естественном языке', + label: `Понял так: «${query.trim()}»`, + hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.', + run: () => void navigate({ to: palette.assistantTo }), + } as PaletteEntry, + ...list, + ] + } + return list + }, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette]) + + const groups = useMemo(() => { + const map = new Map>() + shown.forEach((e, index) => { + const list = map.get(e.group) ?? [] + list.push({ ...e, index }) + map.set(e.group, list) + }) + return [...map.entries()] + }, [shown]) + + function close() { + setPaletteOpen(false) + setQuery('') + setActive(0) + } + + return ( + (o ? setPaletteOpen(true) : close())}> + + Командная палитра + Разделы, пользователи, приложения и действия +
+ + { + setQuery(e.target.value) + setActive(0) + }} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActive((a) => Math.min(a + 1, shown.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActive((a) => Math.max(a - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + shown[active]?.run() + close() + } + }} + /> + Esc +
+
+ {groups.length === 0 ? ( +

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

+ ) : ( + groups.map(([group, list]) => ( +
+
{group}
+ {list.map((e) => { + const Icon = e.icon + return ( + + ) + })} +
+ )) + )} +
+
+ ↑↓ выбор + ↵ открыть + Esc закрыть + {palette.example} +
+
+
+ ) +} diff --git a/apps/web/src/components/portal/data-page.tsx b/apps/web/src/components/portal/data-page.tsx new file mode 100644 index 0000000..2e3a6b3 --- /dev/null +++ b/apps/web/src/components/portal/data-page.tsx @@ -0,0 +1,87 @@ +/** + * Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар + * «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3. + */ +import type { ReactNode } from 'react' +import { SearchIcon } from 'lucide-react' +import { Frame, FramePanel } from '@/components/reui/frame' +import { InputGroup, InputGroupAddon, InputGroupInput } from '@evofw/ui/components/input-group' +import { cn } from '@evofw/ui/lib/utils' + +export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) { + return ( + + {children} + + ) +} + +export function SegmentedControl({ + value, + onChange, + options, + label, +}: { + value: T + onChange: (value: T) => void + options: { value: T; label: string; count?: number }[] + label?: string +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} + +export function DataPageToolbar({ + leading, + segmented, + filterControl, + search, + onSearchChange, + searchPlaceholder = 'Поиск…', + countLabel, + actions, +}: { + leading?: ReactNode + segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string } + filterControl?: ReactNode + search?: string + onSearchChange?: (value: string) => void + searchPlaceholder?: string + countLabel?: string + actions?: ReactNode +}) { + return ( +
+ {leading} + {segmented ? : null} + {filterControl} + {onSearchChange ? ( + + + + + onSearchChange(e.target.value)} /> + + ) : null} + {countLabel ? {countLabel} : null} + {actions} +
+ ) +} diff --git a/apps/web/src/components/portal/format.ts b/apps/web/src/components/portal/format.ts new file mode 100644 index 0000000..d825508 --- /dev/null +++ b/apps/web/src/components/portal/format.ts @@ -0,0 +1,45 @@ +const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }) +const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }) +const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }) + +function parse(iso: string | null | undefined): Date | null { + if (!iso) return null + const d = new Date(iso) + return Number.isNaN(d.getTime()) ? null : d +} + +/** 12.09.2026 */ +export function fmtDate(iso: string | null | undefined, empty = '—') { + const d = parse(iso) + return d ? DATE.format(d) : empty +} + +/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */ +export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) { + const d = parse(iso) + if (!d) return empty + const dayMs = 24 * 60 * 60 * 1000 + const startOfToday = new Date(now) + startOfToday.setHours(0, 0, 0, 0) + const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0) + if (diff === 0) return `сегодня ${TIME.format(d)}` + if (diff === dayMs) return `вчера ${TIME.format(d)}` + return `${SHORT.format(d)} ${TIME.format(d)}` +} + +/** Время дня 14:02. */ +export function fmtTime(iso: string | null | undefined, empty = '—') { + const d = parse(iso) + return d ? TIME.format(d) : empty +} + +/** «5 дн назад», «3 ч назад», «только что». */ +export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) { + const d = parse(iso) + if (!d) return empty + const sec = Math.max(0, Math.round((now - d.getTime()) / 1000)) + if (sec < 60) return 'только что' + if (sec < 3600) return `${Math.floor(sec / 60)} мин назад` + if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад` + return `${Math.floor(sec / 86400)} дн назад` +} diff --git a/apps/web/src/components/portal/kpi-strip.tsx b/apps/web/src/components/portal/kpi-strip.tsx new file mode 100644 index 0000000..169f3f2 --- /dev/null +++ b/apps/web/src/components/portal/kpi-strip.tsx @@ -0,0 +1,74 @@ +import { Badge } from '@/components/reui/badge' +import { IconTile } from '@/components/reui/icon-tile' +import { Frame, FramePanel } from '@/components/reui/frame' +import { cn } from '@evofw/ui/lib/utils' +import type { KpiItem, KpiTone } from './kpi' + +const VALUE: Record = { + neutral: 'text-foreground', + ok: 'text-foreground', + in: 'text-foreground', + wn: 'text-warning', + er: 'text-destructive', +} + +/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */ +const COLS: Record = { + 1: 'grid-cols-1', + 2: 'grid-cols-2 max-[640px]:grid-cols-1', + 3: 'grid-cols-1 min-[1240px]:grid-cols-3', + 4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4', +} + +const TILE: Record = { + neutral: 'text-muted-foreground', + ok: 'text-success', + in: 'text-info', + wn: 'text-warning', + er: 'text-destructive', +} + +const GLYPH: Record = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' } + +const BADGE = { + neutral: 'outline', + ok: 'success-light', + in: 'info-light', + wn: 'warning-light', + er: 'destructive-light', +} as const + +/** + * Полоса показателей как на досках: один Frame, ячейки разделены линиями, + * в ячейке подпись, крупное значение и плашка-подсказка. + */ +export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) { + return ( + + +
+ {items.map((k) => { + const Icon = k.icon + return ( +
+ + + +
+
{k.label}
+
+ {k.value} + + {GLYPH[k.tone] ? {GLYPH[k.tone]} : null} + {k.hint} + +
+
+
+ ) + })} +
+
+ + ) +} diff --git a/apps/web/src/components/portal/kpi.tsx b/apps/web/src/components/portal/kpi.tsx new file mode 100644 index 0000000..d9b4abb --- /dev/null +++ b/apps/web/src/components/portal/kpi.tsx @@ -0,0 +1,24 @@ +import type { LucideIcon } from 'lucide-react' + +export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in' + +export type KpiItem = { + id: string + label: string + value: string + /** Плашка-подсказка рядом со значением. */ + hint: string + tone: KpiTone + /** Иконка показателя: плитка слева от подписи. */ + icon: LucideIcon +} + +/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */ +export function kpi( + id: string, + label: string, + value: string, + opts: { icon: LucideIcon; tone?: KpiTone; badge: string }, +): KpiItem { + return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon } +} diff --git a/apps/web/src/components/portal/tone.ts b/apps/web/src/components/portal/tone.ts new file mode 100644 index 0000000..b45a9d4 --- /dev/null +++ b/apps/web/src/components/portal/tone.ts @@ -0,0 +1,28 @@ +export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in' + +/** Пометка строки с накопленной правкой. */ +export type RowMark = 'new' | 'dirty' | 'deleted' + + +type BadgeVariant = + | 'success-light' + | 'warning-light' + | 'destructive-light' + | 'info-light' + | 'outline' + +export const TONE_BADGE: Record = { + ok: 'success-light', + wn: 'warning-light', + er: 'destructive-light', + in: 'info-light', + mu: 'outline', +} + +export const TONE_DOT: Record = { + ok: 'bg-success', + wn: 'bg-warning', + er: 'bg-destructive', + in: 'bg-success', + mu: 'bg-muted-foreground/50', +} diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx index 86054ce..cf9c5e1 100644 --- a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -223,39 +223,61 @@ interface KpiStatGridProps { 'aria-label'?: string } -function KpiStatCardItem({ item }: { item: KpiStatItem }) { - const onActivate = resolveActivate(item) - const panelClass = panelClassName(item) +/** Колонки полосы по ширине контейнера: 4 → 1/2/4, 3 → 1/3. */ +function stripCols(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 @2xl:grid-cols-3' + if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-4' + return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3' +} +/** Ячейка полосы показателей как на досках макета: плитка-иконка, подпись, значение и плашка рядом. */ +function KpiStripCell({ item }: { item: KpiStatItem }) { + const onActivate = resolveActivate(item) + const footer = resolveFooter(item) + const valueVariant = item.variant ?? 'default' + const clickable = Boolean(item.to || onActivate) + const body = ( +
+ {item.icon ? ( + + ) : null} +
+
{item.label}
+
+ + {item.value} + + {footer ? {footer} : null} +
+
+
+ ) + const cellClass = cn( + 'bg-card relative min-w-0', + clickable && 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2', + isSelected(item) && 'bg-muted/30', + ) if (item.to) { return ( - - - +
+ + {body} - +
) } - if (onActivate) { return ( - handleCardKeyDown(onActivate, e)} - > - - +
handleCardKeyDown(onActivate, e)}> + {body} +
) } - - return ( - - - - ) + return
{body}
} /** @@ -305,12 +327,14 @@ export function KpiStatGrid({ } return ( - -
- {list.map((item, index) => ( - - ))} -
+ + +
+ {list.map((item, index) => ( + + ))} +
+
) } diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index d38f9d2..dc3b7f1 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -12,7 +12,7 @@ import { } from '@tanstack/react-table' import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react' -import { CountedLineTabs } from '@/components/counted-line-tabs' +import { SegmentedControl } from '@/components/portal/data-page' 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' @@ -281,8 +281,10 @@ export function ResourcePage({ const tableLayout = useMemo( () => ({ - dense: true, + dense: false, headerSticky: true, + headerBackground: true, + rowBorder: true, width: 'fixed' as const, ...tableLayoutProp, ...(columnPinningProp || Object.keys(columnPinning).length > 0 @@ -464,81 +466,68 @@ export function ResourcePage({ ) : null} - {countedTabs.length > 0 ? ( - <> -
- -
- - - ) : null} - -
-
- -
- - - - - - - + diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 2f90648..90ee1fd 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,6 +7,7 @@ import { Toaster } from '@evofw/ui/components/sonner' import { TooltipProvider } from '@evofw/ui/components/tooltip' import { routeTree } from './routeTree.gen' import '@evofw/ui/globals.css' +import './portal-theme.css' const queryClient = new QueryClient({ defaultOptions: { diff --git a/apps/web/src/portal-theme.css b/apps/web/src/portal-theme.css new file mode 100644 index 0000000..1b6a106 --- /dev/null +++ b/apps/web/src/portal-theme.css @@ -0,0 +1,23 @@ +/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */ +html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) { + padding-block: 0.25rem; +} + +html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child { + gap: 0.75rem; +} + +/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */ +html [data-slot='sidebar-wrapper'] :focus-visible, +html #main-content :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + html #main-content *, + html [data-slot='sidebar-wrapper'] * { + animation: none !important; + transition: none !important; + } +} diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 341f838..0238961 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -1,10 +1,10 @@ import { createFileRoute, Outlet } from '@tanstack/react-router' -import { AppShell } from '@/components/layout/app-shell' +import { PortalShell } from '@/components/layout/portal-shell' export const Route = createFileRoute('/_auth')({ component: () => ( - + - + ), })