From fe9df167ec16def3b7d5355760c0eada053004cc Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 20 Sep 2026 12:04:21 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=20=D0=B8=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=BF=D0=BD=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/app-sidebar.tsx | 94 +--------- components/command-palette.tsx | 312 ++++++--------------------------- components/nav-main.tsx | 8 +- docs/ux-ui-progress.md | 11 +- lib/navigation.ts | 58 ++++++ tests/e2e/navigation.spec.ts | 94 ++++++++++ 6 files changed, 224 insertions(+), 353 deletions(-) create mode 100644 lib/navigation.ts create mode 100644 tests/e2e/navigation.spec.ts diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index ff7caa3d..6175b4a7 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -10,35 +10,7 @@ import { SidebarFooter, SidebarHeader, } from "@/components/ui/sidebar" -import { - LayoutDashboardIcon, - ActivityIcon, - ChartColumnIcon, - MapIcon, - HeartPulseIcon, - GlobeIcon, - NetworkIcon, - LayersIcon, - TagIcon, - ServerIcon, - FilterIcon, - ShieldIcon, - HardDriveIcon, - RouteIcon, - GitForkIcon, - GitMergeIcon, - TerminalIcon, - SettingsIcon, - DatabaseIcon, - CableIcon, - ScanLineIcon, - BellIcon, - ShieldCheckIcon, - BoxIcon, - BadgeCheckIcon, - UsersIcon, - LockIcon, -} from "lucide-react" +import { navigationGroups } from "@/lib/navigation" import { useDataSource } from "@/lib/data-source" import { useEvoBGP } from "@/lib/evobgp-context" import { requestJson } from "@/shared/api/http-client" @@ -48,66 +20,6 @@ import { type SidebarCountsDto, } from "@/lib/sidebar-badges" -type NavItemBase = { title: string; url: string; icon: React.ReactNode } - -const navStructure: { label: string; items: NavItemBase[] }[] = [ - { - label: "Обзор", - items: [ - { title: "Дашборд", url: "/dashboard", icon: }, - { title: "Трафик", url: "/traffic", icon: }, - { title: "Статистика", url: "/statistics", icon: }, - { title: "Карта сети", url: "/network-map", icon: }, - { title: "Мониторинг", url: "/uptime", icon: }, - ], - }, - { - label: "Данные", - items: [ - { title: "Домены", url: "/domains", icon: }, - { title: "IP-диапазоны", url: "/ip-ranges", icon: }, - { title: "ASN", url: "/asns", icon: }, - { title: "Communities", url: "/communities", icon: }, - ], - }, - { - label: "Управление", - items: [ - { title: "Серверы", url: "/servers", icon: }, - { title: "Пользователи", url: "/users", icon: }, - { title: "Фильтры", url: "/filters", icon: }, - { title: "Рекурсивные маршруты", url: "/recursive-routes", icon: }, - { title: "Firewall", url: "/firewall", icon: }, - { title: "WireGuard", url: "/wireguard", icon: }, - { title: "IPsec / IKEv2", url: "/ipsec", icon: }, - { title: "GRE-туннели", url: "/gre", icon: }, - { title: "VXLAN", url: "/vxlan", icon: }, - { title: "Контейнеры", url: "/containers", icon: }, - { title: "Сертификаты", url: "/certificates", icon: }, - { title: "Бэкапы", url: "/backups", icon: }, - ], - }, - { - label: "Инструменты", - items: [ - { title: "Оптимизатор маршрутов", url: "/route-optimizer", icon: }, - { title: "OSPF", url: "/ospf", icon: }, - { title: "BGP", url: "/bgp", icon: }, - { title: "Диагностика", url: "/probes", icon: }, - { title: "Терминал", url: "/terminal", icon: }, - ], - }, - { - label: "Система", - items: [ - { title: "Оповещения", url: "/alerts", icon: }, - { title: "Сбор данных", url: "/data-collection", icon: }, - { title: "Релизы", url: "/releases", icon: }, - { title: "Настройки", url: "/settings", icon: }, - ], - }, -] - type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number certificates?: number @@ -190,12 +102,12 @@ export function AppSidebar({ ...props }: React.ComponentProps) { return undefined } - return navStructure.map((g) => ({ + return navigationGroups.map((g) => ({ label: g.label, items: g.items.map((it) => ({ title: it.title, url: it.url, - icon: it.icon, + icon: React.createElement(it.icon, { "aria-hidden": true }), badge: mounted ? badgeFor(it.url) : undefined, })), })) diff --git a/components/command-palette.tsx b/components/command-palette.tsx index 2c8ee549..2af78991 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -1,278 +1,74 @@ "use client" -import { useEffect, useState, useRef, useMemo } from "react" -import { useRouter, usePathname } from "next/navigation" -import { cn } from "@/lib/utils" -import { - SearchIcon, LayoutDashboardIcon, ActivityIcon, ChartColumnIcon, MapIcon, HeartPulseIcon, - GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon, - ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon, - HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon, - TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon, LockIcon, -} from "lucide-react" - -// ─── Command item definition ────────────────────────────────────────────────── - -interface CommandItem { - id: string - title: string - subtitle?: string - group: string - url: string - icon: React.ReactNode - keywords?: string[] -} - -const ALL_ITEMS: CommandItem[] = [ - // Обзор - { id: "dashboard", title: "Дашборд", group: "Обзор", url: "/dashboard", icon: , keywords: ["главная","home","overview"] }, - { id: "traffic", title: "Трафик", group: "Обзор", url: "/traffic", icon: , keywords: ["bandwidth","traffic","клиенты","интерфейсы"] }, - { id: "statistics", title: "Статистика", group: "Обзор", url: "/statistics", icon: , keywords: ["stats","отчёт","куб","страны","asn","ipfix"] }, - { id: "network-map", title: "Карта сети", group: "Обзор", url: "/network-map", icon: , keywords: ["topology","топология","map"] }, - { id: "uptime", title: "Мониторинг / Uptime", group: "Обзор", url: "/uptime", icon: , keywords: ["ping","uptime","мониторинг","проверка"] }, - // Данные - { id: "domains", title: "Домены", group: "Данные", url: "/domains", icon: , keywords: ["domain","dns","сайты"] }, - { id: "ip-ranges", title: "IP-диапазоны", group: "Данные", url: "/ip-ranges", icon: , keywords: ["cidr","subnet","подсети","префиксы"] }, - { id: "asns", title: "ASN", group: "Данные", url: "/asns", icon: , keywords: ["autonomous system","автономная система"] }, - { id: "communities", title: "BGP Communities", group: "Данные", url: "/communities", icon: , keywords: ["community","bgp","теги"] }, - // Управление - { id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: , keywords: ["router","mikrotik","сервер","routeros"] }, - { id: "users", title: "Пользователи", group: "Управление", url: "/users", icon: , keywords: ["user","клиент","оператор","привязка","интерфейс"] }, - { id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: , keywords: ["filter","routing","маршрутизация"] }, - { id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: , keywords: ["recursive","route","static","маршруты"] }, - { id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: , keywords: ["rules","правила","брандмауэр","acl"] }, - { id: "wireguard", title: "WireGuard", group: "Управление", url: "/wireguard", icon: , keywords: ["vpn","tunnel","туннель","wg"] }, - { id: "ipsec", title: "IPsec / IKEv2", group: "Управление", url: "/ipsec", icon: , keywords: ["vpn","ikev2","ipsec","сертификат","p12","клиент"] }, - { id: "gre", title: "GRE-туннели", group: "Управление", url: "/gre", icon: , keywords: ["gre","ipsec","tunnel","туннель"] }, - { id: "vxlan", title: "VXLAN", group: "Управление", url: "/vxlan", icon: , keywords: ["overlay","vni","vtep","l2"] }, - { id: "containers", title: "Контейнеры", group: "Управление", url: "/containers", icon: , keywords: ["docker","container","образ","image"] }, - { id: "certificates", title: "Сертификаты", group: "Управление", url: "/certificates", icon: , keywords: ["ssl","tls","cert","pki","x509"] }, - { id: "backups", title: "Бэкапы", group: "Управление", url: "/backups", icon: , keywords: ["backup","backup","резервная копия"] }, - // Инструменты - { id: "route-optimizer", title: "Оптимизатор маршрутов", group: "Инструменты", url: "/route-optimizer", icon: , keywords: ["routing","wan","failover","переключение"] }, - { id: "ospf", title: "OSPF", group: "Инструменты", url: "/ospf", icon: , keywords: ["ospf","igp","neighbors","lsa"] }, - { id: "bgp", title: "BGP", group: "Инструменты", url: "/bgp", icon: , keywords: ["bgp","ebgp","ibgp","сессии","peers"] }, - { id: "probes", title: "Диагностика", group: "Инструменты", url: "/probes", icon: , keywords: ["probe","ping","traceroute","диагностика"] }, - { id: "terminal", title: "Терминал", group: "Инструменты", url: "/terminal", icon: , keywords: ["terminal","cli","ssh","консоль"] }, - // Система - { id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: , keywords: ["alert","notification","уведомление"] }, - { id: "data-collection", title: "Сбор данных", group: "Система", url: "/data-collection", icon: , keywords: ["scheduler","планировщик","коллектор","uptime","трафик","журнал","прогон"] }, - { id: "releases", title: "Релизы", group: "Система", url: "/releases", icon: , keywords: ["release","version","версия","changelog","релиз"] }, - { id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: , keywords: ["settings","config","конфигурация"] }, -] - -// ─── Scoring / filtering ────────────────────────────────────────────────────── - -function score(item: CommandItem, q: string): number { - const t = item.title.toLowerCase() - const kws = (item.keywords ?? []).join(" ").toLowerCase() - if (t === q) return 100 - if (t.startsWith(q)) return 90 - if (t.includes(q)) return 70 - if (kws.includes(q)) return 50 - return 0 -} - -function filterItems(q: string): CommandItem[] { - if (!q.trim()) return ALL_ITEMS - const lq = q.trim().toLowerCase() - return ALL_ITEMS - .map(item => ({ item, s: score(item, lq) })) - .filter(x => x.s > 0) - .sort((a, b) => b.s - a.s) - .map(x => x.item) -} - -// ─── Component ──────────────────────────────────────────────────────────────── +import { useEffect, useRef, useState } from "react" +import { usePathname, useRouter } from "next/navigation" +import { navigationGroups } from "@/lib/navigation" +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog" +/** Keyboard search from the App Shell 12 composition, using installed Base UI primitives. */ export function CommandPalette() { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState("") - const [activeIdx, setActiveIdx] = useState(0) - const inputRef = useRef(null) - const listRef = useRef(null) - const router = useRouter() - const pathname = usePathname() + const [open, setOpen] = useState(false) + const [query, setQuery] = useState("") + const inputRef = useRef(null) + const returnFocusRef = useRef(null) + const pathname = usePathname() + const router = useRouter() - const results = useMemo(() => filterItems(query), [query]) - - // ── Keyboard shortcut to open ───────────────────────────────────────────── useEffect(() => { - function onKey(e: KeyboardEvent) { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault() - setOpen(prev => { - if (!prev) { setQuery(""); setActiveIdx(0) } - return !prev - }) + function onKey(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && !event.isComposing) { + event.preventDefault() + if (!open) { + returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null + setQuery("") + } + setOpen(!open) } - if (e.key === "Escape") setOpen(false) } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) - }, []) - - // ── Focus input on open ─────────────────────────────────────────────────── - useEffect(() => { - if (open) setTimeout(() => inputRef.current?.focus(), 10) }, [open]) - // ── Close on route change ───────────────────────────────────────────────── useEffect(() => { queueMicrotask(() => setOpen(false)) }, [pathname]) - // ── Arrow key + Enter navigation ────────────────────────────────────────── - function onInputKey(e: React.KeyboardEvent) { - if (e.key === "ArrowDown") { - e.preventDefault() - setActiveIdx(i => Math.min(i + 1, results.length - 1)) - } else if (e.key === "ArrowUp") { - e.preventDefault() - setActiveIdx(i => Math.max(i - 1, 0)) - } else if (e.key === "Enter") { - e.preventDefault() - const item = results[activeIdx] - if (item) navigate(item) - } - } - - function navigate(item: CommandItem) { - router.push(item.url) - setOpen(false) - } - - // ── Scroll active item into view ────────────────────────────────────────── - useEffect(() => { - const el = listRef.current?.querySelector(`[data-idx="${activeIdx}"]`) as HTMLElement | null - el?.scrollIntoView({ block: "nearest" }) - }, [activeIdx]) - - // ── Reset active idx when query changes ─────────────────────────────────── - useEffect(() => { - queueMicrotask(() => setActiveIdx(0)) - }, [query]) - - // ── Grouped results for display ─────────────────────────────────────────── - const grouped = useMemo(() => { - const map = new Map() - for (const item of results) { - if (!map.has(item.group)) map.set(item.group, []) - map.get(item.group)!.push(item) - } - return Array.from(map.entries()) - }, [results]) - - // ── Running index across groups ─────────────────────────────────────────── - let runningIdx = 0 - - if (!open) return null - return ( -
setOpen(false)} - > - {/* Backdrop */} -
- - {/* Dialog */} -
e.stopPropagation()} + + - {/* Search input row */} -
- - setQuery(e.target.value)} - onKeyDown={onInputKey} - placeholder="Поиск страниц и функций…" - className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" - /> - {query && ( - - )} - - ESC - -
- - {/* Results */} -
- {results.length === 0 ? ( -
- Ничего не найдено по “{query}” -
- ) : grouped.map(([group, items]) => ( -
- {/* Group header */} -
- {group} -
- - {items.map(item => { - const idx = runningIdx++ - const isActive = idx === activeIdx - return ( - - ) - })} -
- ))} -
- - {/* Footer */} -
- ↑↓ навигация - ↵ открыть - Esc закрыть - - Ctrl - {" + "} - K - -
-
-
+ Поиск страниц + Найдите раздел по названию или ключевым словам. Стрелки выбирают страницу, Enter открывает её, Escape закрывает поиск. + + + +

