feat(web): enhance lists UI and functionality
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m42s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated Toaster component to support rich colors and position.
- Added breadcrumb navigation for list detail pages.
- Removed deprecated ListsCatalog component to streamline the codebase.
- Refactored list detail page to improve entry management and user experience.
- Enhanced filtering and navigation features in the lists overview.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 00:50:16 +07:00
co-authored by Cursor
parent 3f871f440f
commit 8aa265b1c8
6 changed files with 820 additions and 799 deletions
@@ -48,6 +48,13 @@ function getBreadcrumbs(
]
}
if (pathname.match(/^\/lists\/[^/]+$/)) {
return [
{ label: 'Списки', href: '/lists' },
{ label: dynamicLabels[pathname] ?? 'Список', href: pathname },
]
}
const title = routeTitles[pathname]
if (title) {
return [{ label: title, href: pathname }]
@@ -1,262 +0,0 @@
import {
GlobeIcon,
LinkIcon,
ListIcon,
RefreshCwIcon,
SearchIcon,
Trash2,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@evofw/ui/components/item'
import { Separator } from '@evofw/ui/components/separator'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { cn } from '@evofw/ui/lib/utils'
import {
ipListSourceLabel,
isManualListType,
type IpList,
} from '@evofw/shared'
/**
* Lists catalog — list-9 pattern (Frame + Item rows), not a full DataGrid.
* Preview: https://reui.io/preview/base/list-9 · list-5 tabs
*/
export function ListsCatalog({
items,
selectedId,
isLoading,
isError,
error,
onRetry,
onSelect,
onCreate,
onRefresh,
onDelete,
refreshPending,
}: {
items: IpList[]
selectedId?: string
isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
onSelect: (id: string) => void
onCreate: () => void
onRefresh: (id: string) => void
onDelete: (id: string) => void
refreshPending?: boolean
}) {
const [activeTab, setActiveTab] = useState('all')
const [query, setQuery] = useState('')
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return items.filter((item) => {
if (activeTab === 'manual' && !isManualListType(item.type)) return false
if (
activeTab !== 'all' &&
activeTab !== 'manual' &&
item.type !== activeTab
) {
return false
}
if (!q) return true
return item.name.toLowerCase().includes(q)
})
}, [items, activeTab, query])
const tabCounts = useMemo(() => {
const base = query.trim()
? items.filter((i) =>
i.name.toLowerCase().includes(query.trim().toLowerCase()),
)
: items
return {
all: base.length,
manual: base.filter((i) => isManualListType(i.type)).length,
json_url: base.filter((i) => i.type === 'json_url').length,
evobgp_community: base.filter((i) => i.type === 'evobgp_community')
.length,
}
}, [items, query])
if (isLoading) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<Skeleton className="h-5 w-24" />
</FrameHeader>
<FramePanel className="flex flex-col gap-2 p-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</FramePanel>
</Frame>
)
}
if (isError) {
return (
<Frame dense spacing="sm" className="w-full">
<FramePanel className="flex flex-col gap-2 p-4">
<p className="text-destructive text-sm">
{error?.message ?? 'Не удалось загрузить списки'}
</p>
{onRetry ? (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</FramePanel>
</Frame>
)
}
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-2">
<FrameTitle>Каталог</FrameTitle>
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}
</span>
</FrameHeader>
<FramePanel className="flex flex-col gap-0 p-0">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={[
{ id: 'all', label: 'Все', count: tabCounts.all },
{ id: 'manual', label: 'Ручной', count: tabCounts.manual },
{ id: 'json_url', label: 'JSON', count: tabCounts.json_url },
{
id: 'evobgp_community',
label: 'EvoBGP',
count: tabCounts.evobgp_community,
},
]}
value={activeTab}
onValueChange={setActiveTab}
/>
</div>
<Separator />
<div className="px-(--frame-panel-header-px) py-2">
<div className="relative">
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск…"
className="h-8 pl-8"
aria-label="Поиск списков"
/>
</div>
</div>
<Separator />
{items.length === 0 ? (
<div className="p-4">
<EmptyState
title="Нет списков"
description="Создайте первый список."
centered={false}
action={
<Button size="sm" onClick={onCreate}>
Новый список
</Button>
}
/>
</div>
) : filtered.length === 0 ? (
<p className="text-muted-foreground px-4 py-6 text-center text-sm">
Нет совпадений
</p>
) : (
<ItemGroup className="max-h-[min(28rem,55svh)] gap-0 overflow-y-auto p-1">
{filtered.map((list) => {
const selected = selectedId === list.id
const Icon = isManualListType(list.type)
? ListIcon
: list.type === 'json_url'
? LinkIcon
: GlobeIcon
return (
<Item
key={list.id}
size="sm"
variant={selected ? 'muted' : 'default'}
className={cn(
'cursor-pointer border-transparent',
selected && 'bg-muted ring-border ring-1',
)}
onClick={() => onSelect(list.id)}
>
<ItemMedia
variant="icon"
className="bg-background border-border size-8 rounded-md border"
>
<Icon aria-hidden />
</ItemMedia>
<ItemContent>
<ItemTitle className="gap-2">
<span className="truncate">{list.name}</span>
<StatusBadge status={list.type} />
</ItemTitle>
<ItemDescription>
{ipListSourceLabel(list.type)} ·{' '}
<span className="tabular-nums">
{list.entry_count ?? 0}
</span>{' '}
CIDR
</ItemDescription>
</ItemContent>
<ItemActions
onClick={(e) => e.stopPropagation()}
className="gap-0.5"
>
{!isManualListType(list.type) ? (
<Button
size="icon-sm"
variant="ghost"
aria-label="Refresh"
disabled={refreshPending}
onClick={() => onRefresh(list.id)}
>
<RefreshCwIcon className="size-3.5" />
</Button>
) : null}
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={() => onDelete(list.id)}
>
<Trash2 className="size-3.5" />
</Button>
</ItemActions>
</Item>
)
})}
</ItemGroup>
)}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,165 @@
import { Link } from '@tanstack/react-router'
import type { ColumnDef } from '@tanstack/react-table'
import { ListIcon, RefreshCwIcon, Trash2 } from 'lucide-react'
import type { FilterFieldConfig } from '@/components/reui/filters'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
DataGridMutedCell,
DataGridPrimaryCell,
} from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@evofw/ui/components/button'
import { isManualListType, type IpList } from '@evofw/shared'
export const LIST_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'manual', label: 'Ручные' },
{ id: 'json_url', label: 'JSON' },
{ id: 'evobgp_community', label: 'EvoBGP' },
] as const
export const listFilterFields: FilterFieldConfig[] = [
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
{
key: 'type',
label: 'Источник',
type: 'select',
options: [
{ value: 'static', label: 'Ручной' },
{ value: 'json_url', label: 'JSON по URL' },
{ value: 'evobgp_community', label: 'EvoBGP community' },
],
},
]
export function listFilterFieldValue(item: IpList, field: string): unknown {
if (field === 'name') return item.name
if (field === 'type') {
return isManualListType(item.type) ? 'static' : item.type
}
return undefined
}
export function listTabFilter(item: IpList, tabId: string): boolean {
if (tabId === 'all') return true
if (tabId === 'manual') return isManualListType(item.type)
return item.type === tabId
}
export function createListColumns(opts: {
onRefresh: (id: string) => void
onDelete: (id: string) => void
refreshPending?: boolean
}): ColumnDef<IpList>[] {
return [
{
accessorKey: 'name',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Список" />
),
cell: ({ row }) => (
<Link
to="/lists/$id"
params={{ id: row.original.id }}
className="flex min-w-0 items-center gap-2"
>
<ListIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden
/>
<DataGridPrimaryCell
accent="primary"
title={row.original.name}
subtitle={
row.original.last_error
? 'Ошибка обновления'
: row.original.refreshed_at
? `Обновлён ${new Date(row.original.refreshed_at).toLocaleString('ru-RU')}`
: undefined
}
/>
</Link>
),
meta: { headerTitle: 'Список' },
},
{
accessorKey: 'type',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Источник" />
),
cell: ({ row }) => <StatusBadge status={row.original.type} />,
meta: { headerTitle: 'Источник' },
},
{
id: 'entries',
accessorFn: (row) => row.entry_count ?? 0,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Записей" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.entry_count ?? 0}</span>
),
meta: { headerTitle: 'Записей' },
},
{
accessorKey: 'updated_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Обновлено" />
),
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.updated_at
? new Date(row.original.updated_at).toLocaleString('ru-RU')
: '—'}
</DataGridMutedCell>
),
meta: { headerTitle: 'Обновлено' },
},
{
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="Обновить"
disabled={opts.refreshPending}
onClick={(e) => {
e.stopPropagation()
opts.onRefresh(row.original.id)
}}
>
<RefreshCwIcon
className={
opts.refreshPending ? 'size-3.5 animate-spin' : 'size-3.5'
}
/>
</Button>
<Button
size="sm"
variant="outline"
render={
<Link to="/lists/$id" params={{ id: row.original.id }} />
}
>
Открыть
</Button>
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={(e) => {
e.stopPropagation()
opts.onDelete(row.original.id)
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
),
},
]
}
+1 -1
View File
@@ -31,7 +31,7 @@ createRoot(document.getElementById('root')!).render(
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<RouterProvider router={router} />
<Toaster />
<Toaster richColors position="top-right" />
</TooltipProvider>
</ThemeProvider>
</QueryClientProvider>
+566 -6
View File
@@ -1,10 +1,570 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
ArrowLeftIcon,
CircleAlertIcon,
HashIcon,
RefreshCwIcon,
TagIcon,
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 {
DetailPanel,
PageHeader,
PageShell,
ResourcePage,
} from '@/components/reui-kit'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
DataGridMutedCell,
DataGridPrimaryCell,
} from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { listQueryOptions, listsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { ScrollArea } from '@evofw/ui/components/scroll-area'
import { Textarea } from '@evofw/ui/components/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
isManualListType,
type ListEntryKind,
} from '@evofw/shared'
export const Route = createFileRoute('/_auth/lists/$id')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/lists',
search: { listId: params.id },
})
loader: async ({ context: { queryClient }, params }) => {
const detail = await queryClient.ensureQueryData(
listQueryOptions(params.id),
)
return { breadcrumb: detail.name }
},
component: ListDetailPage,
})
type ListItem = {
kind: ListEntryKind
value: string
list_name?: string | null
resolved_count: number
resolved_cidrs: string[]
}
/**
* List detail — DetailPanel + KpiStatGrid + entries ResourcePage.
* KPI: https://reui.io/preview/base/stats-12
* Entries: https://reui.io/preview/base/data-grid-filtering-2
* Alert: https://reui.io/docs/components/base/alert
* Empty: https://reui.io/preview/base/empty-state-12
* Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
*/
function ListDetailPage() {
const { id } = Route.useParams()
const navigate = useNavigate()
const qc = useQueryClient()
const listsQ = useQuery(listsQueryOptions())
const listQ = useQuery(listQueryOptions(id))
const [addOpen, setAddOpen] = useState(false)
const [addKind, setAddKind] = useState<ListEntryKind>('ip')
const [addValue, setAddValue] = useState('')
const [addListRef, setAddListRef] = useState('')
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
const [deleteListOpen, setDeleteListOpen] = useState(false)
const [deleteValue, setDeleteValue] = useState<string | null>(null)
const refresh = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/lists/${id}/refresh`, { method: 'POST' }),
onSuccess: () => {
toast.success('Обновлено')
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const removeList = useMutation({
mutationFn: () => apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Удалён')
setDeleteListOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] })
void navigate({ to: '/lists' })
},
onError: (e: Error) => toast.error(e.message),
})
const addEntries = useMutation({
mutationFn: async () => {
if (addKind === 'list') {
if (!addListRef) throw new Error('Выберите список')
return apiFetch(`/api/v1/lists/${id}/entries`, {
method: 'POST',
body: JSON.stringify({
items: [{ kind: 'list', value: addListRef }],
}),
})
}
const text = addValue.trim()
if (!text) throw new Error('Введите значение')
const lines = text
.split(/[\n,;]+/)
.map((s) => s.trim())
.filter(Boolean)
if (lines.length === 1) {
return apiFetch(`/api/v1/lists/${id}/entries`, {
method: 'POST',
body: JSON.stringify({
items: [{ kind: addKind, value: lines[0]! }],
}),
})
}
return apiFetch(`/api/v1/lists/${id}/entries`, {
method: 'POST',
body: JSON.stringify({ values: [text] }),
})
},
onSuccess: () => {
toast.success('Добавлено')
setAddValue('')
setAddListRef('')
setAddKind('ip')
setAddOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const removeEntry = useMutation({
mutationFn: (value: string) =>
apiFetch(`/api/v1/lists/${id}/entries`, {
method: 'DELETE',
body: JSON.stringify({ value }),
}),
onSuccess: () => {
toast.success('Удалено')
setDeleteValue(null)
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const detail = listQ.data
const manual = detail ? isManualListType(detail.type) : false
const entryItems: ListItem[] = detail?.items ?? []
const allLists = listsQ.data?.items ?? []
const nestedCandidates = allLists.filter((l) => l.id !== id)
const entryFilterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'value',
label: 'Значение',
type: 'text',
placeholder: 'Поиск…',
},
{
key: 'kind',
label: 'Вид',
type: 'select',
options: [
{ value: 'ip', label: 'IP' },
{ value: 'cidr', label: 'CIDR' },
{ value: 'hostname', label: 'Домен' },
{ value: 'list', label: 'Список' },
],
},
],
[],
)
const getEntryFilterValue = useCallback((item: ListItem, field: string) => {
if (field === 'value') {
return item.kind === 'list'
? `${item.list_name ?? ''} ${item.value}`
: item.value
}
if (field === 'kind') return item.kind
return undefined
}, [])
const entryColumns: ColumnDef<ListItem>[] = useMemo(
() => [
{
accessorKey: 'value',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Значение" />
),
cell: ({ row }) => (
<DataGridPrimaryCell
accent={row.original.kind === 'list' ? 'primary' : 'mono'}
title={
row.original.kind === 'list'
? (row.original.list_name ?? row.original.value)
: row.original.value
}
subtitle={
row.original.kind === 'list'
? row.original.value
: row.original.resolved_count > 0
? `${row.original.resolved_count} CIDR`
: undefined
}
/>
),
},
{
accessorKey: 'kind',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Вид" />
),
cell: ({ row }) => <StatusBadge status={row.original.kind} />,
},
{
id: 'resolved',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="CIDR" />
),
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.resolved_count > 0
? String(row.original.resolved_count)
: '—'}
</DataGridMutedCell>
),
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) =>
manual ? (
<div className="flex justify-end">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteValue(row.original.value)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
) : null,
},
],
[manual],
)
const canAdd =
addKind === 'list' ? Boolean(addListRef) : Boolean(addValue.trim())
if (listQ.isLoading) {
return (
<PageShell>
<PageHeader title="Список" description="Загрузка…" />
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</PageShell>
)
}
if (!detail) {
return (
<PageShell>
<PageHeader
title="Список не найден"
actions={
<Button
variant="outline"
size="sm"
render={<Link to="/lists" />}
>
К спискам
</Button>
}
/>
</PageShell>
)
}
return (
<PageShell>
<PageHeader
title={detail.name}
description={
manual
? 'IP, CIDR, домены и вложенные списки'
: 'Записи из внешнего источника (только чтение)'
}
actions={
<>
<Button
variant="outline"
size="sm"
render={<Link to="/lists" />}
>
<ArrowLeftIcon className="size-3.5" />
Списки
</Button>
<Button
size="sm"
variant="outline"
disabled={refresh.isPending}
onClick={() => refresh.mutate()}
>
<RefreshCwIcon
className={
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
}
/>
Обновить
</Button>
{manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="text-destructive"
onClick={() => setDeleteListOpen(true)}
>
<Trash2 className="size-3.5" />
Удалить
</Button>
</>
}
/>
<DetailPanel>
<DetailPanel.Header
title={detail.name}
description={
manual
? 'Ручной список'
: detail.type === 'json_url'
? 'JSON по URL'
: 'EvoBGP community'
}
actions={<StatusBadge status={detail.type} />}
/>
<DetailPanel.Metrics
cards={[
{
id: 'count',
icon: <HashIcon aria-hidden />,
iconClassName: 'text-info',
label: 'Записей',
description: String(entryItems.length),
hint: manual ? 'ручной список' : 'внешний источник',
},
{
id: 'type',
icon: <TagIcon aria-hidden />,
iconClassName: 'text-primary',
label: 'Источник',
description:
detail.type === 'json_url'
? 'JSON'
: detail.type === 'evobgp_community'
? 'EvoBGP'
: 'Ручной',
hint:
detail.type === 'json_url'
? 'JSON по URL'
: detail.type === 'evobgp_community'
? 'community prefixes'
: 'IP / CIDR / домен / список',
},
{
id: 'cidrs',
icon: <RefreshCwIcon aria-hidden />,
iconClassName: 'text-success',
label: 'CIDR в политике',
description: String(
detail.entry_count ?? detail.entries.length,
),
hint: 'materialized',
},
]}
/>
{detail.last_error ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка обновления</AlertTitle>
<AlertDescription>{detail.last_error}</AlertDescription>
</Alert>
) : null}
<DetailPanel.Section title="Содержимое">
<ResourcePage
title="Entries"
hideHeader
data={entryItems}
columns={entryColumns}
getRowId={(r) => `${r.kind}:${r.value}`}
filterFields={entryFilterFields}
filters={entryFilters}
onFiltersChange={setEntryFilters}
onClearFilters={() => setEntryFilters([])}
getFilterFieldValue={getEntryFilterValue}
emptyState={{
title: 'Нет записей',
description: manual
? 'Добавьте IP, CIDR, домен или другой список.'
: 'Нажмите Обновить или проверьте источник.',
action: manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button>
) : undefined,
}}
/>
</DetailPanel.Section>
</DetailPanel>
<ConfirmDialog
open={deleteListOpen}
onOpenChange={setDeleteListOpen}
title="Удалить список?"
description="Записи списка будут удалены. Ссылки из других списков нужно убрать вручную."
onConfirm={() => removeList.mutate()}
disabled={removeList.isPending}
/>
<ConfirmDialog
open={deleteValue !== null}
onOpenChange={(open) => {
if (!open) setDeleteValue(null)
}}
title="Удалить запись?"
description={
deleteValue
? `Будет удалено: ${deleteValue}`
: 'Запись будет удалена из списка.'
}
onConfirm={() => {
if (deleteValue) removeEntry.mutate(deleteValue)
}}
disabled={removeEntry.isPending}
/>
<Sheet open={addOpen} onOpenChange={setAddOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Добавить запись</SheetTitle>
<SheetDescription>
Выберите вид и значение. Для IP/CIDR/доменов можно вставить
несколько строк сразу.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="grid auto-rows-min gap-4 py-2 pb-4">
<Field>
<FieldLabel>Вид</FieldLabel>
<Select
value={addKind}
onValueChange={(v) => {
if (v) setAddKind(v as ListEntryKind)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ip">IP</SelectItem>
<SelectItem value="cidr">CIDR / диапазон</SelectItem>
<SelectItem value="hostname">Домен</SelectItem>
<SelectItem value="list">Список</SelectItem>
</SelectContent>
</Select>
</Field>
{addKind === 'list' ? (
<Field>
<FieldLabel>Список</FieldLabel>
<Select
value={addListRef || null}
onValueChange={(v) => {
if (v) setAddListRef(v)
}}
>
<SelectTrigger>
<SelectValue placeholder="Выберите список" />
</SelectTrigger>
<SelectContent>
{nestedCandidates.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
) : (
<Field>
<FieldLabel htmlFor="entry-value">Значение</FieldLabel>
<Textarea
id="entry-value"
rows={5}
value={addValue}
placeholder={
addKind === 'ip'
? '8.8.8.8'
: addKind === 'cidr'
? '10.0.0.0/8'
: 'bad.example.com'
}
onChange={(e) => setAddValue(e.target.value)}
/>
</Field>
)}
</div>
</ScrollArea>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setAddOpen(false)}>
Отмена
</Button>
<Button
disabled={!canAdd || addEntries.isPending}
onClick={() => addEntries.mutate()}
>
Добавить
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</PageShell>
)
}
+81 -530
View File
@@ -1,41 +1,24 @@
import {
createFileRoute,
useNavigate,
} from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
ArrowLeftIcon,
HashIcon,
RefreshCwIcon,
TagIcon,
Trash2,
} from 'lucide-react'
import { RefreshCwIcon } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { z } from 'zod'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import type { Filter } from '@/components/reui/filters'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import {
DetailPanel,
PageHeader,
PageShell,
ResourcePage,
} from '@/components/reui-kit'
import { ListsCatalog } from '@/components/lists/lists-catalog'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
DataGridMutedCell,
DataGridPrimaryCell,
} from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
LIST_TABS,
createListColumns,
listFilterFieldValue,
listFilterFields,
listTabFilter,
} from '@/components/lists/lists-columns'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { listQueryOptions, listsQueryOptions } from '@/queries'
import { listsQueryOptions } 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 { ScrollArea } from '@evofw/ui/components/scroll-area'
import { Textarea } from '@evofw/ui/components/textarea'
import {
Select,
SelectContent,
@@ -51,74 +34,33 @@ import {
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { cn } from '@evofw/ui/lib/utils'
import {
guessListSourceFromInput,
isManualListType,
type ListEntryKind,
} from '@evofw/shared'
const listsSearchSchema = z.object({
listId: z.string().optional(),
})
import { guessListSourceFromInput } from '@evofw/shared'
export const Route = createFileRoute('/_auth/lists/')({
validateSearch: (search) => listsSearchSchema.parse(search),
component: ListsPage,
})
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
type ListItem = {
kind: ListEntryKind
value: string
list_name?: string | null
resolved_count: number
resolved_cidrs: string[]
}
/**
* Lists — master-detail (ReUI PRO composition).
* Catalog: https://reui.io/preview/base/list-9
* Entries: https://reui.io/preview/base/data-grid-filtering-2
* KPI: https://reui.io/preview/base/stats-12 (KpiStatGrid hybrid)
* Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
* Lists catalog — 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-1 · sheet-8
* Docs: https://reui.io/blocks
*/
function ListsPage() {
const navigate = useNavigate({ from: Route.fullPath })
const { listId } = Route.useSearch()
const navigate = useNavigate()
const qc = useQueryClient()
const listsQ = useQuery(listsQueryOptions())
const listQ = useQuery({
...listQueryOptions(listId ?? ''),
enabled: Boolean(listId),
})
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
const [source, setSource] = useState<CreateSource>('static')
const [extra, setExtra] = useState('')
const [addOpen, setAddOpen] = useState(false)
const [addKind, setAddKind] = useState<ListEntryKind>('ip')
const [addValue, setAddValue] = useState('')
const [addListRef, setAddListRef] = useState('')
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
const [filters, setFilters] = useState<Filter[]>([])
const [activeTab, setActiveTab] = useState('all')
const [deleteListId, setDeleteListId] = useState<string | null>(null)
const [deleteValue, setDeleteValue] = useState<string | null>(null)
const selectList = useCallback(
(id: string | undefined) => {
void navigate({
search: (prev) => ({ ...prev, listId: id }),
replace: true,
})
},
[navigate],
)
const create = useMutation({
mutationFn: async () => {
@@ -140,7 +82,7 @@ function ListsPage() {
setSource('static')
setCreateOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] })
selectList(row.id)
void navigate({ to: '/lists/$id', params: { id: row.id } })
},
onError: (e: Error) => toast.error(e.message),
})
@@ -158,378 +100,90 @@ function ListsPage() {
const removeList = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
onSuccess: (_data, id) => {
onSuccess: () => {
toast.success('Удалён')
setDeleteListId(null)
if (listId === id) selectList(undefined)
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const addEntries = useMutation({
mutationFn: async () => {
if (!listId) throw new Error('Список не выбран')
if (addKind === 'list') {
if (!addListRef) throw new Error('Выберите список')
return apiFetch(`/api/v1/lists/${listId}/entries`, {
method: 'POST',
body: JSON.stringify({
items: [{ kind: 'list', value: addListRef }],
}),
})
}
const text = addValue.trim()
if (!text) throw new Error('Введите значение')
const lines = text
.split(/[\n,;]+/)
.map((s) => s.trim())
.filter(Boolean)
if (lines.length === 1) {
return apiFetch(`/api/v1/lists/${listId}/entries`, {
method: 'POST',
body: JSON.stringify({
items: [{ kind: addKind, value: lines[0]! }],
}),
})
}
return apiFetch(`/api/v1/lists/${listId}/entries`, {
method: 'POST',
body: JSON.stringify({ values: [text] }),
})
},
onSuccess: () => {
toast.success('Добавлено')
setAddValue('')
setAddListRef('')
setAddKind('ip')
setAddOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const removeEntry = useMutation({
mutationFn: (value: string) => {
if (!listId) throw new Error('Список не выбран')
return apiFetch(`/api/v1/lists/${listId}/entries`, {
method: 'DELETE',
body: JSON.stringify({ value }),
})
},
onSuccess: () => {
toast.success('Удалено')
setDeleteValue(null)
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const items = listsQ.data?.items ?? []
const detail = listQ.data
const manual = detail ? isManualListType(detail.type) : false
const entryItems: ListItem[] = detail?.items ?? []
const entryFilterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'value',
label: 'Значение',
type: 'text',
placeholder: 'Поиск…',
},
{
key: 'kind',
label: 'Вид',
type: 'select',
options: [
{ value: 'ip', label: 'IP' },
{ value: 'cidr', label: 'CIDR' },
{ value: 'hostname', label: 'Домен' },
{ value: 'list', label: 'Список' },
],
},
],
[],
const columns = useMemo(
() =>
createListColumns({
onRefresh: (id) => refresh.mutate(id),
onDelete: setDeleteListId,
refreshPending: refresh.isPending,
}),
[refresh.isPending],
)
const getEntryFilterValue = useCallback((item: ListItem, field: string) => {
if (field === 'value') {
return item.kind === 'list'
? `${item.list_name ?? ''} ${item.value}`
: item.value
}
if (field === 'kind') return item.kind
return undefined
}, [])
const entryColumns: ColumnDef<ListItem>[] = useMemo(
() => [
{
accessorKey: 'value',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Значение" />
),
cell: ({ row }) => (
<DataGridPrimaryCell
accent={row.original.kind === 'list' ? 'primary' : 'mono'}
title={
row.original.kind === 'list'
? (row.original.list_name ?? row.original.value)
: row.original.value
}
subtitle={
row.original.kind === 'list'
? row.original.value
: row.original.resolved_count > 0
? `${row.original.resolved_count} CIDR`
: undefined
}
/>
),
},
{
accessorKey: 'kind',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Вид" />
),
cell: ({ row }) => <StatusBadge status={row.original.kind} />,
},
{
id: 'resolved',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="CIDR" />
),
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.resolved_count > 0
? String(row.original.resolved_count)
: '—'}
</DataGridMutedCell>
),
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) =>
manual ? (
<div className="flex justify-end">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
onClick={() => setDeleteValue(row.original.value)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
) : null,
},
],
[manual],
)
const getFilterFieldValue = useCallback(listFilterFieldValue, [])
const tabFilter = useCallback(listTabFilter, [])
const canCreate =
Boolean(name.trim()) &&
(source === 'static' || Boolean(extra.trim()))
const canAdd =
addKind === 'list' ? Boolean(addListRef) : Boolean(addValue.trim())
const nestedCandidates = items.filter((l) => l.id !== listId)
const showCatalogOnMobile = !listId
const showDetailOnMobile = Boolean(listId)
return (
<PageShell>
<PageHeader
title="Списки"
description="Создайте список и заполните его IP, CIDR, доменами или другими списками"
description="Маршрутные списки: IP, CIDR, домены, JSON и EvoBGP community"
actions={
<Button size="sm" onClick={() => setCreateOpen(true)}>
Новый список
</Button>
<>
<Button
variant="outline"
size="sm"
onClick={() => void listsQ.refetch()}
disabled={listsQ.isFetching}
>
<RefreshCwIcon
className={listsQ.isFetching ? 'animate-spin' : undefined}
/>
Обновить
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
Создать
</Button>
</>
}
/>
<div className="grid gap-4 lg:grid-cols-[minmax(280px,340px)_1fr] lg:items-start">
<div
className={cn(
'min-w-0',
showCatalogOnMobile ? 'block' : 'hidden lg:block',
)}
>
<ListsCatalog
items={items}
selectedId={listId}
isLoading={listsQ.isLoading}
isError={listsQ.isError}
error={listsQ.error}
onRetry={() => void listsQ.refetch()}
onSelect={selectList}
onCreate={() => setCreateOpen(true)}
onRefresh={(id) => refresh.mutate(id)}
onDelete={setDeleteListId}
refreshPending={refresh.isPending}
/>
</div>
<div
className={cn(
'min-w-0',
showDetailOnMobile ? 'block' : 'hidden lg:block',
)}
>
{!listId ? (
<DetailPanel>
<DetailPanel.Header
title="Выберите список"
description="Кликните строку в каталоге слева, чтобы увидеть и редактировать содержимое."
/>
</DetailPanel>
) : listQ.isLoading ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : !detail ? (
<DetailPanel>
<DetailPanel.Header
title="Список не найден"
actions={
<Button
variant="outline"
size="sm"
onClick={() => selectList(undefined)}
>
К каталогу
</Button>
}
/>
</DetailPanel>
) : (
<DetailPanel>
<DetailPanel.Header
title={detail.name}
description={
manual
? 'IP, CIDR, домены и вложенные списки'
: 'Записи из внешнего источника (только чтение)'
}
actions={
<>
<Button
variant="outline"
size="sm"
className="lg:hidden"
onClick={() => selectList(undefined)}
>
<ArrowLeftIcon className="size-3.5" />
Списки
</Button>
<StatusBadge status={detail.type} />
<Button
size="sm"
variant="outline"
disabled={refresh.isPending}
onClick={() => refresh.mutate(detail.id)}
>
<RefreshCwIcon
className={
refresh.isPending
? 'size-3.5 animate-spin'
: 'size-3.5'
}
/>
Refresh
</Button>
{manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button>
) : null}
</>
}
/>
<DetailPanel.Metrics
cards={[
{
id: 'count',
icon: <HashIcon aria-hidden />,
iconClassName: 'text-info',
label: 'Записей',
description: String(entryItems.length),
hint: manual ? 'ручной список' : 'внешний источник',
},
{
id: 'type',
icon: <TagIcon aria-hidden />,
iconClassName: 'text-primary',
label: 'Источник',
description:
detail.type === 'json_url'
? 'JSON'
: detail.type === 'evobgp_community'
? 'EvoBGP'
: 'Ручной',
hint:
detail.type === 'json_url'
? 'JSON по URL'
: detail.type === 'evobgp_community'
? 'community prefixes'
: 'IP / CIDR / домен / список',
},
{
id: 'cidrs',
icon: <RefreshCwIcon aria-hidden />,
iconClassName: 'text-success',
label: 'CIDR в политике',
description: String(
detail.entry_count ?? detail.entries.length,
),
hint: 'materialized',
},
]}
/>
<DetailPanel.Section title="Содержимое">
<ResourcePage
title="Entries"
hideHeader
data={entryItems}
columns={entryColumns}
getRowId={(r) => `${r.kind}:${r.value}`}
filterFields={entryFilterFields}
filters={entryFilters}
onFiltersChange={setEntryFilters}
onClearFilters={() => setEntryFilters([])}
getFilterFieldValue={getEntryFilterValue}
emptyState={{
title: 'Нет записей',
description: manual
? 'Добавьте IP, CIDR, домен или другой список.'
: 'Нажмите Refresh или проверьте источник.',
action: manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button>
) : undefined,
}}
/>
</DetailPanel.Section>
{detail.last_error ? (
<p className="text-destructive text-sm">{detail.last_error}</p>
) : null}
</DetailPanel>
)}
</div>
</div>
<ResourcePage
title="Все списки"
hideHeader
data={items}
columns={columns}
getRowId={(r) => r.id}
filterFields={listFilterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
tabs={[...LIST_TABS]}
activeTab={activeTab}
onTabChange={setActiveTab}
tabFilter={tabFilter}
isLoading={listsQ.isLoading}
isError={listsQ.isError}
error={listsQ.error}
onRetry={() => void listsQ.refetch()}
onRowClick={(row) =>
void navigate({ to: '/lists/$id', params: { id: row.id } })
}
emptyState={{
title: 'Нет списков',
description: 'Создайте первый список для политики firewall.',
action: (
<Button size="sm" onClick={() => setCreateOpen(true)}>
Создать
</Button>
),
}}
/>
<ConfirmDialog
open={deleteListId !== null}
@@ -544,30 +198,12 @@ function ListsPage() {
disabled={removeList.isPending}
/>
<ConfirmDialog
open={deleteValue !== null}
onOpenChange={(open) => {
if (!open) setDeleteValue(null)
}}
title="Удалить запись?"
description={
deleteValue
? `Будет удалено: ${deleteValue}`
: 'Запись будет удалена из списка.'
}
onConfirm={() => {
if (deleteValue) removeEntry.mutate(deleteValue)
}}
disabled={removeEntry.isPending}
/>
{/* sheet-1 / sheet-8: inset form + sticky footer */}
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Новый список</SheetTitle>
<SheetDescription>
После создания заполните содержимое в таблице справа.
После создания откроется страница со содержимым списка.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
@@ -642,91 +278,6 @@ function ListsPage() {
</SheetFooter>
</SheetContent>
</Sheet>
<Sheet open={addOpen} onOpenChange={setAddOpen}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Добавить запись</SheetTitle>
<SheetDescription>
Выберите вид и значение. Для IP/CIDR/доменов можно вставить
несколько строк сразу.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="grid auto-rows-min gap-4 py-2 pb-4">
<Field>
<FieldLabel>Вид</FieldLabel>
<Select
value={addKind}
onValueChange={(v) => {
if (v) setAddKind(v as ListEntryKind)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ip">IP</SelectItem>
<SelectItem value="cidr">CIDR / диапазон</SelectItem>
<SelectItem value="hostname">Домен</SelectItem>
<SelectItem value="list">Список</SelectItem>
</SelectContent>
</Select>
</Field>
{addKind === 'list' ? (
<Field>
<FieldLabel>Список</FieldLabel>
<Select
value={addListRef || null}
onValueChange={(v) => {
if (v) setAddListRef(v)
}}
>
<SelectTrigger>
<SelectValue placeholder="Выберите список" />
</SelectTrigger>
<SelectContent>
{nestedCandidates.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
) : (
<Field>
<FieldLabel htmlFor="entry-value">Значение</FieldLabel>
<Textarea
id="entry-value"
rows={5}
value={addValue}
placeholder={
addKind === 'ip'
? '8.8.8.8'
: addKind === 'cidr'
? '10.0.0.0/8'
: 'bad.example.com'
}
onChange={(e) => setAddValue(e.target.value)}
/>
</Field>
)}
</div>
</ScrollArea>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setAddOpen(false)}>
Отмена
</Button>
<Button
disabled={!canAdd || addEntries.isPending}
onClick={() => addEntries.mutate()}
>
Добавить
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</PageShell>
)
}