Ничего не найдено. Попробуйте другое название.
↑↓ выбрать · 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" }) + }) +}