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:
@@ -300,7 +300,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
})
|
||||
if (body.entries?.length && isManualListType(type)) {
|
||||
try {
|
||||
await addListEntries(app.db, id, body.entries)
|
||||
await addListEntries(app.db, id, { values: body.entries })
|
||||
} catch (err) {
|
||||
repos.deleteIpList(app.db, id)
|
||||
throw new AppError(
|
||||
@@ -335,7 +335,10 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
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)
|
||||
const result = await addListEntries(app.db, l.id, {
|
||||
values: body.values,
|
||||
items: body.items,
|
||||
})
|
||||
return mapListDetail(app.db, l.id) ?? result
|
||||
} catch (err) {
|
||||
throw new AppError(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
|
||||
import {
|
||||
addListEntries,
|
||||
deleteListEntry,
|
||||
wouldCreateListCycle,
|
||||
rebuildListCascade,
|
||||
} from './entries.js'
|
||||
|
||||
function insertManual(
|
||||
db: ReturnType<typeof createMemoryDb>['db'],
|
||||
id: string,
|
||||
name: string,
|
||||
) {
|
||||
const now = new Date().toISOString()
|
||||
repos.insertIpList(db, {
|
||||
id,
|
||||
name,
|
||||
type: 'static',
|
||||
configJson: '{}',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
describe('nested lists', () => {
|
||||
let db: ReturnType<typeof createMemoryDb>['db']
|
||||
|
||||
beforeEach(() => {
|
||||
const mem = createMemoryDb()
|
||||
runMigrations(mem.sqlite)
|
||||
db = mem.db
|
||||
})
|
||||
|
||||
it('adds nested list and materializes child CIDRs into parent', async () => {
|
||||
insertManual(db, 'child', 'Child')
|
||||
insertManual(db, 'parent', 'Parent')
|
||||
|
||||
await addListEntries(db, 'child', { values: ['8.8.8.8', '10.0.0.0/8'] })
|
||||
await addListEntries(db, 'parent', {
|
||||
items: [{ kind: 'list', value: 'child' }],
|
||||
})
|
||||
|
||||
const parentCidrs = repos.listIpListEntries(db, 'parent').map((e) => e.cidr)
|
||||
expect(parentCidrs).toContain('8.8.8.8/32')
|
||||
expect(parentCidrs).toContain('10.0.0.0/8')
|
||||
})
|
||||
|
||||
it('rejects self-reference', async () => {
|
||||
insertManual(db, 'a', 'A')
|
||||
await expect(
|
||||
addListEntries(db, 'a', { items: [{ kind: 'list', value: 'a' }] }),
|
||||
).rejects.toThrow(/себя/)
|
||||
})
|
||||
|
||||
it('rejects cycles A→B→A', async () => {
|
||||
insertManual(db, 'a', 'A')
|
||||
insertManual(db, 'b', 'B')
|
||||
await addListEntries(db, 'a', { items: [{ kind: 'list', value: 'b' }] })
|
||||
expect(wouldCreateListCycle(db, 'b', 'a')).toBe(true)
|
||||
await expect(
|
||||
addListEntries(db, 'b', { items: [{ kind: 'list', value: 'a' }] }),
|
||||
).rejects.toThrow(/цикл/i)
|
||||
})
|
||||
|
||||
it('cascades rebuild when child changes', async () => {
|
||||
insertManual(db, 'child', 'Child')
|
||||
insertManual(db, 'parent', 'Parent')
|
||||
|
||||
await addListEntries(db, 'child', { values: ['1.1.1.1'] })
|
||||
await addListEntries(db, 'parent', {
|
||||
items: [{ kind: 'list', value: 'child' }],
|
||||
})
|
||||
|
||||
expect(
|
||||
repos.listIpListEntries(db, 'parent').map((e) => e.cidr),
|
||||
).toContain('1.1.1.1/32')
|
||||
|
||||
await addListEntries(db, 'child', { values: ['9.9.9.9'] })
|
||||
await rebuildListCascade(db, 'child')
|
||||
|
||||
const parentCidrs = repos.listIpListEntries(db, 'parent').map((e) => e.cidr)
|
||||
expect(parentCidrs).toContain('1.1.1.1/32')
|
||||
expect(parentCidrs).toContain('9.9.9.9/32')
|
||||
|
||||
await deleteListEntry(db, 'child', '1.1.1.1')
|
||||
const afterDelete = repos
|
||||
.listIpListEntries(db, 'parent')
|
||||
.map((e) => e.cidr)
|
||||
expect(afterDelete).not.toContain('1.1.1.1/32')
|
||||
expect(afterDelete).toContain('9.9.9.9/32')
|
||||
})
|
||||
})
|
||||
@@ -2,11 +2,13 @@ import type { Db } from '@evofw/db'
|
||||
import { repos } from '@evofw/db'
|
||||
import {
|
||||
isManualListType,
|
||||
listNestedChildIds,
|
||||
normalizeItemToCidrs,
|
||||
parseListEntry,
|
||||
readManualItems,
|
||||
splitListPlaintext,
|
||||
type ListConfigItem,
|
||||
type ListEntryInput,
|
||||
} from '@evofw/shared'
|
||||
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
|
||||
|
||||
@@ -24,11 +26,67 @@ export function getListConfig(list: {
|
||||
}
|
||||
}
|
||||
|
||||
async function expandItem(item: ListConfigItem): Promise<string[]> {
|
||||
/** True if adding parent→child would create a cycle. */
|
||||
export function wouldCreateListCycle(
|
||||
db: Db,
|
||||
parentId: string,
|
||||
childId: string,
|
||||
): boolean {
|
||||
if (parentId === childId) return true
|
||||
const stack = [childId]
|
||||
const seen = new Set<string>()
|
||||
while (stack.length) {
|
||||
const id = stack.pop()!
|
||||
if (id === parentId) return true
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const list = repos.getIpList(db, id)
|
||||
if (!list || !isManualListType(list.type)) continue
|
||||
for (const nested of listNestedChildIds(list.configJson)) {
|
||||
stack.push(nested)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Manual lists that reference childId via kind=list. */
|
||||
export function findParentListIds(db: Db, childId: string): string[] {
|
||||
const parents: string[] = []
|
||||
for (const list of repos.listIpLists(db)) {
|
||||
if (!isManualListType(list.type)) continue
|
||||
if (list.id === childId) continue
|
||||
if (listNestedChildIds(list.configJson).includes(childId)) {
|
||||
parents.push(list.id)
|
||||
}
|
||||
}
|
||||
return parents
|
||||
}
|
||||
|
||||
async function expandItem(
|
||||
db: Db,
|
||||
item: ListConfigItem,
|
||||
visiting: Set<string>,
|
||||
): Promise<string[]> {
|
||||
if (item.kind === 'hostname') {
|
||||
if (item.resolved_cidrs?.length) return item.resolved_cidrs
|
||||
return resolveHostnameToCidrs(item.value)
|
||||
}
|
||||
if (item.kind === 'list') {
|
||||
const childId = item.value
|
||||
if (visiting.has(childId)) {
|
||||
throw new Error(`Циклическая ссылка списков: ${childId}`)
|
||||
}
|
||||
const child = repos.getIpList(db, childId)
|
||||
if (!child) {
|
||||
throw new Error(`Вложенный список не найден: ${childId}`)
|
||||
}
|
||||
// Prefer materialized CIDRs; for manual children rebuild if empty.
|
||||
let entries = repos.listIpListEntries(db, childId).map((e) => e.cidr)
|
||||
if (entries.length === 0 && isManualListType(child.type)) {
|
||||
entries = await rebuildManualListEntries(db, childId, visiting)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
return normalizeItemToCidrs(item)
|
||||
}
|
||||
|
||||
@@ -36,42 +94,93 @@ async function expandItem(item: ListConfigItem): Promise<string[]> {
|
||||
export async function rebuildManualListEntries(
|
||||
db: Db,
|
||||
listId: string,
|
||||
visiting: Set<string> = new Set(),
|
||||
): Promise<string[]> {
|
||||
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[] = []
|
||||
if (visiting.has(listId)) {
|
||||
throw new Error(`Циклическая ссылка списков: ${listId}`)
|
||||
}
|
||||
visiting.add(listId)
|
||||
|
||||
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)
|
||||
try {
|
||||
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(
|
||||
db,
|
||||
{ ...item, resolved_cidrs: undefined },
|
||||
visiting,
|
||||
)
|
||||
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
|
||||
} finally {
|
||||
visiting.delete(listId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild list and all ancestor lists that nest it; bump agents. */
|
||||
export async function rebuildListCascade(
|
||||
db: Db,
|
||||
listId: string,
|
||||
): Promise<string[]> {
|
||||
const affected: string[] = []
|
||||
const queue = [listId]
|
||||
const seen = new Set<string>()
|
||||
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const list = repos.getIpList(db, id)
|
||||
if (!list) continue
|
||||
if (isManualListType(list.type)) {
|
||||
await rebuildManualListEntries(db, id)
|
||||
}
|
||||
affected.push(id)
|
||||
for (const parentId of findParentListIds(db, id)) {
|
||||
if (!seen.has(parentId)) queue.push(parentId)
|
||||
}
|
||||
}
|
||||
|
||||
config.items = nextItems
|
||||
delete config.domains
|
||||
repos.updateIpList(db, listId, { configJson: JSON.stringify(config) })
|
||||
for (const id of affected) {
|
||||
repos.bumpAgentsForList(db, id)
|
||||
}
|
||||
return affected
|
||||
}
|
||||
|
||||
const cidrs = uniq(all)
|
||||
repos.replaceIpListEntries(db, listId, cidrs)
|
||||
return cidrs
|
||||
function normalizeInputItem(input: ListEntryInput): ListConfigItem {
|
||||
if (input.kind === 'list') {
|
||||
return { kind: 'list', value: input.value.trim() }
|
||||
}
|
||||
if (input.kind === 'hostname') {
|
||||
return { kind: 'hostname', value: input.value.trim().toLowerCase() }
|
||||
}
|
||||
return { kind: input.kind, value: input.value.trim() }
|
||||
}
|
||||
|
||||
export async function addListEntries(
|
||||
db: Db,
|
||||
listId: string,
|
||||
values: string[],
|
||||
input: { values?: string[]; items?: ListEntryInput[] },
|
||||
): Promise<{ items: ListConfigItem[]; entries: string[] }> {
|
||||
const list = repos.getIpList(db, listId)
|
||||
if (!list) throw new Error('List not found')
|
||||
@@ -81,16 +190,37 @@ export async function addListEntries(
|
||||
|
||||
const config = getListConfig(list)
|
||||
let items = readManualItems(list.configJson)
|
||||
const existing = new Set(items.map((i) => i.value.toLowerCase()))
|
||||
const existing = new Set(items.map((i) => `${i.kind}:${i.value.toLowerCase()}`))
|
||||
|
||||
const toAdd: ListConfigItem[] = []
|
||||
|
||||
for (const token of (input.values ?? []).flatMap((v) => splitListPlaintext(v))) {
|
||||
toAdd.push(parseListEntry(token))
|
||||
}
|
||||
for (const raw of input.items ?? []) {
|
||||
toAdd.push(normalizeInputItem(raw))
|
||||
}
|
||||
|
||||
const tokens = values.flatMap((v) => splitListPlaintext(v))
|
||||
const added: ListConfigItem[] = []
|
||||
|
||||
for (const token of tokens) {
|
||||
const parsed = parseListEntry(token)
|
||||
const key = parsed.value.toLowerCase()
|
||||
for (const parsed of toAdd) {
|
||||
if (parsed.kind === 'list') {
|
||||
if (parsed.value === listId) {
|
||||
throw new Error('Список не может ссылаться на себя')
|
||||
}
|
||||
const child = repos.getIpList(db, parsed.value)
|
||||
if (!child) {
|
||||
throw new Error(`Список не найден: ${parsed.value}`)
|
||||
}
|
||||
if (wouldCreateListCycle(db, listId, parsed.value)) {
|
||||
throw new Error('Добавление создаст циклическую ссылку списков')
|
||||
}
|
||||
}
|
||||
|
||||
const key = `${parsed.kind}:${parsed.value.toLowerCase()}`
|
||||
if (existing.has(key)) continue
|
||||
const resolved = await expandItem(parsed)
|
||||
|
||||
const resolved = await expandItem(db, parsed, new Set([listId]))
|
||||
const item: ListConfigItem = { ...parsed, resolved_cidrs: resolved }
|
||||
items.push(item)
|
||||
existing.add(key)
|
||||
@@ -110,16 +240,15 @@ export async function addListEntries(
|
||||
configJson: JSON.stringify(config),
|
||||
})
|
||||
|
||||
const entries = await rebuildManualListEntries(db, listId)
|
||||
await rebuildListCascade(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,
|
||||
entries: repos.listIpListEntries(db, listId).map((e) => e.cidr),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,16 +281,15 @@ export async function deleteListEntry(
|
||||
configJson: JSON.stringify(config),
|
||||
})
|
||||
|
||||
const entries = await rebuildManualListEntries(db, listId)
|
||||
await rebuildListCascade(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,
|
||||
entries: repos.listIpListEntries(db, listId).map((e) => e.cidr),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,9 +305,12 @@ export function mapListDetail(db: Db, listId: string) {
|
||||
item.resolved_cidrs?.length
|
||||
? item.resolved_cidrs
|
||||
: normalizeItemToCidrs(item)
|
||||
const child =
|
||||
item.kind === 'list' ? repos.getIpList(db, item.value) : null
|
||||
return {
|
||||
kind: item.kind,
|
||||
value: item.value,
|
||||
list_name: child?.name ?? null,
|
||||
resolved_count: resolved.length,
|
||||
resolved_cidrs: resolved,
|
||||
}
|
||||
@@ -187,6 +318,7 @@ export function mapListDetail(db: Db, listId: string) {
|
||||
: entries.map((cidr) => ({
|
||||
kind: 'cidr' as const,
|
||||
value: cidr,
|
||||
list_name: null as string | null,
|
||||
resolved_count: 1,
|
||||
resolved_cidrs: [cidr],
|
||||
}))
|
||||
|
||||
@@ -2,7 +2,11 @@ import { createHash } from 'node:crypto'
|
||||
import type { Db } from '@evofw/db'
|
||||
import { repos } from '@evofw/db'
|
||||
import { refreshAllHostnameRules } from '../policy/resolve-hostname.js'
|
||||
import { rebuildManualListEntries } from './entries.js'
|
||||
import {
|
||||
findParentListIds,
|
||||
rebuildListCascade,
|
||||
rebuildManualListEntries,
|
||||
} from './entries.js'
|
||||
|
||||
function uniq(cidrs: string[]): string[] {
|
||||
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
|
||||
@@ -126,7 +130,16 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
||||
lastError: null,
|
||||
})
|
||||
|
||||
repos.bumpAgentsForList(db, listId)
|
||||
// Cascade to parents that nest this list (skip self rebuild for non-manual —
|
||||
// CIDRs already replaced above).
|
||||
if (list.type === 'static' || list.type === 'domains') {
|
||||
await rebuildListCascade(db, listId)
|
||||
} else {
|
||||
repos.bumpAgentsForList(db, listId)
|
||||
for (const parentId of findParentListIds(db, listId)) {
|
||||
await rebuildListCascade(db, parentId)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
repos.updateIpList(db, listId, {
|
||||
lastError: err instanceof Error ? err.message : String(err),
|
||||
|
||||
Reference in New Issue
Block a user