Ничего не найдено. Попробуйте другое название.

+ {navigationGroups.map(group => ( + + {group.items.map(item => { + const Icon = item.icon + return ( + { + setOpen(false) + router.push(item.url) + }}> + + ) + })} + + ))} +
+
+

↑↓ выбрать · Enter открыть · Esc закрыть

+ + ) } diff --git a/components/nav-main.tsx b/components/nav-main.tsx index cc2ec763..d8f3c769 100644 --- a/components/nav-main.tsx +++ b/components/nav-main.tsx @@ -8,6 +8,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + useSidebar, } from "@/components/ui/sidebar" export interface NavItem { @@ -25,9 +26,10 @@ export interface NavGroup { export function NavMain({ groups }: { groups: NavGroup[] }) { const pathname = usePathname() + const { setOpenMobile } = useSidebar() return ( - <> + ) } diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f9ecda17..de3757a1 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -389,6 +389,16 @@ Internet Path использует успешный серверный сним Предыдущая попытка запуска была остановлена ошибкой автоматической проверки разрешений из-за исчерпанной квоты; после сброса проверка запуска прошла. На старте этого продолжения использовано 2% пятичасовой и 16% недельной квоты. Этап 6 остаётся открытым. Следующий блок — единый список Sidebar/CommandPalette и стандартное управление фокусом поиска, затем дашборд и ссылки очереди. Локальный коммит без push; файлы настройки другой работы не включать. +## Этап 6 — блок 2: навигация и поиск (2026-09-20) + +`lib/navigation.ts` — общий каталог 30 страниц, групп, иконок и поисковых синонимов. AppSidebar и CommandPalette используют его вместо двух независимых списков. Названия «Мониторинг» и «Communities» согласованы; поисковые синонимы uptime/BGP сохранены. NavMain получил именованный nav и aria-current; мобильная шторка закрывается при клиентском переходе, включая выбор текущей страницы. + +CommandPalette использует установленные Command/cmdk и Base UI Dialog вместо самописного затемнённого окна и ручной навигации. Фокус при открытии переходит в поиск, Tab удерживается внутри, Escape возвращает фокус; ввод сбрасывается при новом открытии. Поддержаны поиск по синонимам, стрелки/Enter, объявление пустой выдачи, светлая/тёмная тема. Высота списка ограничена высотой диалога и viewport. Новые зависимости и примитивы не устанавливались; данные/API и схема прав не менялись. + +Профильные проверки: 4 сценария navigation passed (один проверяет каталог по реальным файлам страниц), 4 сценария шапки passed. Проверены переход на серверы и возврат браузером, пустой поиск, фокус, мобильный выбор текущей страницы, размеры 390×600 в двух темах; скриншоты просмотрены. TypeScript успешен; ESLint новых navigation/CommandPalette/NavMain/test успешен. В AppSidebar сохранилась исходная ошибка react-hooks/set-state-in-effect на сбросе liveCounts при смене режима; её наличие подтверждено чтением HEAD до изменений, логика счётчиков не менялась. Не заявлять чистый lint всего проекта. + +Блок готов для локального коммита без push. Этап 6 продолжается: иерархия дашборда, затем переходы из очереди к конкретным объектам. + ## Карта миграции (исходная инвентаризация этапа 0) Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация. @@ -453,4 +463,3 @@ Internet Path использует успешный серверный сним - `app/(main)/statistics/page.tsx` - `components/backups/backups-history.tsx` - `components/data-page-toolbar.tsx` - diff --git a/lib/navigation.ts b/lib/navigation.ts new file mode 100644 index 00000000..37bb7d8b --- /dev/null +++ b/lib/navigation.ts @@ -0,0 +1,58 @@ +import { + type LucideIcon, LayoutDashboardIcon, ActivityIcon, ChartColumnIcon, MapIcon, HeartPulseIcon, + GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon, + ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon, + HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon, + TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon, LockIcon, +} from "lucide-react" + +export interface NavigationItem { + id: string + title: string + group: string + url: string + icon: LucideIcon + keywords: string[] +} + +/** Shared page catalog for sidebar and keyboard search. */ +export const navigationItems: NavigationItem[] = [ + // Обзор + { id: "dashboard", title: "Дашборд", group: "Обзор", url: "/dashboard", icon: LayoutDashboardIcon, keywords: ["главная","home","overview"] }, + { id: "traffic", title: "Трафик", group: "Обзор", url: "/traffic", icon: ActivityIcon, keywords: ["bandwidth","traffic","клиенты","интерфейсы"] }, + { id: "statistics", title: "Статистика", group: "Обзор", url: "/statistics", icon: ChartColumnIcon, keywords: ["stats","отчёт","куб","страны","asn","ipfix"] }, + { id: "network-map", title: "Карта сети", group: "Обзор", url: "/network-map", icon: MapIcon, keywords: ["topology","топология","map"] }, + { id: "uptime", title: "Мониторинг", group: "Обзор", url: "/uptime", icon: HeartPulseIcon, keywords: ["ping","uptime","мониторинг","проверка"] }, + // Данные + { id: "domains", title: "Домены", group: "Данные", url: "/domains", icon: GlobeIcon, keywords: ["domain","dns","сайты"] }, + { id: "ip-ranges", title: "IP-диапазоны", group: "Данные", url: "/ip-ranges", icon: NetworkIcon, keywords: ["cidr","subnet","подсети","префиксы"] }, + { id: "asns", title: "ASN", group: "Данные", url: "/asns", icon: LayersIcon, keywords: ["autonomous system","автономная система"] }, + { id: "communities", title: "Communities", group: "Данные", url: "/communities", icon: TagIcon, keywords: ["community","bgp","теги"] }, + // Управление + { id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: ServerIcon, keywords: ["router","mikrotik","сервер","routeros"] }, + { id: "users", title: "Пользователи", group: "Управление", url: "/users", icon: UsersIcon, keywords: ["user","клиент","оператор","привязка","интерфейс"] }, + { id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: FilterIcon, keywords: ["filter","routing","маршрутизация"] }, + { id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: RouteIcon, keywords: ["recursive","route","static","маршруты"] }, + { id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: ShieldIcon, keywords: ["rules","правила","брандмауэр","acl"] }, + { id: "wireguard", title: "WireGuard", group: "Управление", url: "/wireguard", icon: ShieldCheckIcon, keywords: ["vpn","tunnel","туннель","wg"] }, + { id: "ipsec", title: "IPsec / IKEv2", group: "Управление", url: "/ipsec", icon: LockIcon, keywords: ["vpn","ikev2","ipsec","сертификат","p12","клиент"] }, + { id: "gre", title: "GRE-туннели", group: "Управление", url: "/gre", icon: CableIcon, keywords: ["gre","ipsec","tunnel","туннель"] }, + { id: "vxlan", title: "VXLAN", group: "Управление", url: "/vxlan", icon: NetworkIcon, keywords: ["overlay","vni","vtep","l2"] }, + { id: "containers", title: "Контейнеры", group: "Управление", url: "/containers", icon: BoxIcon, keywords: ["docker","container","образ","image"] }, + { id: "certificates", title: "Сертификаты", group: "Управление", url: "/certificates", icon: BadgeCheckIcon, keywords: ["ssl","tls","cert","pki","x509"] }, + { id: "backups", title: "Бэкапы", group: "Управление", url: "/backups", icon: HardDriveIcon, keywords: ["backup","резервная копия"] }, + // Инструменты + { id: "route-optimizer", title: "Оптимизатор маршрутов", group: "Инструменты", url: "/route-optimizer", icon: RouteIcon, keywords: ["routing","wan","failover","переключение"] }, + { id: "ospf", title: "OSPF", group: "Инструменты", url: "/ospf", icon: GitForkIcon, keywords: ["ospf","igp","neighbors","lsa"] }, + { id: "bgp", title: "BGP", group: "Инструменты", url: "/bgp", icon: GitMergeIcon, keywords: ["bgp","ebgp","ibgp","сессии","peers"] }, + { id: "probes", title: "Диагностика", group: "Инструменты", url: "/probes", icon: ScanLineIcon, keywords: ["probe","ping","traceroute","диагностика"] }, + { id: "terminal", title: "Терминал", group: "Инструменты", url: "/terminal", icon: TerminalIcon, keywords: ["terminal","cli","ssh","консоль"] }, + // Система + { id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: BellIcon, keywords: ["alert","notification","уведомление"] }, + { id: "data-collection", title: "Сбор данных", group: "Система", url: "/data-collection", icon: DatabaseIcon, keywords: ["scheduler","планировщик","коллектор","uptime","трафик","журнал","прогон"] }, + { id: "releases", title: "Релизы", group: "Система", url: "/releases", icon: BadgeCheckIcon, keywords: ["release","version","версия","changelog","релиз"] }, + { id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: SettingsIcon, keywords: ["settings","config","конфигурация"] }, +] + +export const navigationGroups = [...new Set(navigationItems.map(item => item.group))] + .map(label => ({ label, items: navigationItems.filter(item => item.group === label) })) diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts new file mode 100644 index 00000000..3414b3e9 --- /dev/null +++ b/tests/e2e/navigation.spec.ts @@ -0,0 +1,94 @@ +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { test, expect, type Page } from "@playwright/test" +import { navigationItems } from "../../lib/navigation" + +async function setup(page: Page) { + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", route => { + const path = new URL(route.request().url()).pathname + return route.fulfill({ json: path === "/api/servers" ? [] + : path === "/api/recursive-routes" ? { routes: [] } + : path === "/api/recursive-routes/gateways" ? { gateways: [] } + : path === "/api/auth/config" ? { required: false } + : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} }) + }) + await page.goto("/recursive-routes") + await expect(page.getByRole("navigation", { name: "Путь страницы" })).toBeVisible() +} + +test("каталог навигации покрывает существующие разделы без повторов и мёртвых ссылок", () => { + const root = join(process.cwd(), "app/(main)") + const pages = readdirSync(root).filter(name => existsSync(join(root, name, "page.tsx"))).map(name => `/${name}`) + expect(navigationItems.map(item => item.url).sort()).toEqual(pages.sort()) + expect(new Set(navigationItems.map(item => item.id)).size).toBe(navigationItems.length) + expect(new Set(navigationItems.map(item => item.title)).size).toBe(navigationItems.length) +}) + +test("поиск страниц: одинаковые названия, синонимы, клавиатура и переход", async ({ page }) => { + await setup(page) + const sidebar = page.getByRole("navigation", { name: "Основная навигация" }) + for (const item of navigationItems) { + await expect(sidebar.locator(`a[href="${item.url}"]`)).toContainText(item.title) + } + const opener = page.getByRole("button", { name: "Приложения", exact: true }) + await opener.focus() + await page.keyboard.press("Control+k") + const dialog = page.getByRole("dialog", { name: "Поиск страниц", exact: true }) + const input = dialog.getByRole("combobox", { name: "Поиск страниц", exact: true }) + await expect(input).toBeFocused() + await expect(dialog.getByRole("option")).toHaveCount(navigationItems.length) + await input.press("ArrowDown") + await expect(dialog.getByRole("option", { name: "Трафик", exact: true })).toHaveAttribute("data-selected", "true") + await input.press("ArrowUp") + await expect(dialog.getByRole("option", { name: "Дашборд", exact: true })).toHaveAttribute("data-selected", "true") + await input.fill("uptime") + await expect(dialog.getByRole("option", { name: "Мониторинг", exact: true })).toBeVisible() + await input.fill("Несуществующийраздел999") + await expect(dialog.getByRole("status")).toContainText("Ничего не найдено") + await input.press("ArrowDown") + await input.press("Enter") + await expect(dialog).toBeVisible() + await page.keyboard.press("Tab") + await expect.poll(() => dialog.evaluate(element => element.contains(document.activeElement))).toBe(true) + await page.keyboard.press("Escape") + await expect(dialog).toBeHidden() + await expect(opener).toBeFocused() + await page.keyboard.press("Control+k") + await expect(input).toHaveValue("") + await input.fill("mikrotik") + await expect(dialog.getByRole("option", { name: "Серверы", exact: true })).toBeVisible() + await input.press("Enter") + await expect(page).toHaveURL(/\/servers$/) + await expect(dialog).toBeHidden() + await expect(sidebar.locator('a[href="/servers"]')).toHaveAttribute("aria-current", "page") + await page.goBack() + await expect(sidebar.locator('a[href="/recursive-routes"]')).toHaveAttribute("aria-current", "page") +}) + +for (const theme of ["dark", "light"]) { + test(`мобильное меню и поиск: тема ${theme}, низкий экран`, async ({ page }) => { + await page.addInitScript(value => localStorage.setItem("rl-theme", value), theme) + await page.setViewportSize({ width: 390, height: 600 }) + await setup(page) + const menu = page.getByRole("button", { name: "Боковое меню", exact: true }) + await menu.click() + const sidebar = page.getByRole("navigation", { name: "Основная навигация" }) + await sidebar.locator('a[href="/servers"]').click() + await expect(page).toHaveURL(/\/servers$/) + await expect(page.getByRole("dialog")).toBeHidden() + await menu.click() + await expect(sidebar.locator('a[href="/servers"]')).toHaveAttribute("aria-current", "page") + await sidebar.locator('a[href="/servers"]').click() + await expect(page.getByRole("dialog")).toBeHidden() + await page.keyboard.press("Control+k") + const search = page.getByRole("dialog", { name: "Поиск страниц", exact: true }) + await expect(search).toBeVisible() + const bounds = await search.boundingBox() + expect(bounds!.x).toBeGreaterThanOrEqual(0) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390) + expect(bounds!.y).toBeGreaterThanOrEqual(0) + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(600) + await page.screenshot({ path: `test-results/navigation-mobile-${theme}.png`, animations: "disabled" }) + }) +}