feat(web): командная палитра ⌘K — поиск агентов/списков/наборов и действия (ReUI c-command-7 + cmdk)
This commit is contained in:
@@ -1,36 +1,55 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ListIcon,
|
||||
ListPlusIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
ShieldIcon,
|
||||
ShieldPlusIcon,
|
||||
} from 'lucide-react'
|
||||
import { NAV_ITEMS } from '@/lib/nav'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evofw/ui/components/dialog'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
agentsQueryOptions,
|
||||
listsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
} from '@/queries'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@evofw/ui/components/item'
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@evofw/ui/components/command'
|
||||
|
||||
/**
|
||||
* Command-K palette — навигация, поиск ресурсов и действия.
|
||||
* DNA: https://reui.io/components/command/c-command-7 (hotkey-only по контракту).
|
||||
*/
|
||||
|
||||
/** Command-K search — hotkey dialog (no header chrome trigger). */
|
||||
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
void hotkeyOnly
|
||||
return <SearchMenuDialog />
|
||||
}
|
||||
|
||||
function SearchMenuDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
const can = useCan()
|
||||
|
||||
const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: open })
|
||||
const listsQ = useQuery({ ...listsQueryOptions(), enabled: open })
|
||||
const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: open })
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
setOpen((prev) => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,99 +57,164 @@ export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('')
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return NAV_ITEMS
|
||||
return NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.keywords.some((k) => k.includes(q)),
|
||||
)
|
||||
}, [query])
|
||||
|
||||
function goTo(to: string) {
|
||||
const go = (run: () => void) => {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
run()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hotkeyOnly ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Поиск"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
const navItems = NAV_ITEMS.filter(
|
||||
(item) => !item.permission || can(item.permission),
|
||||
)
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Поиск</DialogTitle>
|
||||
<DialogDescription>
|
||||
Переход к разделам приложения
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
||||
<div className="relative flex items-center gap-3 border-b px-4 py-2">
|
||||
<SearchIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none size-4 opacity-60 select-none"
|
||||
/>
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Перейти к разделу…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) {
|
||||
e.preventDefault()
|
||||
goTo(filtered[0].to)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ItemGroup className="max-h-72 overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<Item
|
||||
key={item.to}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="cursor-pointer border-0"
|
||||
render={<Link to={item.to} onClick={() => setOpen(false)} />}
|
||||
const agents = (agentsQ.data?.items ?? []).slice(0, 50)
|
||||
const lists = (listsQ.data?.items ?? []).slice(0, 50)
|
||||
const sets = (setsQ.data?.items ?? []).slice(0, 50)
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Палитра команд"
|
||||
description="Поиск разделов, агентов, списков и действий"
|
||||
className="max-w-xl"
|
||||
>
|
||||
<CommandInput placeholder="Поиск разделов, агентов, списков…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Ничего не найдено.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Действия">
|
||||
{can('fw:agents:write') ? (
|
||||
<CommandItem
|
||||
value="Добавить агента agent invite"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/agents',
|
||||
search: { view: 'cards', add: true },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
Добавить агента
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{can('fw:lists:write') ? (
|
||||
<CommandItem
|
||||
value="Создать список blocklist"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({ to: '/lists', search: { create: true } }),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListPlusIcon aria-hidden />
|
||||
Создать список
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{can('fw:policies:write') ? (
|
||||
<CommandItem
|
||||
value="Создать набор правил policy"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({ to: '/rules', search: { create: true } }),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldPlusIcon aria-hidden />
|
||||
Создать набор правил
|
||||
</CommandItem>
|
||||
) : null}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Навигация">
|
||||
{navItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.to}
|
||||
value={`${item.label} ${item.keywords.join(' ')}`}
|
||||
onSelect={() => go(() => void navigate({ to: item.to }))}
|
||||
>
|
||||
<item.icon aria-hidden />
|
||||
{item.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{agents.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Агенты">
|
||||
{agents.map((a) => (
|
||||
<CommandItem
|
||||
key={a.id}
|
||||
value={`${a.name} ${a.hostname ?? ''} ${a.last_seen_ip ?? ''}`}
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/agents',
|
||||
search: { view: 'cards', agent: a.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<item.icon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{item.label}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))
|
||||
)}
|
||||
</ItemGroup>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<ServerIcon aria-hidden />
|
||||
<span className="truncate">{a.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{a.hostname ?? a.status}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{lists.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Списки">
|
||||
{lists.map((l) => (
|
||||
<CommandItem
|
||||
key={l.id}
|
||||
value={l.name}
|
||||
onSelect={() =>
|
||||
go(() => void navigate({ to: '/lists/$id', params: { id: l.id } }))
|
||||
}
|
||||
>
|
||||
<ListIcon aria-hidden />
|
||||
<span className="truncate">{l.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{sets.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Наборы правил">
|
||||
{sets.map((s) => (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
value={s.name}
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/rules/$setId',
|
||||
params: { setId: s.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldIcon aria-hidden />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user