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 <[email protected]>
This commit is contained in:
@@ -48,6 +48,13 @@ function getBreadcrumbs(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.match(/^\/lists\/[^/]+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Списки', href: '/lists' },
|
||||||
|
{ label: dynamicLabels[pathname] ?? 'Список', href: pathname },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
const title = routeTitles[pathname]
|
const title = routeTitles[pathname]
|
||||||
if (title) {
|
if (title) {
|
||||||
return [{ label: title, href: pathname }]
|
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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
<Toaster />
|
<Toaster richColors position="top-right" />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -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')({
|
export const Route = createFileRoute('/_auth/lists/$id')({
|
||||||
beforeLoad: ({ params }) => {
|
loader: async ({ context: { queryClient }, params }) => {
|
||||||
throw redirect({
|
const detail = await queryClient.ensureQueryData(
|
||||||
to: '/lists',
|
listQueryOptions(params.id),
|
||||||
search: { listId: 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,41 +1,24 @@
|
|||||||
import {
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
createFileRoute,
|
|
||||||
useNavigate,
|
|
||||||
} from '@tanstack/react-router'
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import { RefreshCwIcon } from 'lucide-react'
|
||||||
ArrowLeftIcon,
|
|
||||||
HashIcon,
|
|
||||||
RefreshCwIcon,
|
|
||||||
TagIcon,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import { z } from 'zod'
|
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
|
||||||
import {
|
import {
|
||||||
DetailPanel,
|
LIST_TABS,
|
||||||
PageHeader,
|
createListColumns,
|
||||||
PageShell,
|
listFilterFieldValue,
|
||||||
ResourcePage,
|
listFilterFields,
|
||||||
} from '@/components/reui-kit'
|
listTabFilter,
|
||||||
import { ListsCatalog } from '@/components/lists/lists-catalog'
|
} from '@/components/lists/lists-columns'
|
||||||
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 { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { listQueryOptions, listsQueryOptions } from '@/queries'
|
import { listsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||||
import { Textarea } from '@evofw/ui/components/textarea'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -51,74 +34,33 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@evofw/ui/components/sheet'
|
} from '@evofw/ui/components/sheet'
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { guessListSourceFromInput } from '@evofw/shared'
|
||||||
import { cn } from '@evofw/ui/lib/utils'
|
|
||||||
import {
|
|
||||||
guessListSourceFromInput,
|
|
||||||
isManualListType,
|
|
||||||
type ListEntryKind,
|
|
||||||
} from '@evofw/shared'
|
|
||||||
|
|
||||||
const listsSearchSchema = z.object({
|
|
||||||
listId: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/lists/')({
|
export const Route = createFileRoute('/_auth/lists/')({
|
||||||
validateSearch: (search) => listsSearchSchema.parse(search),
|
|
||||||
component: ListsPage,
|
component: ListsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
|
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).
|
* Lists catalog — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||||
* Catalog: https://reui.io/preview/base/list-9
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* 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
|
|
||||||
* Empty: https://reui.io/preview/base/empty-state-12
|
* 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() {
|
function ListsPage() {
|
||||||
const navigate = useNavigate({ from: Route.fullPath })
|
const navigate = useNavigate()
|
||||||
const { listId } = Route.useSearch()
|
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const listsQ = useQuery(listsQueryOptions())
|
const listsQ = useQuery(listsQueryOptions())
|
||||||
const listQ = useQuery({
|
|
||||||
...listQueryOptions(listId ?? ''),
|
|
||||||
enabled: Boolean(listId),
|
|
||||||
})
|
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [source, setSource] = useState<CreateSource>('static')
|
const [source, setSource] = useState<CreateSource>('static')
|
||||||
const [extra, setExtra] = useState('')
|
const [extra, setExtra] = useState('')
|
||||||
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const [addKind, setAddKind] = useState<ListEntryKind>('ip')
|
|
||||||
const [addValue, setAddValue] = useState('')
|
|
||||||
const [addListRef, setAddListRef] = useState('')
|
|
||||||
|
|
||||||
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
|
|
||||||
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
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({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -140,7 +82,7 @@ function ListsPage() {
|
|||||||
setSource('static')
|
setSource('static')
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||||
selectList(row.id)
|
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
@@ -158,378 +100,90 @@ function ListsPage() {
|
|||||||
const removeList = useMutation({
|
const removeList = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
|
apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
|
||||||
onSuccess: (_data, id) => {
|
onSuccess: () => {
|
||||||
toast.success('Удалён')
|
toast.success('Удалён')
|
||||||
setDeleteListId(null)
|
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'] })
|
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const items = listsQ.data?.items ?? []
|
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(
|
const columns = useMemo(
|
||||||
() => [
|
() =>
|
||||||
{
|
createListColumns({
|
||||||
key: 'value',
|
onRefresh: (id) => refresh.mutate(id),
|
||||||
label: 'Значение',
|
onDelete: setDeleteListId,
|
||||||
type: 'text',
|
refreshPending: refresh.isPending,
|
||||||
placeholder: 'Поиск…',
|
}),
|
||||||
},
|
[refresh.isPending],
|
||||||
{
|
|
||||||
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) => {
|
const getFilterFieldValue = useCallback(listFilterFieldValue, [])
|
||||||
if (field === 'value') {
|
const tabFilter = useCallback(listTabFilter, [])
|
||||||
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 canCreate =
|
const canCreate =
|
||||||
Boolean(name.trim()) &&
|
Boolean(name.trim()) &&
|
||||||
(source === 'static' || Boolean(extra.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 (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Списки"
|
title="Списки"
|
||||||
description="Создайте список и заполните его IP, CIDR, доменами или другими списками"
|
description="Маршрутные списки: IP, CIDR, домены, JSON и EvoBGP community"
|
||||||
actions={
|
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">
|
<ResourcePage
|
||||||
<div
|
title="Все списки"
|
||||||
className={cn(
|
hideHeader
|
||||||
'min-w-0',
|
data={items}
|
||||||
showCatalogOnMobile ? 'block' : 'hidden lg:block',
|
columns={columns}
|
||||||
)}
|
getRowId={(r) => r.id}
|
||||||
>
|
filterFields={listFilterFields}
|
||||||
<ListsCatalog
|
filters={filters}
|
||||||
items={items}
|
onFiltersChange={setFilters}
|
||||||
selectedId={listId}
|
onClearFilters={() => setFilters([])}
|
||||||
isLoading={listsQ.isLoading}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
isError={listsQ.isError}
|
tabs={[...LIST_TABS]}
|
||||||
error={listsQ.error}
|
activeTab={activeTab}
|
||||||
onRetry={() => void listsQ.refetch()}
|
onTabChange={setActiveTab}
|
||||||
onSelect={selectList}
|
tabFilter={tabFilter}
|
||||||
onCreate={() => setCreateOpen(true)}
|
isLoading={listsQ.isLoading}
|
||||||
onRefresh={(id) => refresh.mutate(id)}
|
isError={listsQ.isError}
|
||||||
onDelete={setDeleteListId}
|
error={listsQ.error}
|
||||||
refreshPending={refresh.isPending}
|
onRetry={() => void listsQ.refetch()}
|
||||||
/>
|
onRowClick={(row) =>
|
||||||
</div>
|
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
||||||
|
}
|
||||||
<div
|
emptyState={{
|
||||||
className={cn(
|
title: 'Нет списков',
|
||||||
'min-w-0',
|
description: 'Создайте первый список для политики firewall.',
|
||||||
showDetailOnMobile ? 'block' : 'hidden lg:block',
|
action: (
|
||||||
)}
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
>
|
Создать
|
||||||
{!listId ? (
|
</Button>
|
||||||
<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>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteListId !== null}
|
open={deleteListId !== null}
|
||||||
@@ -544,30 +198,12 @@ function ListsPage() {
|
|||||||
disabled={removeList.isPending}
|
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}>
|
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
<SheetHeader className="shrink-0">
|
<SheetHeader className="shrink-0">
|
||||||
<SheetTitle>Новый список</SheetTitle>
|
<SheetTitle>Новый список</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
После создания заполните содержимое в таблице справа.
|
После создания откроется страница со содержимым списка.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<ScrollArea className="flex-1 px-4">
|
<ScrollArea className="flex-1 px-4">
|
||||||
@@ -642,91 +278,6 @@ function ListsPage() {
|
|||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</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>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user