feat(web): командная палитра ⌘K — поиск агентов/списков/наборов и действия (ReUI c-command-7 + cmdk)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user