355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
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<string, unknown>): 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<string, unknown>)
|
|
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<string | null>(null)
|
|
|
|
const create = useMutation({
|
|
mutationFn: () =>
|
|
apiFetch<PolicySet>('/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<PolicySet>[] = useMemo(
|
|
() => [
|
|
{
|
|
accessorKey: 'name',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Набор" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<PolicySetIcon />
|
|
<DataGridPrimaryCell
|
|
accent="primary"
|
|
title={row.original.name}
|
|
subtitle={row.original.description ?? undefined}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'enabled',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Статус" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<StatusBadge
|
|
status={row.original.enabled ? 'enabled' : 'disabled'}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'rules_count',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Правила" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="tabular-nums">{row.original.rules_count ?? 0}</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'agents_count',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Агенты" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<span className="tabular-nums">
|
|
{row.original.agents_count ?? 0}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: 'actions',
|
|
enableSorting: false,
|
|
header: () => <span className="sr-only">Действия</span>,
|
|
cell: ({ row }) => (
|
|
<div className="flex justify-end gap-1">
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
aria-label="Открыть"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void navigate({
|
|
to: '/rules/$setId',
|
|
params: { setId: row.original.id },
|
|
})
|
|
}}
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
</Button>
|
|
{row.original.id !== 'set-shared-default' ? (
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className="text-destructive"
|
|
aria-label="Удалить"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setDeleteId(row.original.id)
|
|
}}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
],
|
|
[navigate],
|
|
)
|
|
|
|
const canCreate = useCan()('fw:policies:write')
|
|
const addButton = canCreate ? (
|
|
<Button size="sm" onClick={() => setSheetOpenState(true)}>
|
|
<Plus data-icon="inline-start" />
|
|
Новый набор
|
|
</Button>
|
|
) : null
|
|
|
|
return (
|
|
<PageShell>
|
|
<PageHeader
|
|
title="Наборы правил"
|
|
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
|
|
actions={addButton}
|
|
/>
|
|
|
|
<ResourcePage
|
|
title="Наборы"
|
|
hideHeader
|
|
data={items}
|
|
columns={columns}
|
|
getRowId={(r) => 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,
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={deleteId !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeleteId(null)
|
|
}}
|
|
title="Удалить набор?"
|
|
description="Правила набора будут удалены. Назначения агентам снимутся."
|
|
confirmLabel="Удалить"
|
|
onConfirm={() => {
|
|
if (deleteId) remove.mutate(deleteId)
|
|
}}
|
|
disabled={remove.isPending}
|
|
/>
|
|
|
|
<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>
|
|
<SheetDescription>
|
|
После создания добавьте правила и назначьте набор агентам.
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
|
<Field>
|
|
<FieldLabel htmlFor="set-name">Имя</FieldLabel>
|
|
<Input
|
|
id="set-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Web deny"
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="set-desc">Описание</FieldLabel>
|
|
<Textarea
|
|
id="set-desc"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
|
<Button variant="outline" onClick={() => setSheetOpenState(false)}>
|
|
Отмена
|
|
</Button>
|
|
<Button
|
|
disabled={!name.trim() || create.isPending}
|
|
onClick={() => create.mutate()}
|
|
>
|
|
Создать
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</PageShell>
|
|
)
|
|
}
|