274 lines
15 KiB
TypeScript
274 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useRef, useMemo } from "react"
|
|
import { useRouter, usePathname } from "next/navigation"
|
|
import { cn } from "@/lib/utils"
|
|
import {
|
|
SearchIcon, LayoutDashboardIcon, ActivityIcon, MapIcon, HeartPulseIcon,
|
|
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
|
|
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
|
|
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
|
|
TerminalIcon, BellIcon, SettingsIcon,
|
|
} 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: "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: "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: "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: "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 ────────────────────────────────────────────────────────────────
|
|
|
|
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 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
|
|
})
|
|
}
|
|
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()}
|
|
>
|
|
{/* 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>
|
|
)
|
|
}
|