fix(ui): объединить навигацию и сделать поиск доступным
This commit is contained in:
1 parent
9b3196363c
commit
fe9df167ec
6 files changed
+224
-353
No files matched your search
@@ -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
@@ -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">
|
||||
Ничего не найдено по “{query}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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) }))
|
||||
@@ -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" })
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user