From 3f7672ab7cb8557b53cf59c73f28b50154f64366 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 20 Jul 2026 23:01:23 +0700 Subject: [PATCH] feat(api): enhance IP list management with new entry operations and improved error handling - 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 --- apps/api/src/routes/control.ts | 95 +++++-- apps/api/src/services/lists/entries.ts | 208 +++++++++++++++ apps/api/src/services/lists/refresh.ts | 44 +--- apps/web/src/components/status-badge.tsx | 18 +- apps/web/src/queries/index.ts | 24 +- apps/web/src/routes/_auth/lists/$id.tsx | 306 +++++++++++++++++----- apps/web/src/routes/_auth/lists/index.tsx | 141 +++++----- packages/db/src/repositories/index.ts | 16 ++ packages/shared/src/contracts.ts | 1 + packages/shared/src/index.ts | 1 + packages/shared/src/list-entries.ts | 156 +++++++++++ 11 files changed, 805 insertions(+), 205 deletions(-) create mode 100644 apps/api/src/services/lists/entries.ts create mode 100644 packages/shared/src/list-entries.ts diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts index 491af33..88d5ec4 100644 --- a/apps/api/src/routes/control.ts +++ b/apps/api/src/routes/control.ts @@ -9,9 +9,17 @@ import { putAgentPolicySetsBodySchema, patchAgentBodySchema, cloneFromBodySchema, + listEntriesBodySchema, + deleteListEntryBodySchema, + isManualListType, } from '@evofw/shared' import { AppError } from '../plugins/error-handler.js' import { refreshIpList } from '../services/lists/refresh.js' +import { + addListEntries, + deleteListEntry, + mapListDetail, +} from '../services/lists/entries.js' import { evaluateAgentPolicy } from '../services/policy/evaluate.js' import { resolveAndStoreHostnameRule, @@ -279,19 +287,29 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( app.post('/lists', async (req) => { const body = createIpListBodySchema.parse(req.body) + const type = + body.type === 'domains' ? 'static' : body.type const id = crypto.randomUUID() const list = repos.insertIpList(app.db, { id, name: body.name, - type: body.type, + type, configJson: JSON.stringify(body.config ?? {}), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) - if (body.entries?.length) { - repos.replaceIpListEntries(app.db, id, body.entries) - } - if (body.type !== 'static') { + if (body.entries?.length && isManualListType(type)) { + try { + await addListEntries(app.db, id, body.entries) + } catch (err) { + repos.deleteIpList(app.db, id) + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + } else if (!isManualListType(type)) { await refreshIpList(app.db, id) } return { @@ -305,33 +323,54 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( }) app.get<{ Params: { id: string } }>('/lists/:id', async (req) => { - const l = repos.getIpList(app.db, req.params.id) - if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) - return { - id: l.id, - name: l.name, - type: l.type, - config_json: l.configJson, - content_hash: l.contentHash, - refreshed_at: l.refreshedAt, - last_error: l.lastError, - entries: repos.listIpListEntries(app.db, l.id).map((e) => e.cidr), - created_at: l.createdAt, - updated_at: l.updatedAt, - } + const detail = mapListDetail(app.db, req.params.id) + if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404) + return detail }) + app.post<{ Params: { id: string } }>( + '/lists/:id/entries', + async (req) => { + const l = repos.getIpList(app.db, req.params.id) + if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) + const body = listEntriesBodySchema.parse(req.body) + try { + const result = await addListEntries(app.db, l.id, body.values) + return mapListDetail(app.db, l.id) ?? result + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + }, + ) + + app.delete<{ Params: { id: string } }>( + '/lists/:id/entries', + async (req) => { + const l = repos.getIpList(app.db, req.params.id) + if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) + const body = deleteListEntryBodySchema.parse(req.body) + try { + await deleteListEntry(app.db, l.id, body.value) + return mapListDetail(app.db, l.id) + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + }, + ) + app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => { await refreshIpList(app.db, req.params.id) - const l = repos.getIpList(app.db, req.params.id) - if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) - return { - id: l.id, - content_hash: l.contentHash, - refreshed_at: l.refreshedAt, - last_error: l.lastError, - entry_count: repos.listIpListEntries(app.db, l.id).length, - } + const detail = mapListDetail(app.db, req.params.id) + if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404) + return detail }) app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => { diff --git a/apps/api/src/services/lists/entries.ts b/apps/api/src/services/lists/entries.ts new file mode 100644 index 0000000..92d8011 --- /dev/null +++ b/apps/api/src/services/lists/entries.ts @@ -0,0 +1,208 @@ +import type { Db } from '@evofw/db' +import { repos } from '@evofw/db' +import { + isManualListType, + normalizeItemToCidrs, + parseListEntry, + readManualItems, + splitListPlaintext, + type ListConfigItem, +} from '@evofw/shared' +import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js' + +function uniq(cidrs: string[]): string[] { + return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort() +} + +export function getListConfig(list: { + configJson: string +}): Record { + try { + return JSON.parse(list.configJson || '{}') as Record + } catch { + return {} + } +} + +async function expandItem(item: ListConfigItem): Promise { + if (item.kind === 'hostname') { + if (item.resolved_cidrs?.length) return item.resolved_cidrs + return resolveHostnameToCidrs(item.value) + } + return normalizeItemToCidrs(item) +} + +/** Rebuild ip_list_entries from config.items (manual lists). */ +export async function rebuildManualListEntries( + db: Db, + listId: string, +): Promise { + const list = repos.getIpList(db, listId) + if (!list || !isManualListType(list.type)) return [] + + const config = getListConfig(list) + const items = readManualItems(list.configJson) + const nextItems: ListConfigItem[] = [] + const all: string[] = [] + + for (const item of items) { + try { + const cidrs = await expandItem({ + ...item, + resolved_cidrs: undefined, + }) + nextItems.push({ ...item, resolved_cidrs: cidrs }) + all.push(...cidrs) + } catch { + nextItems.push({ ...item, resolved_cidrs: item.resolved_cidrs ?? [] }) + if (item.resolved_cidrs?.length) all.push(...item.resolved_cidrs) + } + } + + config.items = nextItems + delete config.domains + repos.updateIpList(db, listId, { configJson: JSON.stringify(config) }) + + const cidrs = uniq(all) + repos.replaceIpListEntries(db, listId, cidrs) + return cidrs +} + +export async function addListEntries( + db: Db, + listId: string, + values: string[], +): Promise<{ items: ListConfigItem[]; entries: string[] }> { + const list = repos.getIpList(db, listId) + if (!list) throw new Error('List not found') + if (!isManualListType(list.type)) { + throw new Error('Записи можно добавлять только в ручной список') + } + + const config = getListConfig(list) + let items = readManualItems(list.configJson) + const existing = new Set(items.map((i) => i.value.toLowerCase())) + + const tokens = values.flatMap((v) => splitListPlaintext(v)) + const added: ListConfigItem[] = [] + + for (const token of tokens) { + const parsed = parseListEntry(token) + const key = parsed.value.toLowerCase() + if (existing.has(key)) continue + const resolved = await expandItem(parsed) + const item: ListConfigItem = { ...parsed, resolved_cidrs: resolved } + items.push(item) + existing.add(key) + added.push(item) + } + + if (added.length === 0) { + throw new Error('Нет новых записей для добавления') + } + + config.items = items + delete config.domains + + const type = list.type === 'domains' ? 'static' : list.type + repos.updateIpList(db, listId, { + type, + configJson: JSON.stringify(config), + }) + + const entries = await rebuildManualListEntries(db, listId) + repos.updateIpList(db, listId, { + refreshedAt: new Date().toISOString(), + lastError: null, + }) + repos.bumpAgentsForList(db, listId) + + return { + items: readManualItems(repos.getIpList(db, listId)!.configJson), + entries, + } +} + +export async function deleteListEntry( + db: Db, + listId: string, + value: string, +): Promise<{ items: ListConfigItem[]; entries: string[] }> { + const list = repos.getIpList(db, listId) + if (!list) throw new Error('List not found') + if (!isManualListType(list.type)) { + throw new Error('Записи можно удалять только из ручного списка') + } + + const config = getListConfig(list) + const needle = value.trim().toLowerCase() + let items = readManualItems(list.configJson) + const before = items.length + items = items.filter((i) => i.value.toLowerCase() !== needle) + if (items.length === before) { + throw new Error('Запись не найдена') + } + + config.items = items + delete config.domains + + const type = list.type === 'domains' ? 'static' : list.type + repos.updateIpList(db, listId, { + type, + configJson: JSON.stringify(config), + }) + + const entries = await rebuildManualListEntries(db, listId) + repos.updateIpList(db, listId, { + refreshedAt: new Date().toISOString(), + lastError: null, + }) + repos.bumpAgentsForList(db, listId) + + return { + items: readManualItems(repos.getIpList(db, listId)!.configJson), + entries, + } +} + +export function mapListDetail(db: Db, listId: string) { + const l = repos.getIpList(db, listId) + if (!l) return null + + const entries = repos.listIpListEntries(db, listId).map((e) => e.cidr) + + const items = isManualListType(l.type) + ? readManualItems(l.configJson).map((item) => { + const resolved = + item.resolved_cidrs?.length + ? item.resolved_cidrs + : normalizeItemToCidrs(item) + return { + kind: item.kind, + value: item.value, + resolved_count: resolved.length, + resolved_cidrs: resolved, + } + }) + : entries.map((cidr) => ({ + kind: 'cidr' as const, + value: cidr, + resolved_count: 1, + resolved_cidrs: [cidr], + })) + + return { + id: l.id, + name: l.name, + type: l.type, + config_json: l.configJson, + content_hash: l.contentHash, + refreshed_at: l.refreshedAt, + last_error: l.lastError, + entries, + items, + entry_count: entries.length, + created_at: l.createdAt, + updated_at: l.updatedAt, + } +} diff --git a/apps/api/src/services/lists/refresh.ts b/apps/api/src/services/lists/refresh.ts index 43f2e0c..2eb3dd1 100644 --- a/apps/api/src/services/lists/refresh.ts +++ b/apps/api/src/services/lists/refresh.ts @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto' -import { resolve4, resolve6 } from 'node:dns/promises' import type { Db } from '@evofw/db' import { repos } from '@evofw/db' +import { refreshAllHostnameRules } from '../policy/resolve-hostname.js' +import { rebuildManualListEntries } from './entries.js' function uniq(cidrs: string[]): string[] { return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort() @@ -46,27 +47,6 @@ async function fetchJsonUrl(url: string): Promise { return uniq(out) } -async function resolveDomains(domains: string[]): Promise { - const out: string[] = [] - for (const d of domains) { - const host = d.trim().replace(/\.$/, '') - if (!host) continue - try { - const a = await resolve4(host) - out.push(...a.map((ip) => `${ip}/32`)) - } catch { - /* ignore */ - } - try { - const aaaa = await resolve6(host) - out.push(...aaaa.map((ip) => `${ip}/128`)) - } catch { - /* ignore */ - } - } - return uniq(out) -} - async function fetchEvobgpCommunity( apiUrl: string, token: string, @@ -118,21 +98,13 @@ export async function refreshIpList(db: Db, listId: string): Promise { try { let cidrs: string[] = [] - if (list.type === 'static') { - cidrs = repos.listIpListEntries(db, listId).map((e) => e.cidr) + if (list.type === 'static' || list.type === 'domains') { + cidrs = await rebuildManualListEntries(db, listId) } else if (list.type === 'json_url') { const url = String(config.url ?? '') if (!url) throw new Error('config.url required') cidrs = await fetchJsonUrl(url) repos.replaceIpListEntries(db, listId, cidrs) - } else if (list.type === 'domains') { - const domains = Array.isArray(config.domains) - ? (config.domains as string[]) - : String(config.domains ?? '') - .split(/[\s,]+/) - .filter(Boolean) - cidrs = await resolveDomains(domains) - repos.replaceIpListEntries(db, listId, cidrs) } else if (list.type === 'evobgp_community') { const apiUrl = String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url') @@ -154,10 +126,7 @@ export async function refreshIpList(db: Db, listId: string): Promise { lastError: null, }) - // Bump all agents so they re-fetch policy - for (const a of repos.listAgents(db)) { - if (a.status === 'approved') repos.bumpAgentGeneration(db, a.id) - } + repos.bumpAgentsForList(db, listId) } catch (err) { repos.updateIpList(db, listId, { lastError: err instanceof Error ? err.message : String(err), @@ -166,11 +135,8 @@ export async function refreshIpList(db: Db, listId: string): Promise { } } -import { refreshAllHostnameRules } from '../policy/resolve-hostname.js' - export async function refreshAllLists(db: Db): Promise { for (const list of repos.listIpLists(db)) { - if (list.type === 'static') continue await refreshIpList(db, list.id) } await refreshAllHostnameRules(db) diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx index fe09ded..3b6bc34 100644 --- a/apps/web/src/components/status-badge.tsx +++ b/apps/web/src/components/status-badge.tsx @@ -26,9 +26,13 @@ const STATUS_VARIANT: Record = { 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 = { 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: 'Неизвестно', } diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 29408b7..41aa521 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -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 = () => diff --git a/apps/web/src/routes/_auth/lists/$id.tsx b/apps/web/src/routes/_auth/lists/$id.tsx index 421745f..378d019 100644 --- a/apps/web/src/routes/_auth/lists/$id.tsx +++ b/apps/web/src/routes/_auth/lists/$id.tsx @@ -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([]) + const [addOpen, setAddOpen] = useState(false) + const [plaintext, setPlaintext] = useState('') + const [deleteValue, setDeleteValue] = useState(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[] = useMemo( + const columns: ColumnDef[] = useMemo( () => [ { - accessorKey: 'cidr', + accessorKey: 'value', header: ({ column }) => ( - + ), cell: ({ row }) => ( - + 0 + ? `${row.original.resolved_count} CIDR` + : undefined + } + /> ), }, + { + accessorKey: 'kind', + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + id: 'actions', + enableSorting: false, + header: () => Действия, + cell: ({ row }) => + manual ? ( +
+ +
+ ) : null, + }, ], - [], + [manual], ) if (listQ.isLoading) { @@ -85,7 +182,7 @@ function ListDetailPage() { ) } - if (!listQ.data) { + if (!list) { return ( @@ -102,30 +199,50 @@ function ListDetailPage() { ) } - const list = listQ.data - return ( + - {list.type !== 'static' ? ( - + {manual ? ( + ) : null} + ) : undefined, }} /> {list.last_error ? ( - - {list.last_error} - +

{list.last_error}

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