feat(api): enhance IP list management with new entry operations and improved error handling
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m50s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added endpoints for adding and deleting entries in IP lists.
- Refactored list creation logic to handle manual list types more effectively.
- Updated refresh logic to rebuild manual list entries.
- Improved error handling for entry operations to ensure data integrity.
- Enhanced response structure for list detail retrieval.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 23:01:23 +07:00
co-authored by Cursor
parent 7f0c1009de
commit 3f7672ab7c
11 changed files with 805 additions and 205 deletions
+13 -5
View File
@@ -26,9 +26,13 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
revoked: 'secondary',
disabled: 'secondary',
static: 'secondary',
domains: 'info-light',
domains: 'secondary',
manual: 'secondary',
json_url: 'info-light',
evobgp_community: 'info-light',
ip: 'info-light',
cidr: 'secondary',
hostname: 'warning-light',
unknown: 'outline',
}
@@ -63,10 +67,14 @@ const STATUS_LABELS: Record<string, string> = {
degraded: 'Slow',
down: 'Down',
expired: 'Истёк',
static: 'static',
domains: 'domains',
json_url: 'json_url',
evobgp_community: 'evobgp_community',
static: 'Ручной',
domains: 'Ручной',
manual: 'Ручной',
json_url: 'JSON по URL',
evobgp_community: 'EvoBGP community',
ip: 'IP',
cidr: 'CIDR',
hostname: 'Домен',
unknown: 'Неизвестно',
}
+19 -5
View File
@@ -36,11 +36,25 @@ export const listQueryOptions = (id: string) =>
queryOptions({
queryKey: ['lists', id],
queryFn: () =>
apiFetch<
IpList & {
entries: string[]
}
>(`/api/v1/lists/${id}`),
apiFetch<{
id: string
name: string
type: IpList['type']
config_json: string
content_hash?: string | null
refreshed_at?: string | null
last_error?: string | null
entries: string[]
items: {
kind: 'ip' | 'cidr' | 'hostname'
value: string
resolved_count: number
resolved_cidrs: string[]
}[]
entry_count: number
created_at: string
updated_at: string
}>(`/api/v1/lists/${id}`),
})
export const policySetsQueryOptions = () =>
+243 -63
View File
@@ -1,34 +1,63 @@
import { createFileRoute, Link } 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 { HashIcon, RefreshCwIcon, TagIcon } from 'lucide-react'
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 { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { listQueryOptions } from '@/queries'
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'
export const Route = createFileRoute('/_auth/lists/$id')({
component: ListDetailPage,
})
type EntryRow = { id: string; cidr: string }
type ListItem = {
kind: 'ip' | 'cidr' | 'hostname'
value: string
resolved_count: number
resolved_cidrs: string[]
}
/**
* IP list detail — entries as DataGrid table.
* Preview: https://reui.io/preview/base/data-grid-filtering-1 · empty-state-12
* 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: () =>
@@ -40,40 +69,108 @@ function ListDetailPage() {
onError: (e: Error) => toast.error(e.message),
})
const entries: EntryRow[] = useMemo(
() =>
(listQ.data?.entries ?? []).map((cidr, i) => ({
id: `${i}-${cidr}`,
cidr,
})),
[listQ.data?.entries],
)
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: 'cidr',
label: 'Entry',
key: 'value',
label: 'Значение',
type: 'text',
placeholder: 'Поиск CIDR / домен…',
placeholder: 'Поиск…',
},
{
key: 'kind',
label: 'Вид',
type: 'select',
options: [
{ value: 'ip', label: 'IP' },
{ value: 'cidr', label: 'CIDR' },
{ value: 'hostname', label: 'Домен' },
],
},
],
[],
)
const columns: ColumnDef<EntryRow>[] = useMemo(
const columns: ColumnDef<ListItem>[] = useMemo(
() => [
{
accessorKey: 'cidr',
accessorKey: 'value',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Entry" />
<DataGridColumnHeader column={column} title="Значение" />
),
cell: ({ row }) => (
<DataGridPrimaryCell accent="mono" title={row.original.cidr} />
<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) {
@@ -85,7 +182,7 @@ function ListDetailPage() {
)
}
if (!listQ.data) {
if (!list) {
return (
<PageShell>
<DetailPanel>
@@ -102,30 +199,50 @@ function ListDetailPage() {
)
}
const list = listQ.data
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header
title={list.name}
description={`Тип ${list.type}`}
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} />
{list.type !== 'static' ? (
<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
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" />}>
@@ -140,59 +257,122 @@ function ListDetailPage() {
{
id: 'count',
icon: <HashIcon aria-hidden />,
label: 'Entries',
description: String(entries.length),
label: 'Записей',
description: String(items.length),
},
{
id: 'type',
icon: <TagIcon aria-hidden />,
label: 'Тип',
description: list.type,
label: 'Источник',
description:
list.type === 'json_url'
? 'JSON по URL'
: list.type === 'evobgp_community'
? 'EvoBGP community'
: 'Ручной',
},
{
id: 'refreshed',
id: 'cidrs',
icon: <RefreshCwIcon aria-hidden />,
label: 'Refresh',
description: list.last_error
? list.last_error
: (list.refreshed_at ?? '—'),
label: 'CIDR в политике',
description: String(list.entry_count ?? list.entries.length),
},
]}
/>
<DetailPanel.Section
title="Содержимое"
description="CIDR / resolved prefixes из списка."
description={
manual
? 'IP, CIDR и домены в одном списке — вид определяется автоматически.'
: 'Записи из внешнего источника (только чтение).'
}
>
<ResourcePage
title="Entries"
hideHeader
data={entries}
data={items}
columns={columns}
getRowId={(r) => r.id}
getRowId={(r) => r.value}
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={(item, field) =>
field === 'cidr' ? item.cidr : undefined
}
getFilterFieldValue={(item, field) => {
if (field === 'value') return item.value
if (field === 'kind') return item.kind
return undefined
}}
emptyState={{
title: 'Нет entries',
description:
list.type === 'static'
? 'Добавьте CIDR при создании или обновите список.'
: 'Нажмите Refresh или проверьте источник.',
title: 'Нет записей',
description: manual
? 'Добавьте IP, CIDR или домен — по одной строке или списком.'
: 'Нажмите Refresh или проверьте источник.',
action: manual ? (
<Button size="sm" onClick={() => setAddOpen(true)}>
Добавить
</Button>
) : undefined,
}}
/>
</DetailPanel.Section>
{list.last_error ? (
<DataGridMutedCell className="text-destructive px-1">
{list.last_error}
</DataGridMutedCell>
<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>
)
}
+76 -65
View File
@@ -7,7 +7,10 @@ import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import {
DataGridMutedCell,
DataGridPrimaryCell,
} from '@/components/data-grid-cell'
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { listsQueryOptions } from '@/queries'
@@ -15,7 +18,6 @@ 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,
@@ -31,16 +33,23 @@ import {
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import type { IpList } from '@evofw/shared'
import {
guessListSourceFromInput,
isManualListType,
type IpList,
} from '@evofw/shared'
export const Route = createFileRoute('/_auth/lists/')({
component: ListsPage,
})
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
/**
* IP lists — ResourcePage.
* IP lists catalog — ResourcePage.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Empty: https://reui.io/preview/base/empty-state-12
* Sheet: https://reui.io/preview/base/sheet-8
*/
function ListsPage() {
const navigate = useNavigate()
@@ -48,9 +57,7 @@ function ListsPage() {
const listsQ = useQuery(listsQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false)
const [name, setName] = useState('')
const [type, setType] = useState<
'static' | 'json_url' | 'domains' | 'evobgp_community'
>('static')
const [source, setSource] = useState<CreateSource>('static')
const [extra, setExtra] = useState('')
const [filters, setFilters] = useState<Filter[]>([])
const [activeTab, setActiveTab] = useState('all')
@@ -59,31 +66,21 @@ function ListsPage() {
const create = useMutation({
mutationFn: async () => {
const config: Record<string, unknown> = {}
let entries: string[] | undefined
if (type === 'static') {
entries = extra
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
} else if (type === 'json_url') {
if (source === 'json_url') {
config.url = extra.trim()
} else if (type === 'domains') {
config.domains = extra
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
} else {
} else if (source === 'evobgp_community') {
config.community_id = extra.trim()
}
return apiFetch<{ id: string }>('/api/v1/lists', {
method: 'POST',
body: JSON.stringify({ name, type, config, entries }),
body: JSON.stringify({ name, type: source, config }),
})
},
onSuccess: (row) => {
toast.success('Список создан')
setName('')
setExtra('')
setSource('static')
setSheetOpen(false)
void qc.invalidateQueries({ queryKey: ['lists'] })
void navigate({ to: '/lists/$id', params: { id: row.id } })
@@ -117,13 +114,13 @@ function ListsPage() {
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
{
key: 'type',
label: 'Тип',
label: 'Источник',
type: 'select',
options: [
{ value: 'static', label: 'static' },
{ value: 'json_url', label: 'json_url' },
{ value: 'domains', label: 'domains' },
{ value: 'evobgp_community', label: 'evobgp_community' },
{ value: 'static', label: 'Ручной' },
{ value: 'json_url', label: 'JSON по URL' },
{ value: 'evobgp_community', label: 'EvoBGP community' },
{ value: 'domains', label: 'Ручной' },
],
},
],
@@ -138,6 +135,7 @@ function ListsPage() {
const tabFilter = useCallback((item: IpList, tabId: string) => {
if (tabId === 'all') return true
if (tabId === 'manual') return isManualListType(item.type)
return item.type === tabId
}, [])
@@ -161,14 +159,14 @@ function ListsPage() {
{
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="Entries" />
<DataGridColumnHeader column={column} title="Записей" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.entry_count ?? 0}</span>
@@ -177,7 +175,7 @@ function ListsPage() {
{
id: 'refresh',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Refresh" />
<DataGridColumnHeader column={column} title="Обновление" />
),
cell: ({ row }) => (
<DataGridMutedCell>
@@ -196,13 +194,11 @@ function ListsPage() {
<Button
size="sm"
variant="outline"
render={
<Link to="/lists/$id" params={{ id: l.id }} />
}
render={<Link to="/lists/$id" params={{ id: l.id }} />}
>
Открыть
</Button>
{l.type !== 'static' ? (
{!isManualListType(l.type) ? (
<Button
size="sm"
variant="outline"
@@ -228,11 +224,15 @@ function ListsPage() {
[refresh],
)
const canCreate =
Boolean(name.trim()) &&
(source === 'static' || Boolean(extra.trim()))
return (
<PageShell>
<PageHeader
title="Списки IP"
description="static · JSON URL · domains · EvoBGP community"
description="Ручные plaintext-списки (IP, CIDR, домены) и внешние источники"
actions={
<Button size="sm" onClick={() => setSheetOpen(true)}>
Новый список
@@ -253,10 +253,9 @@ function ListsPage() {
getFilterFieldValue={getFilterFieldValue}
tabs={[
{ id: 'all', label: 'Все' },
{ id: 'static', label: 'static' },
{ id: 'domains', label: 'domains' },
{ id: 'json_url', label: 'json_url' },
{ id: 'evobgp_community', label: 'evobgp' },
{ id: 'manual', label: 'Ручной' },
{ id: 'json_url', label: 'JSON по URL' },
{ id: 'evobgp_community', label: 'EvoBGP' },
]}
activeTab={activeTab}
onTabChange={setActiveTab}
@@ -282,7 +281,7 @@ function ListsPage() {
if (!open) setDeleteId(null)
}}
title="Удалить список?"
description="Entries и ссылки из правил останутся неконсистентны — удаляйте осторожно."
description="Записи списка будут удалены."
onConfirm={() => {
if (deleteId) remove.mutate(deleteId)
}}
@@ -293,7 +292,10 @@ function ListsPage() {
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Новый список</SheetTitle>
<SheetDescription>Источник префиксов для правил</SheetDescription>
<SheetDescription>
Записи IP / CIDR / домены добавляются в таблице на странице
списка.
</SheetDescription>
</SheetHeader>
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
<Field>
@@ -305,50 +307,59 @@ function ListsPage() {
/>
</Field>
<Field>
<FieldLabel>Тип</FieldLabel>
<FieldLabel>Источник</FieldLabel>
<Select
value={type}
value={source}
onValueChange={(v) => {
if (v) setType(v as typeof type)
if (v) setSource(v as CreateSource)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">static</SelectItem>
<SelectItem value="json_url">json_url</SelectItem>
<SelectItem value="domains">domains</SelectItem>
<SelectItem value="static">Ручной</SelectItem>
<SelectItem value="json_url">JSON по URL</SelectItem>
<SelectItem value="evobgp_community">
evobgp_community
EvoBGP community
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="list-extra">
{type === 'static'
? 'CIDR (через пробел/запятую)'
: type === 'json_url'
? 'URL JSON'
: type === 'domains'
? 'Домены'
: 'Community ID'}
</FieldLabel>
<Textarea
id="list-extra"
value={extra}
onChange={(e) => setExtra(e.target.value)}
rows={3}
/>
</Field>
{source === 'json_url' ? (
<Field>
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
<Input
id="list-url"
value={extra}
placeholder="https://…"
onChange={(e) => {
const v = e.target.value
setExtra(v)
if (guessListSourceFromInput(v) === 'json_url') {
setSource('json_url')
}
}}
/>
</Field>
) : null}
{source === 'evobgp_community' ? (
<Field>
<FieldLabel htmlFor="list-comm">Community ID</FieldLabel>
<Input
id="list-comm"
value={extra}
onChange={(e) => setExtra(e.target.value)}
/>
</Field>
) : null}
</div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setSheetOpen(false)}>
Отмена
</Button>
<Button
disabled={!name || create.isPending}
disabled={!canCreate || create.isPending}
onClick={() => create.mutate()}
>
Создать