Files
MikrotikManager/components/command-palette.tsx
T
2026-05-03 11:16:07 +07:00

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">
Ничего не найдено по &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>
)
}