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 <[email protected]>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface RouteBreadcrumbLoaderData {
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'Панель управления',
|
||||
'/agents': 'Агенты',
|
||||
'/lists': 'Списки IP',
|
||||
'/lists': 'Списки',
|
||||
'/rules': 'Наборы правил',
|
||||
'/stats': 'Статистика',
|
||||
'/settings': 'Настройки',
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface ResourcePageProps<T extends object> {
|
||||
}) => ReactNode
|
||||
toolbarExtra?: ReactNode
|
||||
hideHeader?: boolean
|
||||
onRowClick?: (row: T) => void
|
||||
}
|
||||
|
||||
function ResourcePageSkeleton() {
|
||||
@@ -124,6 +125,7 @@ export function ResourcePage<T extends object>({
|
||||
selectionToolbar,
|
||||
toolbarExtra,
|
||||
hideHeader = false,
|
||||
onRowClick,
|
||||
}: ResourcePageProps<T>) {
|
||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||
const activeTab = controlledTab ?? internalTab
|
||||
@@ -287,6 +289,7 @@ export function ResourcePage<T extends object>({
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={{ dense: true }}
|
||||
onRowClick={onRowClick}
|
||||
>
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
|
||||
@@ -33,6 +33,7 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
ip: 'info-light',
|
||||
cidr: 'secondary',
|
||||
hostname: 'warning-light',
|
||||
list: 'info-light',
|
||||
unknown: 'outline',
|
||||
}
|
||||
|
||||
@@ -75,6 +76,7 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
ip: 'IP',
|
||||
cidr: 'CIDR',
|
||||
hostname: 'Домен',
|
||||
list: 'Список',
|
||||
unknown: 'Неизвестно',
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}[]
|
||||
|
||||
@@ -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<Filter[]>([])
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [plaintext, setPlaintext] = useState('')
|
||||
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 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<ListItem>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'value',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Значение" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataGridPrimaryCell
|
||||
accent="mono"
|
||||
title={row.original.value}
|
||||
subtitle={
|
||||
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: '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],
|
||||
)
|
||||
|
||||
if (listQ.isLoading) {
|
||||
return (
|
||||
<PageShell>
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!list) {
|
||||
return (
|
||||
<PageShell>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Список не найден"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" render={<Link to="/lists" />}>
|
||||
К спискам
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</DetailPanel>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={list.name}
|
||||
description="Содержимое списка для правил политики"
|
||||
actions={
|
||||
<>
|
||||
<Select
|
||||
value={id}
|
||||
onValueChange={(v) => {
|
||||
if (v && v !== id) {
|
||||
void navigate({ to: '/lists/$id', params: { id: v } })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]" size="sm">
|
||||
<SelectValue placeholder="Список" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(listsQ.data?.items ?? []).map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<StatusBadge status={list.type} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={refresh.isPending}
|
||||
onClick={() => refresh.mutate()}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={
|
||||
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
|
||||
}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
{manual ? (
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
Добавить
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" render={<Link to="/lists" />}>
|
||||
Назад
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'count',
|
||||
icon: <HashIcon aria-hidden />,
|
||||
label: 'Записей',
|
||||
description: String(items.length),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
icon: <TagIcon aria-hidden />,
|
||||
label: 'Источник',
|
||||
description:
|
||||
list.type === 'json_url'
|
||||
? 'JSON по URL'
|
||||
: list.type === 'evobgp_community'
|
||||
? 'EvoBGP community'
|
||||
: 'Ручной',
|
||||
},
|
||||
{
|
||||
id: 'cidrs',
|
||||
icon: <RefreshCwIcon aria-hidden />,
|
||||
label: 'CIDR в политике',
|
||||
description: String(list.entry_count ?? list.entries.length),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DetailPanel.Section
|
||||
title="Содержимое"
|
||||
description={
|
||||
manual
|
||||
? 'IP, CIDR и домены в одном списке — вид определяется автоматически.'
|
||||
: 'Записи из внешнего источника (только чтение).'
|
||||
}
|
||||
>
|
||||
<ResourcePage
|
||||
title="Entries"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={columns}
|
||||
getRowId={(r) => 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 ? (
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
Добавить
|
||||
</Button>
|
||||
) : undefined,
|
||||
}}
|
||||
/>
|
||||
</DetailPanel.Section>
|
||||
|
||||
{list.last_error ? (
|
||||
<p className="text-destructive text-sm">{list.last_error}</p>
|
||||
) : null}
|
||||
</DetailPanel>
|
||||
|
||||
<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>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="entries-plain">Записи</FieldLabel>
|
||||
<Textarea
|
||||
id="entries-plain"
|
||||
rows={6}
|
||||
value={plaintext}
|
||||
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
|
||||
onChange={(e) => setPlaintext(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setAddOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!plaintext.trim() || addEntries.isPending}
|
||||
onClick={() => addEntries.mutate()}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
createFileRoute,
|
||||
useNavigate,
|
||||
} from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
HashIcon,
|
||||
RefreshCwIcon,
|
||||
TagIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { z } from 'zod'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import {
|
||||
DetailPanel,
|
||||
PageHeader,
|
||||
PageShell,
|
||||
ResourcePage,
|
||||
} from '@/components/reui-kit'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
DataGridMutedCell,
|
||||
@@ -13,11 +28,12 @@ import {
|
||||
} from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { listsQueryOptions } from '@/queries'
|
||||
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 { Input } from '@evofw/ui/components/input'
|
||||
import { Textarea } from '@evofw/ui/components/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -33,35 +49,75 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import {
|
||||
guessListSourceFromInput,
|
||||
isManualListType,
|
||||
type IpList,
|
||||
type ListEntryKind,
|
||||
} from '@evofw/shared'
|
||||
|
||||
const listsSearchSchema = z.object({
|
||||
listId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_auth/lists/')({
|
||||
validateSearch: (search) => listsSearchSchema.parse(search),
|
||||
component: ListsPage,
|
||||
})
|
||||
|
||||
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
|
||||
|
||||
type ListItem = {
|
||||
kind: ListEntryKind
|
||||
value: string
|
||||
list_name?: string | null
|
||||
resolved_count: number
|
||||
resolved_cidrs: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* IP lists catalog — ResourcePage.
|
||||
* Lists — master-detail.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Sheet: https://reui.io/preview/base/sheet-8 · form-7
|
||||
* Empty: https://reui.io/preview/base/empty-state-12
|
||||
* Sheet: https://reui.io/preview/base/sheet-8
|
||||
*/
|
||||
function ListsPage() {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const { listId } = Route.useSearch()
|
||||
const qc = useQueryClient()
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const listQ = useQuery({
|
||||
...listQueryOptions(listId ?? ''),
|
||||
enabled: Boolean(listId),
|
||||
})
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [source, setSource] = useState<CreateSource>('static')
|
||||
const [extra, setExtra] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [addKind, setAddKind] = useState<ListEntryKind>('ip')
|
||||
const [addValue, setAddValue] = useState('')
|
||||
const [addListRef, setAddListRef] = useState('')
|
||||
|
||||
const [catalogFilters, setCatalogFilters] = useState<Filter[]>([])
|
||||
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [deleteId, setDeleteId] = 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({
|
||||
mutationFn: async () => {
|
||||
@@ -81,9 +137,9 @@ function ListsPage() {
|
||||
setName('')
|
||||
setExtra('')
|
||||
setSource('static')
|
||||
setSheetOpen(false)
|
||||
setCreateOpen(false)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
||||
selectList(row.id)
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -95,21 +151,83 @@ function ListsPage() {
|
||||
toast.success('Обновлено')
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
const removeList = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_data, id) => {
|
||||
toast.success('Удалён')
|
||||
setDeleteId(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('Введите значение')
|
||||
// Multi-line paste for ip/cidr/hostname; structured kind when single line
|
||||
const lines = text.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean)
|
||||
if (lines.length === 1) {
|
||||
return apiFetch(`/api/v1/lists/${listId}/entries`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
items: [{ kind: addKind, value: lines[0]! }],
|
||||
}),
|
||||
})
|
||||
}
|
||||
return apiFetch(`/api/v1/lists/${listId}/entries`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ values: [text] }),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Добавлено')
|
||||
setAddValue('')
|
||||
setAddListRef('')
|
||||
setAddKind('ip')
|
||||
setAddOpen(false)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const removeEntry = useMutation({
|
||||
mutationFn: (value: string) => {
|
||||
if (!listId) throw new Error('Список не выбран')
|
||||
return apiFetch(`/api/v1/lists/${listId}/entries`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ value }),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Удалено')
|
||||
setDeleteValue(null)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const items = listsQ.data?.items ?? []
|
||||
const detail = listQ.data
|
||||
const manual = detail ? isManualListType(detail.type) : false
|
||||
const entryItems: ListItem[] = detail?.items ?? []
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
const catalogFilterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
|
||||
{
|
||||
@@ -127,19 +245,52 @@ function ListsPage() {
|
||||
[],
|
||||
)
|
||||
|
||||
const getFilterFieldValue = useCallback((item: IpList, field: string) => {
|
||||
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 getCatalogFilterValue = useCallback((item: IpList, field: string) => {
|
||||
if (field === 'name') return item.name
|
||||
if (field === 'type') return item.type
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
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 tabFilter = useCallback((item: IpList, tabId: string) => {
|
||||
if (tabId === 'all') return true
|
||||
if (tabId === 'manual') return isManualListType(item.type)
|
||||
return item.type === tabId
|
||||
}, [])
|
||||
|
||||
const columns: ColumnDef<IpList>[] = useMemo(
|
||||
const catalogColumns: ColumnDef<IpList>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
@@ -147,40 +298,40 @@ function ListsPage() {
|
||||
<DataGridColumnHeader column={column} title="Имя" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to="/lists/$id"
|
||||
params={{ id: row.original.id }}
|
||||
className="min-w-0"
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'min-w-0 text-left',
|
||||
listId === row.original.id && 'font-medium',
|
||||
)}
|
||||
onClick={() => selectList(row.original.id)}
|
||||
>
|
||||
<DataGridPrimaryCell accent="primary" title={row.original.name} />
|
||||
</Link>
|
||||
<DataGridPrimaryCell
|
||||
accent={listId === row.original.id ? 'primary' : undefined}
|
||||
title={row.original.name}
|
||||
subtitle={
|
||||
listId === row.original.id ? 'выбран' : undefined
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Источник" />
|
||||
<DataGridColumnHeader column={column} title="Тип" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.type} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'entry_count',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Записей" />
|
||||
<DataGridColumnHeader column={column} title="#" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.entry_count ?? 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'refresh',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Обновление" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.last_error ?? row.original.refreshed_at ?? '—'}
|
||||
</DataGridMutedCell>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{row.original.entry_count ?? 0}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -190,21 +341,19 @@ function ListsPage() {
|
||||
cell: ({ row }) => {
|
||||
const l = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
render={<Link to="/lists/$id" params={{ id: l.id }} />}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
<div className="flex justify-end gap-0.5">
|
||||
{!isManualListType(l.type) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refresh.mutate(l.id)}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Refresh"
|
||||
disabled={refresh.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refresh.mutate(l.id)
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -212,7 +361,10 @@ function ListsPage() {
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить"
|
||||
onClick={() => setDeleteId(l.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteListId(l.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
@@ -221,80 +373,326 @@ function ListsPage() {
|
||||
},
|
||||
},
|
||||
],
|
||||
[refresh],
|
||||
[listId, refresh, selectList],
|
||||
)
|
||||
|
||||
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 =
|
||||
Boolean(name.trim()) &&
|
||||
(source === 'static' || Boolean(extra.trim()))
|
||||
|
||||
const canAdd =
|
||||
addKind === 'list' ? Boolean(addListRef) : Boolean(addValue.trim())
|
||||
|
||||
const nestedCandidates = items.filter(
|
||||
(l) => l.id !== listId,
|
||||
)
|
||||
|
||||
const showCatalogOnMobile = !listId
|
||||
const showDetailOnMobile = Boolean(listId)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Списки IP"
|
||||
description="Ручные plaintext-списки (IP, CIDR, домены) и внешние источники"
|
||||
title="Списки"
|
||||
description="Создайте список и заполните его IP, CIDR, доменами или другими списками"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
Новый список
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourcePage
|
||||
title="Списки"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
tabs={[
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'manual', label: 'Ручной' },
|
||||
{ id: 'json_url', label: 'JSON по URL' },
|
||||
{ id: 'evobgp_community', label: 'EvoBGP' },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={listsQ.isLoading}
|
||||
isError={listsQ.isError}
|
||||
error={listsQ.error}
|
||||
onRetry={() => void listsQ.refetch()}
|
||||
emptyState={{
|
||||
title: 'Пусто',
|
||||
description: 'Создайте первый список.',
|
||||
action: (
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
Новый список
|
||||
</Button>
|
||||
),
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(280px,360px)_1fr] lg:items-start">
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0',
|
||||
showCatalogOnMobile ? 'block' : 'hidden lg:block',
|
||||
)}
|
||||
>
|
||||
<ResourcePage
|
||||
title="Каталог"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={catalogColumns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={catalogFilterFields}
|
||||
filters={catalogFilters}
|
||||
onFiltersChange={setCatalogFilters}
|
||||
onClearFilters={() => setCatalogFilters([])}
|
||||
getFilterFieldValue={getCatalogFilterValue}
|
||||
tabs={[
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'manual', label: 'Ручной' },
|
||||
{ id: 'json_url', label: 'JSON' },
|
||||
{ id: 'evobgp_community', label: 'EvoBGP' },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tabFilter={tabFilter}
|
||||
onRowClick={(row) => selectList(row.id)}
|
||||
isLoading={listsQ.isLoading}
|
||||
isError={listsQ.isError}
|
||||
error={listsQ.error}
|
||||
onRetry={() => void listsQ.refetch()}
|
||||
pageSize={8}
|
||||
emptyState={{
|
||||
title: 'Нет списков',
|
||||
description: 'Создайте первый список.',
|
||||
action: (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
Новый список
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0',
|
||||
showDetailOnMobile ? 'block' : 'hidden lg:block',
|
||||
)}
|
||||
>
|
||||
{!listId ? (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Выберите список"
|
||||
description="Кликните строку в каталоге слева, чтобы увидеть и редактировать содержимое."
|
||||
/>
|
||||
</DetailPanel>
|
||||
) : listQ.isLoading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-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 />,
|
||||
label: 'Записей',
|
||||
description: String(entryItems.length),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
icon: <TagIcon aria-hidden />,
|
||||
label: 'Источник',
|
||||
description:
|
||||
detail.type === 'json_url'
|
||||
? 'JSON по URL'
|
||||
: detail.type === 'evobgp_community'
|
||||
? 'EvoBGP community'
|
||||
: 'Ручной',
|
||||
},
|
||||
{
|
||||
id: 'cidrs',
|
||||
icon: <RefreshCwIcon aria-hidden />,
|
||||
label: 'CIDR в политике',
|
||||
description: String(
|
||||
detail.entry_count ?? detail.entries.length,
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<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
|
||||
open={deleteListId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteListId(null)
|
||||
}}
|
||||
title="Удалить список?"
|
||||
description="Записи списка будут удалены. Ссылки из других списков нужно убрать вручную."
|
||||
onConfirm={() => {
|
||||
if (deleteListId) removeList.mutate(deleteListId)
|
||||
}}
|
||||
disabled={removeList.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteId !== null}
|
||||
open={deleteValue !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteId(null)
|
||||
if (!open) setDeleteValue(null)
|
||||
}}
|
||||
title="Удалить список?"
|
||||
description="Записи списка будут удалены."
|
||||
title="Удалить запись?"
|
||||
description={
|
||||
deleteValue
|
||||
? `Будет удалено: ${deleteValue}`
|
||||
: 'Запись будет удалена из списка.'
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (deleteId) remove.mutate(deleteId)
|
||||
if (deleteValue) removeEntry.mutate(deleteValue)
|
||||
}}
|
||||
disabled={remove.isPending}
|
||||
disabled={removeEntry.isPending}
|
||||
/>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новый список</SheetTitle>
|
||||
<SheetDescription>
|
||||
Записи IP / CIDR / домены добавляются в таблице на странице
|
||||
списка.
|
||||
После создания заполните содержимое в таблице справа.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||
@@ -355,7 +753,7 @@ function ListsPage() {
|
||||
) : null}
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setSheetOpen(false)}>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
@@ -367,6 +765,89 @@ function ListsPage() {
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Sheet open={addOpen} onOpenChange={setAddOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Добавить запись</SheetTitle>
|
||||
<SheetDescription>
|
||||
Выберите вид и значение. Для IP/CIDR/доменов можно вставить
|
||||
несколько строк сразу.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user