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)) {
|
if (body.entries?.length && isManualListType(type)) {
|
||||||
try {
|
try {
|
||||||
await addListEntries(app.db, id, body.entries)
|
await addListEntries(app.db, id, { values: body.entries })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
repos.deleteIpList(app.db, id)
|
repos.deleteIpList(app.db, id)
|
||||||
throw new AppError(
|
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)
|
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
|
||||||
const body = listEntriesBodySchema.parse(req.body)
|
const body = listEntriesBodySchema.parse(req.body)
|
||||||
try {
|
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
|
return mapListDetail(app.db, l.id) ?? result
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new AppError(
|
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 { repos } from '@evofw/db'
|
||||||
import {
|
import {
|
||||||
isManualListType,
|
isManualListType,
|
||||||
|
listNestedChildIds,
|
||||||
normalizeItemToCidrs,
|
normalizeItemToCidrs,
|
||||||
parseListEntry,
|
parseListEntry,
|
||||||
readManualItems,
|
readManualItems,
|
||||||
splitListPlaintext,
|
splitListPlaintext,
|
||||||
type ListConfigItem,
|
type ListConfigItem,
|
||||||
|
type ListEntryInput,
|
||||||
} from '@evofw/shared'
|
} from '@evofw/shared'
|
||||||
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
|
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.kind === 'hostname') {
|
||||||
if (item.resolved_cidrs?.length) return item.resolved_cidrs
|
if (item.resolved_cidrs?.length) return item.resolved_cidrs
|
||||||
return resolveHostnameToCidrs(item.value)
|
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)
|
return normalizeItemToCidrs(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,42 +94,93 @@ async function expandItem(item: ListConfigItem): Promise<string[]> {
|
|||||||
export async function rebuildManualListEntries(
|
export async function rebuildManualListEntries(
|
||||||
db: Db,
|
db: Db,
|
||||||
listId: string,
|
listId: string,
|
||||||
|
visiting: Set<string> = new Set(),
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const list = repos.getIpList(db, listId)
|
const list = repos.getIpList(db, listId)
|
||||||
if (!list || !isManualListType(list.type)) return []
|
if (!list || !isManualListType(list.type)) return []
|
||||||
|
|
||||||
const config = getListConfig(list)
|
if (visiting.has(listId)) {
|
||||||
const items = readManualItems(list.configJson)
|
throw new Error(`Циклическая ссылка списков: ${listId}`)
|
||||||
const nextItems: ListConfigItem[] = []
|
}
|
||||||
const all: string[] = []
|
visiting.add(listId)
|
||||||
|
|
||||||
for (const item of items) {
|
try {
|
||||||
try {
|
const config = getListConfig(list)
|
||||||
const cidrs = await expandItem({
|
const items = readManualItems(list.configJson)
|
||||||
...item,
|
const nextItems: ListConfigItem[] = []
|
||||||
resolved_cidrs: undefined,
|
const all: string[] = []
|
||||||
})
|
|
||||||
nextItems.push({ ...item, resolved_cidrs: cidrs })
|
for (const item of items) {
|
||||||
all.push(...cidrs)
|
try {
|
||||||
} catch {
|
const cidrs = await expandItem(
|
||||||
nextItems.push({ ...item, resolved_cidrs: item.resolved_cidrs ?? [] })
|
db,
|
||||||
if (item.resolved_cidrs?.length) all.push(...item.resolved_cidrs)
|
{ ...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
|
for (const id of affected) {
|
||||||
delete config.domains
|
repos.bumpAgentsForList(db, id)
|
||||||
repos.updateIpList(db, listId, { configJson: JSON.stringify(config) })
|
}
|
||||||
|
return affected
|
||||||
|
}
|
||||||
|
|
||||||
const cidrs = uniq(all)
|
function normalizeInputItem(input: ListEntryInput): ListConfigItem {
|
||||||
repos.replaceIpListEntries(db, listId, cidrs)
|
if (input.kind === 'list') {
|
||||||
return cidrs
|
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(
|
export async function addListEntries(
|
||||||
db: Db,
|
db: Db,
|
||||||
listId: string,
|
listId: string,
|
||||||
values: string[],
|
input: { values?: string[]; items?: ListEntryInput[] },
|
||||||
): Promise<{ items: ListConfigItem[]; entries: string[] }> {
|
): Promise<{ items: ListConfigItem[]; entries: string[] }> {
|
||||||
const list = repos.getIpList(db, listId)
|
const list = repos.getIpList(db, listId)
|
||||||
if (!list) throw new Error('List not found')
|
if (!list) throw new Error('List not found')
|
||||||
@@ -81,16 +190,37 @@ export async function addListEntries(
|
|||||||
|
|
||||||
const config = getListConfig(list)
|
const config = getListConfig(list)
|
||||||
let items = readManualItems(list.configJson)
|
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[] = []
|
const added: ListConfigItem[] = []
|
||||||
|
|
||||||
for (const token of tokens) {
|
for (const parsed of toAdd) {
|
||||||
const parsed = parseListEntry(token)
|
if (parsed.kind === 'list') {
|
||||||
const key = parsed.value.toLowerCase()
|
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
|
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 }
|
const item: ListConfigItem = { ...parsed, resolved_cidrs: resolved }
|
||||||
items.push(item)
|
items.push(item)
|
||||||
existing.add(key)
|
existing.add(key)
|
||||||
@@ -110,16 +240,15 @@ export async function addListEntries(
|
|||||||
configJson: JSON.stringify(config),
|
configJson: JSON.stringify(config),
|
||||||
})
|
})
|
||||||
|
|
||||||
const entries = await rebuildManualListEntries(db, listId)
|
await rebuildListCascade(db, listId)
|
||||||
repos.updateIpList(db, listId, {
|
repos.updateIpList(db, listId, {
|
||||||
refreshedAt: new Date().toISOString(),
|
refreshedAt: new Date().toISOString(),
|
||||||
lastError: null,
|
lastError: null,
|
||||||
})
|
})
|
||||||
repos.bumpAgentsForList(db, listId)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: readManualItems(repos.getIpList(db, listId)!.configJson),
|
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),
|
configJson: JSON.stringify(config),
|
||||||
})
|
})
|
||||||
|
|
||||||
const entries = await rebuildManualListEntries(db, listId)
|
await rebuildListCascade(db, listId)
|
||||||
repos.updateIpList(db, listId, {
|
repos.updateIpList(db, listId, {
|
||||||
refreshedAt: new Date().toISOString(),
|
refreshedAt: new Date().toISOString(),
|
||||||
lastError: null,
|
lastError: null,
|
||||||
})
|
})
|
||||||
repos.bumpAgentsForList(db, listId)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: readManualItems(repos.getIpList(db, listId)!.configJson),
|
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?.length
|
||||||
? item.resolved_cidrs
|
? item.resolved_cidrs
|
||||||
: normalizeItemToCidrs(item)
|
: normalizeItemToCidrs(item)
|
||||||
|
const child =
|
||||||
|
item.kind === 'list' ? repos.getIpList(db, item.value) : null
|
||||||
return {
|
return {
|
||||||
kind: item.kind,
|
kind: item.kind,
|
||||||
value: item.value,
|
value: item.value,
|
||||||
|
list_name: child?.name ?? null,
|
||||||
resolved_count: resolved.length,
|
resolved_count: resolved.length,
|
||||||
resolved_cidrs: resolved,
|
resolved_cidrs: resolved,
|
||||||
}
|
}
|
||||||
@@ -187,6 +318,7 @@ export function mapListDetail(db: Db, listId: string) {
|
|||||||
: entries.map((cidr) => ({
|
: entries.map((cidr) => ({
|
||||||
kind: 'cidr' as const,
|
kind: 'cidr' as const,
|
||||||
value: cidr,
|
value: cidr,
|
||||||
|
list_name: null as string | null,
|
||||||
resolved_count: 1,
|
resolved_count: 1,
|
||||||
resolved_cidrs: [cidr],
|
resolved_cidrs: [cidr],
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { createHash } from 'node:crypto'
|
|||||||
import type { Db } from '@evofw/db'
|
import type { Db } from '@evofw/db'
|
||||||
import { repos } from '@evofw/db'
|
import { repos } from '@evofw/db'
|
||||||
import { refreshAllHostnameRules } from '../policy/resolve-hostname.js'
|
import { refreshAllHostnameRules } from '../policy/resolve-hostname.js'
|
||||||
import { rebuildManualListEntries } from './entries.js'
|
import {
|
||||||
|
findParentListIds,
|
||||||
|
rebuildListCascade,
|
||||||
|
rebuildManualListEntries,
|
||||||
|
} from './entries.js'
|
||||||
|
|
||||||
function uniq(cidrs: string[]): string[] {
|
function uniq(cidrs: string[]): string[] {
|
||||||
return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort()
|
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,
|
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) {
|
} catch (err) {
|
||||||
repos.updateIpList(db, listId, {
|
repos.updateIpList(db, listId, {
|
||||||
lastError: err instanceof Error ? err.message : String(err),
|
lastError: err instanceof Error ? err.message : String(err),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const overviewNav = [
|
|||||||
|
|
||||||
const opsNav = [
|
const opsNav = [
|
||||||
{ to: '/agents', label: 'Агенты', icon: ServerIcon, exact: false },
|
{ 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: '/rules', label: 'Наборы правил', icon: ShieldIcon, exact: false },
|
||||||
{ to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false },
|
{ to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false },
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface RouteBreadcrumbLoaderData {
|
|||||||
const routeTitles: Record<string, string> = {
|
const routeTitles: Record<string, string> = {
|
||||||
'/': 'Панель управления',
|
'/': 'Панель управления',
|
||||||
'/agents': 'Агенты',
|
'/agents': 'Агенты',
|
||||||
'/lists': 'Списки IP',
|
'/lists': 'Списки',
|
||||||
'/rules': 'Наборы правил',
|
'/rules': 'Наборы правил',
|
||||||
'/stats': 'Статистика',
|
'/stats': 'Статистика',
|
||||||
'/settings': 'Настройки',
|
'/settings': 'Настройки',
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface ResourcePageProps<T extends object> {
|
|||||||
}) => ReactNode
|
}) => ReactNode
|
||||||
toolbarExtra?: ReactNode
|
toolbarExtra?: ReactNode
|
||||||
hideHeader?: boolean
|
hideHeader?: boolean
|
||||||
|
onRowClick?: (row: T) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourcePageSkeleton() {
|
function ResourcePageSkeleton() {
|
||||||
@@ -124,6 +125,7 @@ export function ResourcePage<T extends object>({
|
|||||||
selectionToolbar,
|
selectionToolbar,
|
||||||
toolbarExtra,
|
toolbarExtra,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
|
onRowClick,
|
||||||
}: ResourcePageProps<T>) {
|
}: ResourcePageProps<T>) {
|
||||||
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||||
const activeTab = controlledTab ?? internalTab
|
const activeTab = controlledTab ?? internalTab
|
||||||
@@ -287,6 +289,7 @@ export function ResourcePage<T extends object>({
|
|||||||
recordCount={filteredData.length}
|
recordCount={filteredData.length}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
tableLayout={{ dense: true }}
|
tableLayout={{ dense: true }}
|
||||||
|
onRowClick={onRowClick}
|
||||||
>
|
>
|
||||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
{!hideHeader ? (
|
{!hideHeader ? (
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
|||||||
ip: 'info-light',
|
ip: 'info-light',
|
||||||
cidr: 'secondary',
|
cidr: 'secondary',
|
||||||
hostname: 'warning-light',
|
hostname: 'warning-light',
|
||||||
|
list: 'info-light',
|
||||||
unknown: 'outline',
|
unknown: 'outline',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
ip: 'IP',
|
ip: 'IP',
|
||||||
cidr: 'CIDR',
|
cidr: 'CIDR',
|
||||||
hostname: 'Домен',
|
hostname: 'Домен',
|
||||||
|
list: 'Список',
|
||||||
unknown: 'Неизвестно',
|
unknown: 'Неизвестно',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ export const listQueryOptions = (id: string) =>
|
|||||||
last_error?: string | null
|
last_error?: string | null
|
||||||
entries: string[]
|
entries: string[]
|
||||||
items: {
|
items: {
|
||||||
kind: 'ip' | 'cidr' | 'hostname'
|
kind: 'ip' | 'cidr' | 'hostname' | 'list'
|
||||||
value: string
|
value: string
|
||||||
|
list_name?: string | null
|
||||||
resolved_count: number
|
resolved_count: number
|
||||||
resolved_cidrs: string[]
|
resolved_cidrs: string[]
|
||||||
}[]
|
}[]
|
||||||
|
|||||||
@@ -1,378 +1,10 @@
|
|||||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, redirect } 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'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/lists/$id')({
|
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
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 { useCallback, useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { z } from 'zod'
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import {
|
import {
|
||||||
DataGridMutedCell,
|
DataGridMutedCell,
|
||||||
@@ -13,11 +28,12 @@ import {
|
|||||||
} from '@/components/data-grid-cell'
|
} from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { listsQueryOptions } from '@/queries'
|
import { listQueryOptions, listsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
|
import { Textarea } from '@evofw/ui/components/textarea'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -33,35 +49,75 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@evofw/ui/components/sheet'
|
} from '@evofw/ui/components/sheet'
|
||||||
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
import {
|
import {
|
||||||
guessListSourceFromInput,
|
guessListSourceFromInput,
|
||||||
isManualListType,
|
isManualListType,
|
||||||
type IpList,
|
type IpList,
|
||||||
|
type ListEntryKind,
|
||||||
} from '@evofw/shared'
|
} from '@evofw/shared'
|
||||||
|
|
||||||
|
const listsSearchSchema = z.object({
|
||||||
|
listId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/lists/')({
|
export const Route = createFileRoute('/_auth/lists/')({
|
||||||
|
validateSearch: (search) => listsSearchSchema.parse(search),
|
||||||
component: ListsPage,
|
component: ListsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
|
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
|
||||||
|
|
||||||
|
type ListItem = {
|
||||||
|
kind: ListEntryKind
|
||||||
|
value: string
|
||||||
|
list_name?: string | null
|
||||||
|
resolved_count: number
|
||||||
|
resolved_cidrs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IP lists catalog — ResourcePage.
|
* Lists — master-detail.
|
||||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
* 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
|
* Empty: https://reui.io/preview/base/empty-state-12
|
||||||
* Sheet: https://reui.io/preview/base/sheet-8
|
|
||||||
*/
|
*/
|
||||||
function ListsPage() {
|
function ListsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
|
const { listId } = Route.useSearch()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const listsQ = useQuery(listsQueryOptions())
|
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 [name, setName] = useState('')
|
||||||
const [source, setSource] = useState<CreateSource>('static')
|
const [source, setSource] = useState<CreateSource>('static')
|
||||||
const [extra, setExtra] = useState('')
|
const [extra, setExtra] = useState('')
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [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 [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({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -81,9 +137,9 @@ function ListsPage() {
|
|||||||
setName('')
|
setName('')
|
||||||
setExtra('')
|
setExtra('')
|
||||||
setSource('static')
|
setSource('static')
|
||||||
setSheetOpen(false)
|
setCreateOpen(false)
|
||||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||||
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
selectList(row.id)
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
@@ -95,21 +151,83 @@ function ListsPage() {
|
|||||||
toast.success('Обновлено')
|
toast.success('Обновлено')
|
||||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const remove = useMutation({
|
const removeList = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
|
apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
|
||||||
onSuccess: () => {
|
onSuccess: (_data, id) => {
|
||||||
toast.success('Удалён')
|
toast.success('Удалён')
|
||||||
setDeleteId(null)
|
setDeleteListId(null)
|
||||||
|
if (listId === id) selectList(undefined)
|
||||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
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 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: 'Поиск…' },
|
{ 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 === 'name') return item.name
|
||||||
if (field === 'type') return item.type
|
if (field === 'type') return item.type
|
||||||
return undefined
|
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) => {
|
const tabFilter = useCallback((item: IpList, tabId: string) => {
|
||||||
if (tabId === 'all') return true
|
if (tabId === 'all') return true
|
||||||
if (tabId === 'manual') return isManualListType(item.type)
|
if (tabId === 'manual') return isManualListType(item.type)
|
||||||
return item.type === tabId
|
return item.type === tabId
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const columns: ColumnDef<IpList>[] = useMemo(
|
const catalogColumns: ColumnDef<IpList>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
@@ -147,40 +298,40 @@ function ListsPage() {
|
|||||||
<DataGridColumnHeader column={column} title="Имя" />
|
<DataGridColumnHeader column={column} title="Имя" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Link
|
<button
|
||||||
to="/lists/$id"
|
type="button"
|
||||||
params={{ id: row.original.id }}
|
className={cn(
|
||||||
className="min-w-0"
|
'min-w-0 text-left',
|
||||||
|
listId === row.original.id && 'font-medium',
|
||||||
|
)}
|
||||||
|
onClick={() => selectList(row.original.id)}
|
||||||
>
|
>
|
||||||
<DataGridPrimaryCell accent="primary" title={row.original.name} />
|
<DataGridPrimaryCell
|
||||||
</Link>
|
accent={listId === row.original.id ? 'primary' : undefined}
|
||||||
|
title={row.original.name}
|
||||||
|
subtitle={
|
||||||
|
listId === row.original.id ? 'выбран' : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Источник" />
|
<DataGridColumnHeader column={column} title="Тип" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.type} />,
|
cell: ({ row }) => <StatusBadge status={row.original.type} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'entry_count',
|
accessorKey: 'entry_count',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Записей" />
|
<DataGridColumnHeader column={column} title="#" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="tabular-nums">{row.original.entry_count ?? 0}</span>
|
<span className="tabular-nums text-muted-foreground">
|
||||||
),
|
{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>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -190,21 +341,19 @@ function ListsPage() {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const l = row.original
|
const l = row.original
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-0.5">
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
render={<Link to="/lists/$id" params={{ id: l.id }} />}
|
|
||||||
>
|
|
||||||
Открыть
|
|
||||||
</Button>
|
|
||||||
{!isManualListType(l.type) ? (
|
{!isManualListType(l.type) ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
onClick={() => refresh.mutate(l.id)}
|
aria-label="Refresh"
|
||||||
|
disabled={refresh.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
refresh.mutate(l.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Refresh
|
<RefreshCwIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
@@ -212,7 +361,10 @@ function ListsPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
aria-label="Удалить"
|
aria-label="Удалить"
|
||||||
onClick={() => setDeleteId(l.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDeleteListId(l.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
</Button>
|
</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 =
|
const canCreate =
|
||||||
Boolean(name.trim()) &&
|
Boolean(name.trim()) &&
|
||||||
(source === 'static' || Boolean(extra.trim()))
|
(source === 'static' || Boolean(extra.trim()))
|
||||||
|
|
||||||
|
const canAdd =
|
||||||
|
addKind === 'list' ? Boolean(addListRef) : Boolean(addValue.trim())
|
||||||
|
|
||||||
|
const nestedCandidates = items.filter(
|
||||||
|
(l) => l.id !== listId,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showCatalogOnMobile = !listId
|
||||||
|
const showDetailOnMobile = Boolean(listId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Списки IP"
|
title="Списки"
|
||||||
description="Ручные plaintext-списки (IP, CIDR, домены) и внешние источники"
|
description="Создайте список и заполните его IP, CIDR, доменами или другими списками"
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
Новый список
|
Новый список
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResourcePage
|
<div className="grid gap-4 lg:grid-cols-[minmax(280px,360px)_1fr] lg:items-start">
|
||||||
title="Списки"
|
<div
|
||||||
hideHeader
|
className={cn(
|
||||||
data={items}
|
'min-w-0',
|
||||||
columns={columns}
|
showCatalogOnMobile ? 'block' : 'hidden lg:block',
|
||||||
getRowId={(r) => r.id}
|
)}
|
||||||
filterFields={filterFields}
|
>
|
||||||
filters={filters}
|
<ResourcePage
|
||||||
onFiltersChange={setFilters}
|
title="Каталог"
|
||||||
onClearFilters={() => setFilters([])}
|
hideHeader
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
data={items}
|
||||||
tabs={[
|
columns={catalogColumns}
|
||||||
{ id: 'all', label: 'Все' },
|
getRowId={(r) => r.id}
|
||||||
{ id: 'manual', label: 'Ручной' },
|
filterFields={catalogFilterFields}
|
||||||
{ id: 'json_url', label: 'JSON по URL' },
|
filters={catalogFilters}
|
||||||
{ id: 'evobgp_community', label: 'EvoBGP' },
|
onFiltersChange={setCatalogFilters}
|
||||||
]}
|
onClearFilters={() => setCatalogFilters([])}
|
||||||
activeTab={activeTab}
|
getFilterFieldValue={getCatalogFilterValue}
|
||||||
onTabChange={setActiveTab}
|
tabs={[
|
||||||
tabFilter={tabFilter}
|
{ id: 'all', label: 'Все' },
|
||||||
isLoading={listsQ.isLoading}
|
{ id: 'manual', label: 'Ручной' },
|
||||||
isError={listsQ.isError}
|
{ id: 'json_url', label: 'JSON' },
|
||||||
error={listsQ.error}
|
{ id: 'evobgp_community', label: 'EvoBGP' },
|
||||||
onRetry={() => void listsQ.refetch()}
|
]}
|
||||||
emptyState={{
|
activeTab={activeTab}
|
||||||
title: 'Пусто',
|
onTabChange={setActiveTab}
|
||||||
description: 'Создайте первый список.',
|
tabFilter={tabFilter}
|
||||||
action: (
|
onRowClick={(row) => selectList(row.id)}
|
||||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
isLoading={listsQ.isLoading}
|
||||||
Новый список
|
isError={listsQ.isError}
|
||||||
</Button>
|
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
|
<ConfirmDialog
|
||||||
open={deleteId !== null}
|
open={deleteValue !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setDeleteId(null)
|
if (!open) setDeleteValue(null)
|
||||||
}}
|
}}
|
||||||
title="Удалить список?"
|
title="Удалить запись?"
|
||||||
description="Записи списка будут удалены."
|
description={
|
||||||
|
deleteValue
|
||||||
|
? `Будет удалено: ${deleteValue}`
|
||||||
|
: 'Запись будет удалена из списка.'
|
||||||
|
}
|
||||||
onConfirm={() => {
|
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">
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
<SheetHeader className="shrink-0">
|
<SheetHeader className="shrink-0">
|
||||||
<SheetTitle>Новый список</SheetTitle>
|
<SheetTitle>Новый список</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Записи IP / CIDR / домены добавляются в таблице на странице
|
После создания заполните содержимое в таблице справа.
|
||||||
списка.
|
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||||
@@ -355,7 +753,7 @@ function ListsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
<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>
|
||||||
<Button
|
<Button
|
||||||
@@ -367,6 +765,89 @@ function ListsPage() {
|
|||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
<Sheet open={addOpen} onOpenChange={setAddOpen}>
|
||||||
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
|
<SheetHeader className="shrink-0">
|
||||||
|
<SheetTitle>Добавить запись</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Выберите вид и значение. Для IP/CIDR/доменов можно вставить
|
||||||
|
несколько строк сразу.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<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>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
listEntriesBodySchema,
|
||||||
|
listEntryKindSchema,
|
||||||
|
listNestedChildIds,
|
||||||
|
parseListEntry,
|
||||||
|
} from '../src/list-entries.js'
|
||||||
|
|
||||||
|
describe('list entry kinds', () => {
|
||||||
|
it('includes list kind', () => {
|
||||||
|
expect(listEntryKindSchema.parse('list')).toBe('list')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses ip/cidr/hostname but not list ids from plaintext', () => {
|
||||||
|
expect(parseListEntry('8.8.8.8').kind).toBe('ip')
|
||||||
|
expect(parseListEntry('10.0.0.0/8').kind).toBe('cidr')
|
||||||
|
expect(parseListEntry('bad.example.com').kind).toBe('hostname')
|
||||||
|
expect(() => parseListEntry('not-a-valid-token')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts values or items in body', () => {
|
||||||
|
expect(
|
||||||
|
listEntriesBodySchema.parse({ values: ['8.8.8.8'] }).values,
|
||||||
|
).toEqual(['8.8.8.8'])
|
||||||
|
expect(
|
||||||
|
listEntriesBodySchema.parse({
|
||||||
|
items: [{ kind: 'list', value: 'abc' }],
|
||||||
|
}).items,
|
||||||
|
).toEqual([{ kind: 'list', value: 'abc' }])
|
||||||
|
expect(() => listEntriesBodySchema.parse({})).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads nested child ids from config', () => {
|
||||||
|
const ids = listNestedChildIds(
|
||||||
|
JSON.stringify({
|
||||||
|
items: [
|
||||||
|
{ kind: 'ip', value: '1.1.1.1' },
|
||||||
|
{ kind: 'list', value: 'child-1' },
|
||||||
|
{ kind: 'list', value: 'child-2' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(ids).toEqual(['child-1', 'child-2'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -21,7 +21,7 @@ export function isManualListType(type: string): boolean {
|
|||||||
return type === 'static' || type === 'domains'
|
return type === 'static' || type === 'domains'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listEntryKindSchema = z.enum(['ip', 'cidr', 'hostname'])
|
export const listEntryKindSchema = z.enum(['ip', 'cidr', 'hostname', 'list'])
|
||||||
export type ListEntryKind = z.infer<typeof listEntryKindSchema>
|
export type ListEntryKind = z.infer<typeof listEntryKindSchema>
|
||||||
|
|
||||||
export const listConfigItemSchema = z.object({
|
export const listConfigItemSchema = z.object({
|
||||||
@@ -35,6 +35,7 @@ export const listEntryKindLabels: Record<ListEntryKind, string> = {
|
|||||||
ip: 'IP',
|
ip: 'IP',
|
||||||
cidr: 'CIDR',
|
cidr: 'CIDR',
|
||||||
hostname: 'Домен',
|
hostname: 'Домен',
|
||||||
|
list: 'Список',
|
||||||
}
|
}
|
||||||
|
|
||||||
const IPV4 =
|
const IPV4 =
|
||||||
@@ -96,7 +97,8 @@ export function normalizeItemToCidrs(
|
|||||||
if (item.kind === 'cidr') {
|
if (item.kind === 'cidr') {
|
||||||
return [item.value]
|
return [item.value]
|
||||||
}
|
}
|
||||||
return resolvedHostCidrs ?? []
|
// hostname | list — CIDRs come from resolution / nested list materialization
|
||||||
|
return resolvedHostCidrs ?? item.resolved_cidrs ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readManualItems(
|
export function readManualItems(
|
||||||
@@ -135,15 +137,42 @@ export function readManualItems(
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nested list refs in config.items (kind=list, value=child list id). */
|
||||||
|
export function listNestedChildIds(configJson: string): string[] {
|
||||||
|
return readManualItems(configJson)
|
||||||
|
.filter((i) => i.kind === 'list')
|
||||||
|
.map((i) => i.value)
|
||||||
|
}
|
||||||
|
|
||||||
export function guessListSourceFromInput(extra: string): 'static' | 'json_url' {
|
export function guessListSourceFromInput(extra: string): 'static' | 'json_url' {
|
||||||
const t = extra.trim()
|
const t = extra.trim()
|
||||||
if (/^https?:\/\//i.test(t)) return 'json_url'
|
if (/^https?:\/\//i.test(t)) return 'json_url'
|
||||||
return 'static'
|
return 'static'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listEntriesBodySchema = z.object({
|
export const listEntryInputSchema = z.object({
|
||||||
values: z.array(z.string().min(1)).min(1),
|
kind: listEntryKindSchema,
|
||||||
|
value: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
export type ListEntryInput = z.infer<typeof listEntryInputSchema>
|
||||||
|
|
||||||
|
export const listEntriesBodySchema = z
|
||||||
|
.object({
|
||||||
|
/** Legacy plaintext tokens (auto-classified; not for kind=list). */
|
||||||
|
values: z.array(z.string().min(1)).optional(),
|
||||||
|
/** Structured entries including nested list refs. */
|
||||||
|
items: z.array(listEntryInputSchema).optional(),
|
||||||
|
})
|
||||||
|
.superRefine((body, ctx) => {
|
||||||
|
const hasValues = Boolean(body.values?.length)
|
||||||
|
const hasItems = Boolean(body.items?.length)
|
||||||
|
if (!hasValues && !hasItems) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Нужны values или items',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export const deleteListEntryBodySchema = z.object({
|
export const deleteListEntryBodySchema = z.object({
|
||||||
value: z.string().min(1),
|
value: z.string().min(1),
|
||||||
|
|||||||
Reference in New Issue
Block a user