From 8aa265b1c8208991b560abed770fcbb24fa64d45 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 21 Jul 2026 00:50:16 +0700 Subject: [PATCH] feat(web): enhance lists UI and functionality - 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 --- .../web/src/components/layout/site-header.tsx | 7 + .../src/components/lists/lists-catalog.tsx | 262 -------- .../src/components/lists/lists-columns.tsx | 165 +++++ apps/web/src/main.tsx | 2 +- apps/web/src/routes/_auth/lists/$id.tsx | 572 +++++++++++++++- apps/web/src/routes/_auth/lists/index.tsx | 611 +++--------------- 6 files changed, 820 insertions(+), 799 deletions(-) delete mode 100644 apps/web/src/components/lists/lists-catalog.tsx create mode 100644 apps/web/src/components/lists/lists-columns.tsx diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index 549c968..fba90d0 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -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 }] diff --git a/apps/web/src/components/lists/lists-catalog.tsx b/apps/web/src/components/lists/lists-catalog.tsx deleted file mode 100644 index 229e866..0000000 --- a/apps/web/src/components/lists/lists-catalog.tsx +++ /dev/null @@ -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 ( - - - - - - {Array.from({ length: 4 }).map((_, i) => ( - - ))} - - - ) - } - - if (isError) { - return ( - - -

- {error?.message ?? 'Не удалось загрузить списки'} -

- {onRetry ? ( - - ) : null} -
- - ) - } - - return ( - - - Каталог - - {filtered.length} - - - -
- -
- -
-
- - setQuery(e.target.value)} - placeholder="Поиск…" - className="h-8 pl-8" - aria-label="Поиск списков" - /> -
-
- - {items.length === 0 ? ( -
- - Новый список - - } - /> -
- ) : filtered.length === 0 ? ( -

- Нет совпадений -

- ) : ( - - {filtered.map((list) => { - const selected = selectedId === list.id - const Icon = isManualListType(list.type) - ? ListIcon - : list.type === 'json_url' - ? LinkIcon - : GlobeIcon - return ( - onSelect(list.id)} - > - - - - - - {list.name} - - - - {ipListSourceLabel(list.type)} ·{' '} - - {list.entry_count ?? 0} - {' '} - CIDR - - - e.stopPropagation()} - className="gap-0.5" - > - {!isManualListType(list.type) ? ( - - ) : null} - - - - ) - })} - - )} -
- - ) -} diff --git a/apps/web/src/components/lists/lists-columns.tsx b/apps/web/src/components/lists/lists-columns.tsx new file mode 100644 index 0000000..3c99d98 --- /dev/null +++ b/apps/web/src/components/lists/lists-columns.tsx @@ -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[] { + return [ + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + + + + ), + meta: { headerTitle: 'Список' }, + }, + { + accessorKey: 'type', + header: ({ column }) => ( + + ), + cell: ({ row }) => , + meta: { headerTitle: 'Источник' }, + }, + { + id: 'entries', + accessorFn: (row) => row.entry_count ?? 0, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.entry_count ?? 0} + ), + meta: { headerTitle: 'Записей' }, + }, + { + accessorKey: 'updated_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.updated_at + ? new Date(row.original.updated_at).toLocaleString('ru-RU') + : '—'} + + ), + meta: { headerTitle: 'Обновлено' }, + }, + { + id: 'actions', + enableSorting: false, + header: () => Действия, + cell: ({ row }) => ( +
+ + + +
+ ), + }, + ] +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 11a291e..2f90648 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -31,7 +31,7 @@ createRoot(document.getElementById('root')!).render( - + diff --git a/apps/web/src/routes/_auth/lists/$id.tsx b/apps/web/src/routes/_auth/lists/$id.tsx index 67f068a..9bacfff 100644 --- a/apps/web/src/routes/_auth/lists/$id.tsx +++ b/apps/web/src/routes/_auth/lists/$id.tsx @@ -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('ip') + const [addValue, setAddValue] = useState('') + const [addListRef, setAddListRef] = useState('') + const [entryFilters, setEntryFilters] = useState([]) + const [deleteListOpen, setDeleteListOpen] = useState(false) + 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 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[] = useMemo( + () => [ + { + accessorKey: 'value', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + 0 + ? `${row.original.resolved_count} CIDR` + : undefined + } + /> + ), + }, + { + accessorKey: 'kind', + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + id: 'resolved', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.resolved_count > 0 + ? String(row.original.resolved_count) + : '—'} + + ), + }, + { + id: 'actions', + enableSorting: false, + header: () => Действия, + cell: ({ row }) => + manual ? ( +
+ +
+ ) : null, + }, + ], + [manual], + ) + + const canAdd = + addKind === 'list' ? Boolean(addListRef) : Boolean(addValue.trim()) + + if (listQ.isLoading) { + return ( + + +
+ + + +
+
+ ) + } + + if (!detail) { + return ( + + } + > + К спискам + + } + /> + + ) + } + + return ( + + + + + {manual ? ( + + ) : null} + + + } + /> + + + } + /> + + , + iconClassName: 'text-info', + label: 'Записей', + description: String(entryItems.length), + hint: manual ? 'ручной список' : 'внешний источник', + }, + { + id: 'type', + icon: , + 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: , + iconClassName: 'text-success', + label: 'CIDR в политике', + description: String( + detail.entry_count ?? detail.entries.length, + ), + hint: 'materialized', + }, + ]} + /> + + {detail.last_error ? ( + + + Ошибка обновления + {detail.last_error} + + ) : null} + + + `${r.kind}:${r.value}`} + filterFields={entryFilterFields} + filters={entryFilters} + onFiltersChange={setEntryFilters} + onClearFilters={() => setEntryFilters([])} + getFilterFieldValue={getEntryFilterValue} + emptyState={{ + title: 'Нет записей', + description: manual + ? 'Добавьте IP, CIDR, домен или другой список.' + : 'Нажмите Обновить или проверьте источник.', + action: manual ? ( + + ) : undefined, + }} + /> + + + + removeList.mutate()} + disabled={removeList.isPending} + /> + + { + if (!open) setDeleteValue(null) + }} + title="Удалить запись?" + description={ + deleteValue + ? `Будет удалено: ${deleteValue}` + : 'Запись будет удалена из списка.' + } + onConfirm={() => { + if (deleteValue) removeEntry.mutate(deleteValue) + }} + disabled={removeEntry.isPending} + /> + + + + + Добавить запись + + Выберите вид и значение. Для IP/CIDR/доменов можно вставить + несколько строк сразу. + + + +
+ + Вид + + + {addKind === 'list' ? ( + + Список + + + ) : ( + + Значение +