feat(web): командная палитра ⌘K — поиск агентов/списков/наборов и действия (ReUI c-command-7 + cmdk)

This commit is contained in:
Denozordec
2026-09-25 01:35:29 +07:00
parent 28f1f35f16
commit 3d4edbad8b
7 changed files with 448 additions and 125 deletions
+2
View File
@@ -26,6 +26,8 @@
"@tanstack/react-virtual": "^3.14.7",
"@tanstack/router-plugin": "^1.120.0",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"cn": "^0.4.0",
"date-fns": "^4.4.0",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
+194 -110
View File
@@ -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>
)
}
+20 -6
View File
@@ -68,6 +68,8 @@ import type { Agent } from '@evofw/shared'
type AgentsSearch = {
agent?: string
view: 'cards' | 'table'
/** Открыть sheet добавления агента (из ⌘K-палитры). */
add?: boolean
}
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
@@ -76,7 +78,8 @@ function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
typeof search.agent === 'string' && search.agent.length > 0
? search.agent
: undefined
return { view, agent }
const add = search.add === true
return { view, agent, add }
}
export const Route = createFileRoute('/_auth/agents/')({
@@ -101,13 +104,14 @@ function formatNames(names: string[]): string {
function AgentsPage() {
const navigate = useNavigate({ from: Route.fullPath })
const { agent: detailAgentId, view } = Route.useSearch()
const { agent: detailAgentId, view, add: addParam } = Route.useSearch()
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
const settingsQ = useQuery(settingsQueryOptions())
const { copyToClipboard } = useCopyToClipboard()
const canWrite = useCan()('fw:agents:write')
const [createOpen, setCreateOpen] = useState(false)
const [createOpenState, setCreateOpenState] = useState(false)
const createOpen = createOpenState || addParam === true
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
const [approveAllOpen, setApproveAllOpen] = useState(false)
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
@@ -130,6 +134,10 @@ function AgentsPage() {
next.agent !== undefined
? next.agent || undefined
: base.agent,
add:
next.add !== undefined
? next.add || undefined
: base.add,
} satisfies AgentsSearch
},
replace: true,
@@ -463,7 +471,7 @@ function AgentsPage() {
icon: <Plus aria-hidden />,
iconClassName: 'text-primary [&_svg]:text-current',
badgeLabel: 'Открыть',
onSelect: () => setCreateOpen(true),
onSelect: () => setCreateOpenState(true),
},
{
id: 'pending',
@@ -568,7 +576,7 @@ function AgentsPage() {
)
const addButton = canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Button size="sm" onClick={() => setCreateOpenState(true)}>
<Plus data-icon="inline-start" />
Добавить агента
</Button>
@@ -726,7 +734,13 @@ function AgentsPage() {
</QueryState>
)}
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
<AddAgentSheet
open={createOpen}
onOpenChange={(open) => {
setCreateOpenState(open)
if (!open && addParam) setSearch({ add: false })
}}
/>
<AgentDetailSheet
agentId={detailAgentId ?? null}
+18 -4
View File
@@ -47,6 +47,10 @@ import {
import { guessListSourceFromInput } from '@evofw/shared'
export const Route = createFileRoute('/_auth/lists/')({
validateSearch: (search: Record<string, unknown>) => ({
/** Открыть sheet создания (из ⌘K-палитры). */
create: search.create === true || undefined,
}),
component: ListsPage,
})
@@ -69,7 +73,9 @@ function ListsPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const canWrite = useCan()('fw:lists:write')
const [createOpen, setCreateOpen] = useState(false)
const createParam = Route.useSearch().create === true
const [createOpenState, setCreateOpenState] = useState(false)
const createOpen = createOpenState || createParam
const [name, setName] = useState('')
const [source, setSource] = useState<CreateSource>('static')
const [extra, setExtra] = useState('')
@@ -202,7 +208,7 @@ function ListsPage() {
Обновить
</Button>
{canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Button size="sm" onClick={() => setCreateOpenState(true)}>
<Plus data-icon="inline-start" />
Создать
</Button>
@@ -241,7 +247,7 @@ function ListsPage() {
title: 'Нет списков',
description: 'Создайте первый список для политики firewall.',
action: (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Button size="sm" onClick={() => setCreateOpenState(true)}>
<Plus data-icon="inline-start" />
Создать
</Button>
@@ -262,7 +268,15 @@ function ListsPage() {
disabled={removeList.isPending}
/>
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
<Sheet
open={createOpen}
onOpenChange={(open) => {
setCreateOpenState(open)
if (!open && createParam) {
void navigate({ to: '/lists', search: { create: undefined }, replace: true })
}
}}
>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Новый список</SheetTitle>
+19 -5
View File
@@ -29,6 +29,10 @@ import {
import type { PolicySet } from '@evofw/shared'
export const Route = createFileRoute('/_auth/rules/')({
validateSearch: (search: Record<string, unknown>) => ({
/** Открыть sheet создания (из ⌘K-палитры). */
create: search.create === true || undefined,
}),
component: PolicySetsPage,
})
@@ -42,7 +46,9 @@ function PolicySetsPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const setsQ = useQuery(policySetsQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false)
const createParam = Route.useSearch().create === true
const [sheetOpenState, setSheetOpenState] = useState(false)
const sheetOpen = sheetOpenState || createParam
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [filters, setFilters] = useState<Filter[]>([])
@@ -63,7 +69,7 @@ function PolicySetsPage() {
toast.success('Набор создан')
setName('')
setDescription('')
setSheetOpen(false)
setSheetOpenState(false)
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
},
@@ -203,7 +209,7 @@ function PolicySetsPage() {
const canCreate = useCan()('fw:policies:write')
const addButton = canCreate ? (
<Button size="sm" onClick={() => setSheetOpen(true)}>
<Button size="sm" onClick={() => setSheetOpenState(true)}>
<Plus data-icon="inline-start" />
Новый набор
</Button>
@@ -260,7 +266,15 @@ function PolicySetsPage() {
disabled={remove.isPending}
/>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<Sheet
open={sheetOpen}
onOpenChange={(open) => {
setSheetOpenState(open)
if (!open && createParam) {
void navigate({ to: '/rules', search: { create: undefined }, replace: true })
}
}}
>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Новый набор</SheetTitle>
@@ -289,7 +303,7 @@ function PolicySetsPage() {
</Field>
</div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setSheetOpen(false)}>
<Button variant="outline" onClick={() => setSheetOpenState(false)}>
Отмена
</Button>
<Button
+1
View File
@@ -23,6 +23,7 @@
"@radix-ui/react-tabs": "^1.1.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
+194
View File
@@ -0,0 +1,194 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@evofw/ui/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@evofw/ui/components/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@evofw/ui/components/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}