fix(ui): объединить навигацию и сделать поиск доступным

This commit is contained in:
Denozordec committed 2026-09-20 12:04:21 +07:00
1 parent 9b3196363c
commit fe9df167ec
6 files changed
+224 -353

No files matched your search

+3 -91
View File
@@ -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: <LayoutDashboardIcon /> },
{ title: "Трафик", url: "/traffic", icon: <ActivityIcon /> },
{ title: "Статистика", url: "/statistics", icon: <ChartColumnIcon /> },
{ title: "Карта сети", url: "/network-map", icon: <MapIcon /> },
{ title: "Мониторинг", url: "/uptime", icon: <HeartPulseIcon /> },
],
},
{
label: "Данные",
items: [
{ title: "Домены", url: "/domains", icon: <GlobeIcon /> },
{ title: "IP-диапазоны", url: "/ip-ranges", icon: <NetworkIcon /> },
{ title: "ASN", url: "/asns", icon: <LayersIcon /> },
{ title: "Communities", url: "/communities", icon: <TagIcon /> },
],
},
{
label: "Управление",
items: [
{ title: "Серверы", url: "/servers", icon: <ServerIcon /> },
{ title: "Пользователи", url: "/users", icon: <UsersIcon /> },
{ title: "Фильтры", url: "/filters", icon: <FilterIcon /> },
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
{ title: "WireGuard", url: "/wireguard", icon: <ShieldCheckIcon /> },
{ title: "IPsec / IKEv2", url: "/ipsec", icon: <LockIcon /> },
{ title: "GRE-туннели", url: "/gre", icon: <CableIcon /> },
{ title: "VXLAN", url: "/vxlan", icon: <NetworkIcon /> },
{ title: "Контейнеры", url: "/containers", icon: <BoxIcon /> },
{ title: "Сертификаты", url: "/certificates", icon: <BadgeCheckIcon /> },
{ title: "Бэкапы", url: "/backups", icon: <HardDriveIcon /> },
],
},
{
label: "Инструменты",
items: [
{ title: "Оптимизатор маршрутов", url: "/route-optimizer", icon: <RouteIcon /> },
{ title: "OSPF", url: "/ospf", icon: <GitForkIcon /> },
{ title: "BGP", url: "/bgp", icon: <GitMergeIcon /> },
{ title: "Диагностика", url: "/probes", icon: <ScanLineIcon /> },
{ title: "Терминал", url: "/terminal", icon: <TerminalIcon /> },
],
},
{
label: "Система",
items: [
{ title: "Оповещения", url: "/alerts", icon: <BellIcon /> },
{ title: "Сбор данных", url: "/data-collection", icon: <DatabaseIcon /> },
{ title: "Релизы", url: "/releases", icon: <BadgeCheckIcon /> },
{ title: "Настройки", url: "/settings", icon: <SettingsIcon /> },
],
},
]
type LiveSidebarCounts = SidebarCountsDto & {
greTunnels?: number
certificates?: number
@@ -190,12 +102,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
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,
})),
}))
+54 -258
View File
@@ -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: <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: "Мониторинг / Uptime", 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: "BGP 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","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","конфигурация"] },
]
// ─── 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<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const router = useRouter()
const pathname = usePathname()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const inputRef = useRef<HTMLInputElement>(null)
const returnFocusRef = useRef<HTMLElement | null>(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<string, CommandItem[]>()
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 (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[12vh]"
onClick={() => setOpen(false)}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* Dialog */}
<div
className="relative z-10 w-full max-w-[620px] mx-4 rounded-xl border border-white/[0.08] shadow-2xl overflow-hidden"
style={{ background: "#0c1526" }}
onClick={e => e.stopPropagation()}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
showCloseButton={false}
initialFocus={inputRef}
finalFocus={returnFocusRef}
className="flex h-[min(32rem,calc(100dvh-2rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
>
{/* Search input row */}
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-white/[0.07]">
<SearchIcon className="size-4 text-muted-foreground shrink-0" />
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={onInputKey}
placeholder="Поиск страниц и функций…"
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
{query && (
<button
onClick={() => setQuery("")}
className="text-muted-foreground hover:text-foreground transition-colors text-xs px-1.5 py-0.5 rounded border border-white/10 hover:border-white/20"
>
✕
</button>
)}
<kbd className="text-[10px] font-mono text-muted-foreground/50 border border-white/10 rounded px-1.5 py-0.5 hidden sm:block">
ESC
</kbd>
</div>
{/* Results */}
<div ref={listRef} className="max-h-[420px] overflow-y-auto py-2">
{results.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
Ничего не найдено по &ldquo;{query}&rdquo;
</div>
) : grouped.map(([group, items]) => (
<div key={group}>
{/* Group header */}
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
{group}
</div>
{items.map(item => {
const idx = runningIdx++
const isActive = idx === activeIdx
return (
<button
key={item.id}
data-idx={idx}
onClick={() => navigate(item)}
onMouseEnter={() => setActiveIdx(idx)}
className={cn(
"w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors",
isActive
? "bg-white/[0.07] text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className={cn(
"size-7 flex items-center justify-center rounded-md shrink-0 transition-colors [&_svg]:size-3.5",
isActive ? "bg-primary/20 text-primary" : "bg-white/[0.04] text-muted-foreground",
)}>
{item.icon}
</span>
<span className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">{item.title}</span>
{item.subtitle && (
<span className="text-xs text-muted-foreground ml-2">{item.subtitle}</span>
)}
</span>
{isActive && (
<span className="text-[10px] font-mono text-muted-foreground/50 border border-white/10 rounded px-1.5 py-0.5 shrink-0">
↵
</span>
)}
</button>
)
})}
</div>
))}
</div>
{/* Footer */}
<div className="border-t border-white/[0.07] px-4 py-2 flex items-center gap-4 text-[10px] text-muted-foreground/50">
<span><kbd className="font-mono border border-white/10 rounded px-1 py-0.5">↑↓</kbd> навигация</span>
<span><kbd className="font-mono border border-white/10 rounded px-1 py-0.5">↵</kbd> открыть</span>
<span><kbd className="font-mono border border-white/10 rounded px-1 py-0.5">Esc</kbd> закрыть</span>
<span className="ml-auto">
<kbd className="font-mono border border-white/10 rounded px-1 py-0.5">Ctrl</kbd>
{" + "}
<kbd className="font-mono border border-white/10 rounded px-1 py-0.5">K</kbd>
</span>
</div>
</div>
</div>
<DialogTitle className="sr-only">Поиск страниц</DialogTitle>
<DialogDescription className="sr-only">Найдите раздел по названию или ключевым словам. Стрелки выбирают страницу, Enter открывает её, Escape закрывает поиск.</DialogDescription>
<Command className="min-h-0 flex-1" label="Поиск страниц">
<CommandInput ref={inputRef} aria-label="Поиск страниц" placeholder="Название страницы или ключевое слово…" value={query} onValueChange={setQuery} />
<CommandList className="min-h-0 max-h-none flex-1" label="Найденные страницы">
<CommandEmpty><p role="status">Ничего не найдено. Попробуйте другое название.</p></CommandEmpty>
{navigationGroups.map(group => (
<CommandGroup key={group.label} heading={group.label}>
{group.items.map(item => {
const Icon = item.icon
return (
<CommandItem key={item.id} value={item.title} keywords={item.keywords} onSelect={() => {
setOpen(false)
router.push(item.url)
}}>
<Icon aria-hidden="true" />
<span>{item.title}</span>
{pathname === item.url && <span className="ml-auto text-xs text-muted-foreground">Текущая</span>}
</CommandItem>
)
})}
</CommandGroup>
))}
</CommandList>
</Command>
<p className="shrink-0 border-t px-3 py-2 text-xs text-muted-foreground">↑↓ выбрать · Enter открыть · Esc закрыть</p>
</DialogContent>
</Dialog>
)
}
+5 -3
View File
@@ -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 (
<>
<nav aria-label="Основная навигация">
{groups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
@@ -37,7 +39,7 @@ export function NavMain({ groups }: { groups: NavGroup[] }) {
<SidebarMenuButton
isActive={pathname === item.url}
tooltip={item.title}
render={<Link href={item.url} />}
render={<Link href={item.url} aria-current={pathname === item.url ? "page" : undefined} onNavigate={() => setOpenMobile(false)} />}
>
{item.icon}
<span>{item.title}</span>
@@ -52,6 +54,6 @@ export function NavMain({ groups }: { groups: NavGroup[] }) {
</SidebarMenu>
</SidebarGroup>
))}
</>
</nav>
)
}
+10 -1
View File
@@ -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`
+58
View File
@@ -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) }))
+94
View File
@@ -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" })
})
}