import type { ColumnDef } from '@tanstack/react-table' import { Pencil, Plus, Trash2 } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import type { Filter, FilterFieldConfig } from '@/components/reui/filters' import { searchToFilters, searchToFlag, searchToQ } from '@/lib/search-params' import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { StatusBadge } from '@/components/status-badge' import { ConfirmDialog } from '@/components/confirm-dialog' import { PolicySetIcon } from '@/components/rules/policy-set-icon' import { policySetsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' import { useCan } from '@/lib/permissions' import { Button } from '@evofw/ui/components/button' import { Field, FieldLabel } from '@evofw/ui/components/field' import { Input } from '@evofw/ui/components/input' import { Textarea } from '@evofw/ui/components/textarea' import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from '@evofw/ui/components/sheet' import type { PolicySet } from '@evofw/shared' type RulesSearch = { create?: boolean q?: string f?: Filter[] } /** URL-состояние страницы: create/q/f (deep-link и refresh сохраняют вид). */ function parseRulesSearch(search: Record): RulesSearch { return { create: searchToFlag(search.create), q: searchToQ(search.q), f: searchToFilters(search.f), } } export const Route = createFileRoute('/_auth/rules/')({ validateSearch: parseRulesSearch, component: PolicySetsPage, }) /** * Policy sets list — ResourcePage (Frame + Filters + DataGrid + search). * Preview: https://reui.io/preview/base/data-grid-filtering-2 * Empty: https://reui.io/preview/base/empty-state-12 * Create sheet: https://reui.io/preview/base/sheet-8 */ function PolicySetsPage() { const navigate = useNavigate({ from: Route.fullPath }) const qc = useQueryClient() const setsQ = useQuery(policySetsQueryOptions()) const { create: createParam, q: qParam, f: fParam } = Route.useSearch() const searchQuery = qParam ?? '' const filters = fParam ?? [] const [sheetOpenState, setSheetOpenState] = useState(false) const sheetOpen = sheetOpenState || createParam === true /** Обновление URL-состояния (q/f) без перезагрузки. */ const setRulesSearch = useCallback( (next: { q?: string; f?: Filter[]; create?: boolean }) => { void navigate({ search: (prev) => { const base = parseRulesSearch(prev as Record) return { create: next.create !== undefined ? next.create || undefined : base.create, q: next.q !== undefined ? searchToQ(next.q) : base.q, f: next.f !== undefined ? next.f : base.f, } }, replace: true, }) }, [navigate], ) const [name, setName] = useState('') const [description, setDescription] = useState('') const [deleteId, setDeleteId] = useState(null) const create = useMutation({ mutationFn: () => apiFetch('/api/v1/policy-sets', { method: 'POST', body: JSON.stringify({ name, description: description.trim() || null, enabled: true, }), }), onSuccess: (row) => { toast.success('Набор создан') setName('') setDescription('') setSheetOpenState(false) void qc.invalidateQueries({ queryKey: ['policy-sets'] }) void navigate({ to: '/rules/$setId', params: { setId: row.id } }) }, onError: (e: Error) => toast.error(e.message), }) const remove = useMutation({ mutationFn: (id: string) => apiFetch(`/api/v1/policy-sets/${id}`, { method: 'DELETE' }), onSuccess: () => { toast.success('Набор удалён') setDeleteId(null) void qc.invalidateQueries({ queryKey: ['policy-sets'] }) }, onError: (e: Error) => toast.error(e.message), }) const items = setsQ.data?.items ?? [] const filterFields: FilterFieldConfig[] = useMemo( () => [ { key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' }, { key: 'enabled', label: 'Статус', type: 'select', options: [ { value: 'true', label: 'Включён' }, { value: 'false', label: 'Выключен' }, ], }, ], [], ) const getFilterFieldValue = useCallback((item: PolicySet, field: string) => { if (field === 'name') return item.name if (field === 'enabled') return String(item.enabled) return undefined }, []) const getSearchText = useCallback( (item: PolicySet) => [item.name, item.description ?? ''].filter(Boolean).join(' '), [], ) const columns: ColumnDef[] = useMemo( () => [ { accessorKey: 'name', header: ({ column }) => ( ), cell: ({ row }) => (
), }, { accessorKey: 'enabled', header: ({ column }) => ( ), cell: ({ row }) => ( ), }, { accessorKey: 'rules_count', header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.rules_count ?? 0} ), }, { accessorKey: 'agents_count', header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.agents_count ?? 0} ), }, { id: 'actions', enableSorting: false, header: () => Действия, cell: ({ row }) => (
{row.original.id !== 'set-shared-default' ? ( ) : null}
), }, ], [navigate], ) const canCreate = useCan()('fw:policies:write') const addButton = canCreate ? ( ) : null return ( r.id} filterFields={filterFields} filters={filters} onFiltersChange={(f) => setRulesSearch({ f })} onClearFilters={() => setRulesSearch({ f: [], q: '' })} getFilterFieldValue={getFilterFieldValue} searchQuery={searchQuery} onSearchChange={(q) => setRulesSearch({ q })} searchPlaceholder="Поиск наборов…" getSearchText={getSearchText} isLoading={setsQ.isLoading} isError={setsQ.isError} error={setsQ.error} onRetry={() => void setsQ.refetch()} onRowClick={(row) => void navigate({ to: '/rules/$setId', params: { setId: row.id } }) } emptyState={{ title: 'Нет наборов', description: 'Создайте первый набор правил политики.', action: addButton, }} /> { if (!open) setDeleteId(null) }} title="Удалить набор?" description="Правила набора будут удалены. Назначения агентам снимутся." confirmLabel="Удалить" onConfirm={() => { if (deleteId) remove.mutate(deleteId) }} disabled={remove.isPending} /> { setSheetOpenState(open) if (!open && createParam) { void navigate({ to: '/rules', search: { create: undefined }, replace: true }) } }} > Новый набор После создания добавьте правила и назначьте набор агентам.
Имя setName(e.target.value)} placeholder="Web deny" /> Описание