From c505ab82e88bd5d2b8df6d8fd2d836ba6f9e3e6b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 20 Jul 2026 23:25:05 +0700 Subject: [PATCH] feat(api, web): enhance list entry management and UI consistency - Updated API to support structured list entry inputs, allowing for nested list references. - Improved error handling for list operations to prevent cyclic references. - Refactored UI components to ensure consistent labeling and navigation for IP lists. - Enhanced list detail and catalog pages with better filtering and entry management features. Co-authored-by: Cursor --- apps/api/src/routes/control.ts | 7 +- apps/api/src/services/lists/entries.test.ts | 93 +++ apps/api/src/services/lists/entries.ts | 202 +++++- apps/api/src/services/lists/refresh.ts | 17 +- apps/web/src/components/app-sidebar.tsx | 2 +- .../web/src/components/layout/site-header.tsx | 2 +- .../src/components/reui-kit/resource-page.tsx | 3 + apps/web/src/components/status-badge.tsx | 2 + apps/web/src/queries/index.ts | 3 +- apps/web/src/routes/_auth/lists/$id.tsx | 382 +--------- apps/web/src/routes/_auth/lists/index.tsx | 675 +++++++++++++++--- packages/shared/src/list-entries.test.ts | 45 ++ packages/shared/src/list-entries.ts | 37 +- 13 files changed, 952 insertions(+), 518 deletions(-) create mode 100644 apps/api/src/services/lists/entries.test.ts create mode 100644 packages/shared/src/list-entries.test.ts diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts index 88d5ec4..4d3defb 100644 --- a/apps/api/src/routes/control.ts +++ b/apps/api/src/routes/control.ts @@ -300,7 +300,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( }) if (body.entries?.length && isManualListType(type)) { try { - await addListEntries(app.db, id, body.entries) + await addListEntries(app.db, id, { values: body.entries }) } catch (err) { repos.deleteIpList(app.db, id) throw new AppError( @@ -335,7 +335,10 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) const body = listEntriesBodySchema.parse(req.body) try { - const result = await addListEntries(app.db, l.id, body.values) + const result = await addListEntries(app.db, l.id, { + values: body.values, + items: body.items, + }) return mapListDetail(app.db, l.id) ?? result } catch (err) { throw new AppError( diff --git a/apps/api/src/services/lists/entries.test.ts b/apps/api/src/services/lists/entries.test.ts new file mode 100644 index 0000000..707aa16 --- /dev/null +++ b/apps/api/src/services/lists/entries.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { createMemoryDb, runMigrations, repos } from '@evofw/db' +import { + addListEntries, + deleteListEntry, + wouldCreateListCycle, + rebuildListCascade, +} from './entries.js' + +function insertManual( + db: ReturnType['db'], + id: string, + name: string, +) { + const now = new Date().toISOString() + repos.insertIpList(db, { + id, + name, + type: 'static', + configJson: '{}', + createdAt: now, + updatedAt: now, + }) +} + +describe('nested lists', () => { + let db: ReturnType['db'] + + beforeEach(() => { + const mem = createMemoryDb() + runMigrations(mem.sqlite) + db = mem.db + }) + + it('adds nested list and materializes child CIDRs into parent', async () => { + insertManual(db, 'child', 'Child') + insertManual(db, 'parent', 'Parent') + + await addListEntries(db, 'child', { values: ['8.8.8.8', '10.0.0.0/8'] }) + await addListEntries(db, 'parent', { + items: [{ kind: 'list', value: 'child' }], + }) + + const parentCidrs = repos.listIpListEntries(db, 'parent').map((e) => e.cidr) + expect(parentCidrs).toContain('8.8.8.8/32') + expect(parentCidrs).toContain('10.0.0.0/8') + }) + + it('rejects self-reference', async () => { + insertManual(db, 'a', 'A') + await expect( + addListEntries(db, 'a', { items: [{ kind: 'list', value: 'a' }] }), + ).rejects.toThrow(/себя/) + }) + + it('rejects cycles A→B→A', async () => { + insertManual(db, 'a', 'A') + insertManual(db, 'b', 'B') + await addListEntries(db, 'a', { items: [{ kind: 'list', value: 'b' }] }) + expect(wouldCreateListCycle(db, 'b', 'a')).toBe(true) + await expect( + addListEntries(db, 'b', { items: [{ kind: 'list', value: 'a' }] }), + ).rejects.toThrow(/цикл/i) + }) + + it('cascades rebuild when child changes', async () => { + insertManual(db, 'child', 'Child') + insertManual(db, 'parent', 'Parent') + + await addListEntries(db, 'child', { values: ['1.1.1.1'] }) + await addListEntries(db, 'parent', { + items: [{ kind: 'list', value: 'child' }], + }) + + expect( + repos.listIpListEntries(db, 'parent').map((e) => e.cidr), + ).toContain('1.1.1.1/32') + + await addListEntries(db, 'child', { values: ['9.9.9.9'] }) + await rebuildListCascade(db, 'child') + + const parentCidrs = repos.listIpListEntries(db, 'parent').map((e) => e.cidr) + expect(parentCidrs).toContain('1.1.1.1/32') + expect(parentCidrs).toContain('9.9.9.9/32') + + await deleteListEntry(db, 'child', '1.1.1.1') + const afterDelete = repos + .listIpListEntries(db, 'parent') + .map((e) => e.cidr) + expect(afterDelete).not.toContain('1.1.1.1/32') + expect(afterDelete).toContain('9.9.9.9/32') + }) +}) diff --git a/apps/api/src/services/lists/entries.ts b/apps/api/src/services/lists/entries.ts index 92d8011..cd4c499 100644 --- a/apps/api/src/services/lists/entries.ts +++ b/apps/api/src/services/lists/entries.ts @@ -2,11 +2,13 @@ import type { Db } from '@evofw/db' import { repos } from '@evofw/db' import { isManualListType, + listNestedChildIds, normalizeItemToCidrs, parseListEntry, readManualItems, splitListPlaintext, type ListConfigItem, + type ListEntryInput, } from '@evofw/shared' import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js' @@ -24,11 +26,67 @@ export function getListConfig(list: { } } -async function expandItem(item: ListConfigItem): Promise { +/** True if adding parent→child would create a cycle. */ +export function wouldCreateListCycle( + db: Db, + parentId: string, + childId: string, +): boolean { + if (parentId === childId) return true + const stack = [childId] + const seen = new Set() + while (stack.length) { + const id = stack.pop()! + if (id === parentId) return true + if (seen.has(id)) continue + seen.add(id) + const list = repos.getIpList(db, id) + if (!list || !isManualListType(list.type)) continue + for (const nested of listNestedChildIds(list.configJson)) { + stack.push(nested) + } + } + return false +} + +/** Manual lists that reference childId via kind=list. */ +export function findParentListIds(db: Db, childId: string): string[] { + const parents: string[] = [] + for (const list of repos.listIpLists(db)) { + if (!isManualListType(list.type)) continue + if (list.id === childId) continue + if (listNestedChildIds(list.configJson).includes(childId)) { + parents.push(list.id) + } + } + return parents +} + +async function expandItem( + db: Db, + item: ListConfigItem, + visiting: Set, +): Promise { if (item.kind === 'hostname') { if (item.resolved_cidrs?.length) return item.resolved_cidrs return resolveHostnameToCidrs(item.value) } + if (item.kind === 'list') { + const childId = item.value + if (visiting.has(childId)) { + throw new Error(`Циклическая ссылка списков: ${childId}`) + } + const child = repos.getIpList(db, childId) + if (!child) { + throw new Error(`Вложенный список не найден: ${childId}`) + } + // Prefer materialized CIDRs; for manual children rebuild if empty. + let entries = repos.listIpListEntries(db, childId).map((e) => e.cidr) + if (entries.length === 0 && isManualListType(child.type)) { + entries = await rebuildManualListEntries(db, childId, visiting) + } + return entries + } return normalizeItemToCidrs(item) } @@ -36,42 +94,93 @@ async function expandItem(item: ListConfigItem): Promise { export async function rebuildManualListEntries( db: Db, listId: string, + visiting: Set = new Set(), ): Promise { const list = repos.getIpList(db, listId) if (!list || !isManualListType(list.type)) return [] - const config = getListConfig(list) - const items = readManualItems(list.configJson) - const nextItems: ListConfigItem[] = [] - const all: string[] = [] + if (visiting.has(listId)) { + throw new Error(`Циклическая ссылка списков: ${listId}`) + } + visiting.add(listId) - for (const item of items) { - try { - const cidrs = await expandItem({ - ...item, - resolved_cidrs: undefined, - }) - nextItems.push({ ...item, resolved_cidrs: cidrs }) - all.push(...cidrs) - } catch { - nextItems.push({ ...item, resolved_cidrs: item.resolved_cidrs ?? [] }) - if (item.resolved_cidrs?.length) all.push(...item.resolved_cidrs) + try { + const config = getListConfig(list) + const items = readManualItems(list.configJson) + const nextItems: ListConfigItem[] = [] + const all: string[] = [] + + for (const item of items) { + try { + const cidrs = await expandItem( + db, + { ...item, resolved_cidrs: undefined }, + visiting, + ) + nextItems.push({ ...item, resolved_cidrs: cidrs }) + all.push(...cidrs) + } catch { + nextItems.push({ ...item, resolved_cidrs: item.resolved_cidrs ?? [] }) + if (item.resolved_cidrs?.length) all.push(...item.resolved_cidrs) + } + } + + config.items = nextItems + delete config.domains + repos.updateIpList(db, listId, { configJson: JSON.stringify(config) }) + + const cidrs = uniq(all) + repos.replaceIpListEntries(db, listId, cidrs) + return cidrs + } finally { + visiting.delete(listId) + } +} + +/** Rebuild list and all ancestor lists that nest it; bump agents. */ +export async function rebuildListCascade( + db: Db, + listId: string, +): Promise { + const affected: string[] = [] + const queue = [listId] + const seen = new Set() + + while (queue.length) { + const id = queue.shift()! + if (seen.has(id)) continue + seen.add(id) + const list = repos.getIpList(db, id) + if (!list) continue + if (isManualListType(list.type)) { + await rebuildManualListEntries(db, id) + } + affected.push(id) + for (const parentId of findParentListIds(db, id)) { + if (!seen.has(parentId)) queue.push(parentId) } } - config.items = nextItems - delete config.domains - repos.updateIpList(db, listId, { configJson: JSON.stringify(config) }) + for (const id of affected) { + repos.bumpAgentsForList(db, id) + } + return affected +} - const cidrs = uniq(all) - repos.replaceIpListEntries(db, listId, cidrs) - return cidrs +function normalizeInputItem(input: ListEntryInput): ListConfigItem { + if (input.kind === 'list') { + return { kind: 'list', value: input.value.trim() } + } + if (input.kind === 'hostname') { + return { kind: 'hostname', value: input.value.trim().toLowerCase() } + } + return { kind: input.kind, value: input.value.trim() } } export async function addListEntries( db: Db, listId: string, - values: string[], + input: { values?: string[]; items?: ListEntryInput[] }, ): Promise<{ items: ListConfigItem[]; entries: string[] }> { const list = repos.getIpList(db, listId) if (!list) throw new Error('List not found') @@ -81,16 +190,37 @@ export async function addListEntries( const config = getListConfig(list) let items = readManualItems(list.configJson) - const existing = new Set(items.map((i) => i.value.toLowerCase())) + const existing = new Set(items.map((i) => `${i.kind}:${i.value.toLowerCase()}`)) + + const toAdd: ListConfigItem[] = [] + + for (const token of (input.values ?? []).flatMap((v) => splitListPlaintext(v))) { + toAdd.push(parseListEntry(token)) + } + for (const raw of input.items ?? []) { + toAdd.push(normalizeInputItem(raw)) + } - const tokens = values.flatMap((v) => splitListPlaintext(v)) const added: ListConfigItem[] = [] - for (const token of tokens) { - const parsed = parseListEntry(token) - const key = parsed.value.toLowerCase() + for (const parsed of toAdd) { + if (parsed.kind === 'list') { + if (parsed.value === listId) { + throw new Error('Список не может ссылаться на себя') + } + const child = repos.getIpList(db, parsed.value) + if (!child) { + throw new Error(`Список не найден: ${parsed.value}`) + } + if (wouldCreateListCycle(db, listId, parsed.value)) { + throw new Error('Добавление создаст циклическую ссылку списков') + } + } + + const key = `${parsed.kind}:${parsed.value.toLowerCase()}` if (existing.has(key)) continue - const resolved = await expandItem(parsed) + + const resolved = await expandItem(db, parsed, new Set([listId])) const item: ListConfigItem = { ...parsed, resolved_cidrs: resolved } items.push(item) existing.add(key) @@ -110,16 +240,15 @@ export async function addListEntries( configJson: JSON.stringify(config), }) - const entries = await rebuildManualListEntries(db, listId) + await rebuildListCascade(db, listId) repos.updateIpList(db, listId, { refreshedAt: new Date().toISOString(), lastError: null, }) - repos.bumpAgentsForList(db, listId) return { items: readManualItems(repos.getIpList(db, listId)!.configJson), - entries, + entries: repos.listIpListEntries(db, listId).map((e) => e.cidr), } } @@ -152,16 +281,15 @@ export async function deleteListEntry( configJson: JSON.stringify(config), }) - const entries = await rebuildManualListEntries(db, listId) + await rebuildListCascade(db, listId) repos.updateIpList(db, listId, { refreshedAt: new Date().toISOString(), lastError: null, }) - repos.bumpAgentsForList(db, listId) return { items: readManualItems(repos.getIpList(db, listId)!.configJson), - entries, + entries: repos.listIpListEntries(db, listId).map((e) => e.cidr), } } @@ -177,9 +305,12 @@ export function mapListDetail(db: Db, listId: string) { item.resolved_cidrs?.length ? item.resolved_cidrs : normalizeItemToCidrs(item) + const child = + item.kind === 'list' ? repos.getIpList(db, item.value) : null return { kind: item.kind, value: item.value, + list_name: child?.name ?? null, resolved_count: resolved.length, resolved_cidrs: resolved, } @@ -187,6 +318,7 @@ export function mapListDetail(db: Db, listId: string) { : entries.map((cidr) => ({ kind: 'cidr' as const, value: cidr, + list_name: null as string | null, resolved_count: 1, resolved_cidrs: [cidr], })) diff --git a/apps/api/src/services/lists/refresh.ts b/apps/api/src/services/lists/refresh.ts index 2eb3dd1..92af454 100644 --- a/apps/api/src/services/lists/refresh.ts +++ b/apps/api/src/services/lists/refresh.ts @@ -2,7 +2,11 @@ import { createHash } from 'node:crypto' import type { Db } from '@evofw/db' import { repos } from '@evofw/db' import { refreshAllHostnameRules } from '../policy/resolve-hostname.js' -import { rebuildManualListEntries } from './entries.js' +import { + findParentListIds, + rebuildListCascade, + rebuildManualListEntries, +} from './entries.js' function uniq(cidrs: string[]): string[] { return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort() @@ -126,7 +130,16 @@ export async function refreshIpList(db: Db, listId: string): Promise { lastError: null, }) - repos.bumpAgentsForList(db, listId) + // Cascade to parents that nest this list (skip self rebuild for non-manual — + // CIDRs already replaced above). + if (list.type === 'static' || list.type === 'domains') { + await rebuildListCascade(db, listId) + } else { + repos.bumpAgentsForList(db, listId) + for (const parentId of findParentListIds(db, listId)) { + await rebuildListCascade(db, parentId) + } + } } catch (err) { repos.updateIpList(db, listId, { lastError: err instanceof Error ? err.message : String(err), diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index f44103c..89fa1f5 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -27,7 +27,7 @@ const overviewNav = [ const opsNav = [ { to: '/agents', label: 'Агенты', icon: ServerIcon, exact: false }, - { to: '/lists', label: 'Списки IP', icon: ListIcon, exact: false }, + { to: '/lists', label: 'Списки', icon: ListIcon, exact: false }, { to: '/rules', label: 'Наборы правил', icon: ShieldIcon, exact: false }, { to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false }, ] as const diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index bbd6aa4..93ec5bd 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -21,7 +21,7 @@ export interface RouteBreadcrumbLoaderData { const routeTitles: Record = { '/': 'Панель управления', '/agents': 'Агенты', - '/lists': 'Списки IP', + '/lists': 'Списки', '/rules': 'Наборы правил', '/stats': 'Статистика', '/settings': 'Настройки', diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index 96c9c37..bb2b402 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -77,6 +77,7 @@ export interface ResourcePageProps { }) => ReactNode toolbarExtra?: ReactNode hideHeader?: boolean + onRowClick?: (row: T) => void } function ResourcePageSkeleton() { @@ -124,6 +125,7 @@ export function ResourcePage({ selectionToolbar, toolbarExtra, hideHeader = false, + onRowClick, }: ResourcePageProps) { const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all') const activeTab = controlledTab ?? internalTab @@ -287,6 +289,7 @@ export function ResourcePage({ recordCount={filteredData.length} emptyMessage={emptyMessage} tableLayout={{ dense: true }} + onRowClick={onRowClick} > {!hideHeader ? ( diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx index 3b6bc34..f0fc7c2 100644 --- a/apps/web/src/components/status-badge.tsx +++ b/apps/web/src/components/status-badge.tsx @@ -33,6 +33,7 @@ const STATUS_VARIANT: Record = { ip: 'info-light', cidr: 'secondary', hostname: 'warning-light', + list: 'info-light', unknown: 'outline', } @@ -75,6 +76,7 @@ const STATUS_LABELS: Record = { ip: 'IP', cidr: 'CIDR', hostname: 'Домен', + list: 'Список', unknown: 'Неизвестно', } diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 41aa521..2f9b6f5 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -46,8 +46,9 @@ export const listQueryOptions = (id: string) => last_error?: string | null entries: string[] items: { - kind: 'ip' | 'cidr' | 'hostname' + kind: 'ip' | 'cidr' | 'hostname' | 'list' value: string + list_name?: string | null resolved_count: number resolved_cidrs: string[] }[] diff --git a/apps/web/src/routes/_auth/lists/$id.tsx b/apps/web/src/routes/_auth/lists/$id.tsx index 378d019..67f068a 100644 --- a/apps/web/src/routes/_auth/lists/$id.tsx +++ b/apps/web/src/routes/_auth/lists/$id.tsx @@ -1,378 +1,10 @@ -import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' -import { HashIcon, RefreshCwIcon, TagIcon, Trash2 } from 'lucide-react' -import { useMemo, useState } from 'react' -import type { ColumnDef } from '@tanstack/react-table' -import type { Filter, FilterFieldConfig } from '@/components/reui/filters' -import { PageShell, DetailPanel, 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 { 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 { 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 } from '@evofw/shared' +import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_auth/lists/$id')({ - component: ListDetailPage, + beforeLoad: ({ params }) => { + throw redirect({ + to: '/lists', + search: { listId: params.id }, + }) + }, }) - -type ListItem = { - kind: 'ip' | 'cidr' | 'hostname' - value: string - resolved_count: number - resolved_cidrs: string[] -} - -/** - * List detail — tabular entries + switcher. - * Preview: https://reui.io/preview/base/data-grid-filtering-2 · sheet-8 · empty-state-12 - */ -function ListDetailPage() { - const { id } = Route.useParams() - const navigate = useNavigate() - const qc = useQueryClient() - const listQ = useQuery(listQueryOptions(id)) - const listsQ = useQuery(listsQueryOptions()) - const [filters, setFilters] = useState([]) - const [addOpen, setAddOpen] = useState(false) - const [plaintext, setPlaintext] = useState('') - const [deleteValue, setDeleteValue] = useState(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 addEntries = useMutation({ - mutationFn: () => - apiFetch(`/api/v1/lists/${id}/entries`, { - method: 'POST', - body: JSON.stringify({ values: [plaintext] }), - }), - onSuccess: () => { - toast.success('Добавлено') - setPlaintext('') - 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 list = listQ.data - const manual = list ? isManualListType(list.type) : false - const items: ListItem[] = list?.items ?? [] - - const filterFields: 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: 'Домен' }, - ], - }, - ], - [], - ) - - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: 'value', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - 0 - ? `${row.original.resolved_count} CIDR` - : undefined - } - /> - ), - }, - { - accessorKey: 'kind', - header: ({ column }) => ( - - ), - cell: ({ row }) => , - }, - { - id: 'actions', - enableSorting: false, - header: () => Действия, - cell: ({ row }) => - manual ? ( -
- -
- ) : null, - }, - ], - [manual], - ) - - if (listQ.isLoading) { - return ( - - - - - ) - } - - if (!list) { - return ( - - - }> - К спискам - - } - /> - - - ) - } - - return ( - - - - - - - {manual ? ( - - ) : null} - - - } - /> - - , - label: 'Записей', - description: String(items.length), - }, - { - id: 'type', - icon: , - label: 'Источник', - description: - list.type === 'json_url' - ? 'JSON по URL' - : list.type === 'evobgp_community' - ? 'EvoBGP community' - : 'Ручной', - }, - { - id: 'cidrs', - icon: , - label: 'CIDR в политике', - description: String(list.entry_count ?? list.entries.length), - }, - ]} - /> - - - r.value} - filterFields={filterFields} - filters={filters} - onFiltersChange={setFilters} - onClearFilters={() => setFilters([])} - getFilterFieldValue={(item, field) => { - if (field === 'value') return item.value - if (field === 'kind') return item.kind - return undefined - }} - emptyState={{ - title: 'Нет записей', - description: manual - ? 'Добавьте IP, CIDR или домен — по одной строке или списком.' - : 'Нажмите Refresh или проверьте источник.', - action: manual ? ( - - ) : undefined, - }} - /> - - - {list.last_error ? ( -

{list.last_error}

- ) : null} -
- - { - if (!open) setDeleteValue(null) - }} - title="Удалить запись?" - description={ - deleteValue - ? `Будет удалено: ${deleteValue}` - : 'Запись будет удалена из списка.' - } - onConfirm={() => { - if (deleteValue) removeEntry.mutate(deleteValue) - }} - disabled={removeEntry.isPending} - /> - - - - - Добавить записи - - Вставьте IP, CIDR или домены — система определит вид сама. - Несколько строк через перевод строки или пробел. - - -
- - Записи -