diff --git a/apps/web/package.json b/apps/web/package.json index acd0d2a..74a09b1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/layout/search-menu.tsx b/apps/web/src/components/layout/search-menu.tsx index 42fd7ac..f3ef27d 100644 --- a/apps/web/src/components/layout/search-menu.tsx +++ b/apps/web/src/components/layout/search-menu.tsx @@ -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 +} + +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 : ( - - )} + const navItems = NAV_ITEMS.filter( + (item) => !item.permission || can(item.permission), + ) - - - Поиск - - Переход к разделам приложения - - - -
-
- - {filtered.length === 0 ? ( -

- Ничего не найдено -

- ) : ( - filtered.map((item) => ( - 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 ( + + + + Ничего не найдено. + + + {can('fw:agents:write') ? ( + + go(() => + void navigate({ + to: '/agents', + search: { view: 'cards', add: true }, + }), + ) + } + > + + Добавить агента + + ) : null} + {can('fw:lists:write') ? ( + + go(() => + void navigate({ to: '/lists', search: { create: true } }), + ) + } + > + + Создать список + + ) : null} + {can('fw:policies:write') ? ( + + go(() => + void navigate({ to: '/rules', search: { create: true } }), + ) + } + > + + Создать набор правил + + ) : null} + + + + + + {navItems.map((item) => ( + go(() => void navigate({ to: item.to }))} + > + + {item.label} + + ))} + + + {agents.length > 0 ? ( + <> + + + {agents.map((a) => ( + + go(() => + void navigate({ + to: '/agents', + search: { view: 'cards', agent: a.id }, + }), + ) + } > - - - - {item.label} - - - )) - )} -
-
-
- + + {a.name} + + {a.hostname ?? a.status} + + + ))} + + + ) : null} + + {lists.length > 0 ? ( + <> + + + {lists.map((l) => ( + + go(() => void navigate({ to: '/lists/$id', params: { id: l.id } })) + } + > + + {l.name} + + ))} + + + ) : null} + + {sets.length > 0 ? ( + <> + + + {sets.map((s) => ( + + go(() => + void navigate({ + to: '/rules/$setId', + params: { setId: s.id }, + }), + ) + } + > + + {s.name} + + ))} + + + ) : null} + + ) } diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index 52b0438..c69053b 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -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): AgentsSearch { @@ -76,7 +78,8 @@ function parseAgentsSearch(search: Record): 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(null) const [approveAllOpen, setApproveAllOpen] = useState(false) const [bulkDeleteIds, setBulkDeleteIds] = useState(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: , iconClassName: 'text-primary [&_svg]:text-current', badgeLabel: 'Открыть', - onSelect: () => setCreateOpen(true), + onSelect: () => setCreateOpenState(true), }, { id: 'pending', @@ -568,7 +576,7 @@ function AgentsPage() { ) const addButton = canWrite ? ( - @@ -726,7 +734,13 @@ function AgentsPage() { )} - + { + setCreateOpenState(open) + if (!open && addParam) setSearch({ add: false }) + }} + /> ) => ({ + /** Открыть 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('static') const [extra, setExtra] = useState('') @@ -202,7 +208,7 @@ function ListsPage() { Обновить {canWrite ? ( - @@ -241,7 +247,7 @@ function ListsPage() { title: 'Нет списков', description: 'Создайте первый список для политики firewall.', action: ( - @@ -262,7 +268,15 @@ function ListsPage() { disabled={removeList.isPending} /> - + { + setCreateOpenState(open) + if (!open && createParam) { + void navigate({ to: '/lists', search: { create: undefined }, replace: true }) + } + }} + > Новый список diff --git a/apps/web/src/routes/_auth/rules/index.tsx b/apps/web/src/routes/_auth/rules/index.tsx index 9b5b622..981bd3f 100644 --- a/apps/web/src/routes/_auth/rules/index.tsx +++ b/apps/web/src/routes/_auth/rules/index.tsx @@ -29,6 +29,10 @@ import { import type { PolicySet } from '@evofw/shared' export const Route = createFileRoute('/_auth/rules/')({ + validateSearch: (search: Record) => ({ + /** Открыть 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([]) @@ -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 ? ( - @@ -260,7 +266,15 @@ function PolicySetsPage() { disabled={remove.isPending} /> - + { + setSheetOpenState(open) + if (!open && createParam) { + void navigate({ to: '/rules', search: { create: undefined }, replace: true }) + } + }} + > Новый набор @@ -289,7 +303,7 @@ function PolicySetsPage() { -