fix(web): открытие detail-роутов и паритет ReUI DataGrid с EvoBGP/CFDM
Sibling routes index+$id, ColumnHeader/PrimaryCell/StatusBadge/ConfirmDialog, tabs и таблица entries списков. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
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 { policySetsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
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'
|
||||
|
||||
export const Route = createFileRoute('/_auth/rules/')({
|
||||
component: PolicySetsPage,
|
||||
})
|
||||
|
||||
/**
|
||||
* Policy sets list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* 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()
|
||||
const qc = useQueryClient()
|
||||
const setsQ = useQuery(policySetsQueryOptions())
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
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('')
|
||||
setSheetOpen(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 tabFilter = useCallback((item: PolicySet, tabId: string) => {
|
||||
if (tabId === 'all') return true
|
||||
if (tabId === 'enabled') return item.enabled
|
||||
if (tabId === 'disabled') return !item.enabled
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const columns: ColumnDef<PolicySet>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Набор" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to="/rules/$setId"
|
||||
params={{ setId: row.original.id }}
|
||||
className="min-w-0"
|
||||
>
|
||||
<DataGridPrimaryCell
|
||||
accent="primary"
|
||||
title={row.original.name}
|
||||
subtitle={row.original.description ?? undefined}
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
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="sm"
|
||||
variant="outline"
|
||||
render={
|
||||
<Link
|
||||
to="/rules/$setId"
|
||||
params={{ setId: row.original.id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
{row.original.id !== 'set-shared-default' ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить"
|
||||
onClick={() => setDeleteId(row.original.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Наборы правил"
|
||||
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
Новый набор
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourcePage
|
||||
title="Наборы"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
tabs={[
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'enabled', label: 'Включён' },
|
||||
{ id: 'disabled', label: 'Выключен' },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={setsQ.isLoading}
|
||||
isError={setsQ.isError}
|
||||
error={setsQ.error}
|
||||
onRetry={() => void setsQ.refetch()}
|
||||
emptyState={{
|
||||
title: 'Нет наборов',
|
||||
description: 'Создайте первый набор правил политики.',
|
||||
action: (
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
Новый набор
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<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={setSheetOpen}>
|
||||
<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={() => setSheetOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!name.trim() || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user