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 <[email protected]>
This commit is contained in:
@@ -9,9 +9,17 @@ import {
|
|||||||
putAgentPolicySetsBodySchema,
|
putAgentPolicySetsBodySchema,
|
||||||
patchAgentBodySchema,
|
patchAgentBodySchema,
|
||||||
cloneFromBodySchema,
|
cloneFromBodySchema,
|
||||||
|
listEntriesBodySchema,
|
||||||
|
deleteListEntryBodySchema,
|
||||||
|
isManualListType,
|
||||||
} from '@evofw/shared'
|
} from '@evofw/shared'
|
||||||
import { AppError } from '../plugins/error-handler.js'
|
import { AppError } from '../plugins/error-handler.js'
|
||||||
import { refreshIpList } from '../services/lists/refresh.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 { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
||||||
import {
|
import {
|
||||||
resolveAndStoreHostnameRule,
|
resolveAndStoreHostnameRule,
|
||||||
@@ -279,19 +287,29 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
|
|
||||||
app.post('/lists', async (req) => {
|
app.post('/lists', async (req) => {
|
||||||
const body = createIpListBodySchema.parse(req.body)
|
const body = createIpListBodySchema.parse(req.body)
|
||||||
|
const type =
|
||||||
|
body.type === 'domains' ? 'static' : body.type
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const list = repos.insertIpList(app.db, {
|
const list = repos.insertIpList(app.db, {
|
||||||
id,
|
id,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
type: body.type,
|
type,
|
||||||
configJson: JSON.stringify(body.config ?? {}),
|
configJson: JSON.stringify(body.config ?? {}),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
if (body.entries?.length) {
|
if (body.entries?.length && isManualListType(type)) {
|
||||||
repos.replaceIpListEntries(app.db, id, body.entries)
|
try {
|
||||||
}
|
await addListEntries(app.db, id, body.entries)
|
||||||
if (body.type !== 'static') {
|
} 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)
|
await refreshIpList(app.db, id)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -305,33 +323,54 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>('/lists/:id', async (req) => {
|
app.get<{ Params: { id: string } }>('/lists/:id', async (req) => {
|
||||||
const l = repos.getIpList(app.db, req.params.id)
|
const detail = mapListDetail(app.db, req.params.id)
|
||||||
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
|
if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404)
|
||||||
return {
|
return detail
|
||||||
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,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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) => {
|
app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => {
|
||||||
await refreshIpList(app.db, req.params.id)
|
await refreshIpList(app.db, req.params.id)
|
||||||
const l = repos.getIpList(app.db, req.params.id)
|
const detail = mapListDetail(app.db, req.params.id)
|
||||||
if (!l) throw new AppError('NOT_FOUND', 'List not found', 404)
|
if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404)
|
||||||
return {
|
return detail
|
||||||
id: l.id,
|
|
||||||
content_hash: l.contentHash,
|
|
||||||
refreshed_at: l.refreshedAt,
|
|
||||||
last_error: l.lastError,
|
|
||||||
entry_count: repos.listIpListEntries(app.db, l.id).length,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => {
|
app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => {
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(list.configJson || '{}') as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expandItem(item: ListConfigItem): Promise<string[]> {
|
||||||
|
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<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[] = []
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { resolve4, resolve6 } from 'node:dns/promises'
|
|
||||||
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 { 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()
|
||||||
@@ -46,27 +47,6 @@ async function fetchJsonUrl(url: string): Promise<string[]> {
|
|||||||
return uniq(out)
|
return uniq(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDomains(domains: string[]): Promise<string[]> {
|
|
||||||
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(
|
async function fetchEvobgpCommunity(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
token: string,
|
token: string,
|
||||||
@@ -118,21 +98,13 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let cidrs: string[] = []
|
let cidrs: string[] = []
|
||||||
if (list.type === 'static') {
|
if (list.type === 'static' || list.type === 'domains') {
|
||||||
cidrs = repos.listIpListEntries(db, listId).map((e) => e.cidr)
|
cidrs = await rebuildManualListEntries(db, listId)
|
||||||
} else if (list.type === 'json_url') {
|
} else if (list.type === 'json_url') {
|
||||||
const url = String(config.url ?? '')
|
const url = String(config.url ?? '')
|
||||||
if (!url) throw new Error('config.url required')
|
if (!url) throw new Error('config.url required')
|
||||||
cidrs = await fetchJsonUrl(url)
|
cidrs = await fetchJsonUrl(url)
|
||||||
repos.replaceIpListEntries(db, listId, cidrs)
|
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') {
|
} else if (list.type === 'evobgp_community') {
|
||||||
const apiUrl =
|
const apiUrl =
|
||||||
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url')
|
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url')
|
||||||
@@ -154,10 +126,7 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bump all agents so they re-fetch policy
|
repos.bumpAgentsForList(db, listId)
|
||||||
for (const a of repos.listAgents(db)) {
|
|
||||||
if (a.status === 'approved') repos.bumpAgentGeneration(db, a.id)
|
|
||||||
}
|
|
||||||
} 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),
|
||||||
@@ -166,11 +135,8 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import { refreshAllHostnameRules } from '../policy/resolve-hostname.js'
|
|
||||||
|
|
||||||
export async function refreshAllLists(db: Db): Promise<void> {
|
export async function refreshAllLists(db: Db): Promise<void> {
|
||||||
for (const list of repos.listIpLists(db)) {
|
for (const list of repos.listIpLists(db)) {
|
||||||
if (list.type === 'static') continue
|
|
||||||
await refreshIpList(db, list.id)
|
await refreshIpList(db, list.id)
|
||||||
}
|
}
|
||||||
await refreshAllHostnameRules(db)
|
await refreshAllHostnameRules(db)
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
|||||||
revoked: 'secondary',
|
revoked: 'secondary',
|
||||||
disabled: 'secondary',
|
disabled: 'secondary',
|
||||||
static: 'secondary',
|
static: 'secondary',
|
||||||
domains: 'info-light',
|
domains: 'secondary',
|
||||||
|
manual: 'secondary',
|
||||||
json_url: 'info-light',
|
json_url: 'info-light',
|
||||||
evobgp_community: 'info-light',
|
evobgp_community: 'info-light',
|
||||||
|
ip: 'info-light',
|
||||||
|
cidr: 'secondary',
|
||||||
|
hostname: 'warning-light',
|
||||||
unknown: 'outline',
|
unknown: 'outline',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +67,14 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
degraded: 'Slow',
|
degraded: 'Slow',
|
||||||
down: 'Down',
|
down: 'Down',
|
||||||
expired: 'Истёк',
|
expired: 'Истёк',
|
||||||
static: 'static',
|
static: 'Ручной',
|
||||||
domains: 'domains',
|
domains: 'Ручной',
|
||||||
json_url: 'json_url',
|
manual: 'Ручной',
|
||||||
evobgp_community: 'evobgp_community',
|
json_url: 'JSON по URL',
|
||||||
|
evobgp_community: 'EvoBGP community',
|
||||||
|
ip: 'IP',
|
||||||
|
cidr: 'CIDR',
|
||||||
|
hostname: 'Домен',
|
||||||
unknown: 'Неизвестно',
|
unknown: 'Неизвестно',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,25 @@ export const listQueryOptions = (id: string) =>
|
|||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['lists', id],
|
queryKey: ['lists', id],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<
|
apiFetch<{
|
||||||
IpList & {
|
id: string
|
||||||
entries: string[]
|
name: string
|
||||||
}
|
type: IpList['type']
|
||||||
>(`/api/v1/lists/${id}`),
|
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 = () =>
|
export const policySetsQueryOptions = () =>
|
||||||
|
|||||||
@@ -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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
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 { useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import { PageShell, DetailPanel, ResourcePage } from '@/components/reui-kit'
|
import { PageShell, DetailPanel, 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 { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
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 { 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 { 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 { 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,
|
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.
|
* List detail — tabular entries + switcher.
|
||||||
* Preview: https://reui.io/preview/base/data-grid-filtering-1 · empty-state-12
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · sheet-8 · empty-state-12
|
||||||
*/
|
*/
|
||||||
function ListDetailPage() {
|
function ListDetailPage() {
|
||||||
const { id } = Route.useParams()
|
const { id } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const listQ = useQuery(listQueryOptions(id))
|
const listQ = useQuery(listQueryOptions(id))
|
||||||
|
const listsQ = useQuery(listsQueryOptions())
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [plaintext, setPlaintext] = useState('')
|
||||||
|
const [deleteValue, setDeleteValue] = useState<string | null>(null)
|
||||||
|
|
||||||
const refresh = useMutation({
|
const refresh = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -40,40 +69,108 @@ function ListDetailPage() {
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const entries: EntryRow[] = useMemo(
|
const addEntries = useMutation({
|
||||||
() =>
|
mutationFn: () =>
|
||||||
(listQ.data?.entries ?? []).map((cidr, i) => ({
|
apiFetch(`/api/v1/lists/${id}/entries`, {
|
||||||
id: `${i}-${cidr}`,
|
method: 'POST',
|
||||||
cidr,
|
body: JSON.stringify({ values: [plaintext] }),
|
||||||
})),
|
}),
|
||||||
[listQ.data?.entries],
|
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(
|
const filterFields: FilterFieldConfig[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: 'cidr',
|
key: 'value',
|
||||||
label: 'Entry',
|
label: 'Значение',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
placeholder: 'Поиск CIDR / домен…',
|
placeholder: 'Поиск…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'kind',
|
||||||
|
label: 'Вид',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'ip', label: 'IP' },
|
||||||
|
{ value: 'cidr', label: 'CIDR' },
|
||||||
|
{ value: 'hostname', label: 'Домен' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns: ColumnDef<EntryRow>[] = useMemo(
|
const columns: ColumnDef<ListItem>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'cidr',
|
accessorKey: 'value',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Entry" />
|
<DataGridColumnHeader column={column} title="Значение" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell accent="mono" title={row.original.cidr} />
|
<DataGridPrimaryCell
|
||||||
|
accent="mono"
|
||||||
|
title={row.original.value}
|
||||||
|
subtitle={
|
||||||
|
row.original.resolved_count > 0
|
||||||
|
? `${row.original.resolved_count} CIDR`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'kind',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Вид" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.kind} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
manual ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
aria-label="Удалить"
|
||||||
|
onClick={() => setDeleteValue(row.original.value)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[],
|
[manual],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (listQ.isLoading) {
|
if (listQ.isLoading) {
|
||||||
@@ -85,7 +182,7 @@ function ListDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!listQ.data) {
|
if (!list) {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<DetailPanel>
|
<DetailPanel>
|
||||||
@@ -102,30 +199,50 @@ function ListDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = listQ.data
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<DetailPanel>
|
<DetailPanel>
|
||||||
<DetailPanel.Header
|
<DetailPanel.Header
|
||||||
title={list.name}
|
title={list.name}
|
||||||
description={`Тип ${list.type}`}
|
description="Содержимое списка для правил политики"
|
||||||
actions={
|
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} />
|
<StatusBadge status={list.type} />
|
||||||
{list.type !== 'static' ? (
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant="outline"
|
||||||
variant="outline"
|
disabled={refresh.isPending}
|
||||||
disabled={refresh.isPending}
|
onClick={() => refresh.mutate()}
|
||||||
onClick={() => refresh.mutate()}
|
>
|
||||||
>
|
<RefreshCwIcon
|
||||||
<RefreshCwIcon
|
className={
|
||||||
className={
|
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
|
||||||
refresh.isPending ? 'size-3.5 animate-spin' : 'size-3.5'
|
}
|
||||||
}
|
/>
|
||||||
/>
|
Refresh
|
||||||
Refresh
|
</Button>
|
||||||
|
{manual ? (
|
||||||
|
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||||
|
Добавить
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button variant="outline" size="sm" render={<Link to="/lists" />}>
|
<Button variant="outline" size="sm" render={<Link to="/lists" />}>
|
||||||
@@ -140,59 +257,122 @@ function ListDetailPage() {
|
|||||||
{
|
{
|
||||||
id: 'count',
|
id: 'count',
|
||||||
icon: <HashIcon aria-hidden />,
|
icon: <HashIcon aria-hidden />,
|
||||||
label: 'Entries',
|
label: 'Записей',
|
||||||
description: String(entries.length),
|
description: String(items.length),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'type',
|
id: 'type',
|
||||||
icon: <TagIcon aria-hidden />,
|
icon: <TagIcon aria-hidden />,
|
||||||
label: 'Тип',
|
label: 'Источник',
|
||||||
description: list.type,
|
description:
|
||||||
|
list.type === 'json_url'
|
||||||
|
? 'JSON по URL'
|
||||||
|
: list.type === 'evobgp_community'
|
||||||
|
? 'EvoBGP community'
|
||||||
|
: 'Ручной',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'refreshed',
|
id: 'cidrs',
|
||||||
icon: <RefreshCwIcon aria-hidden />,
|
icon: <RefreshCwIcon aria-hidden />,
|
||||||
label: 'Refresh',
|
label: 'CIDR в политике',
|
||||||
description: list.last_error
|
description: String(list.entry_count ?? list.entries.length),
|
||||||
? list.last_error
|
|
||||||
: (list.refreshed_at ?? '—'),
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailPanel.Section
|
<DetailPanel.Section
|
||||||
title="Содержимое"
|
title="Содержимое"
|
||||||
description="CIDR / resolved prefixes из списка."
|
description={
|
||||||
|
manual
|
||||||
|
? 'IP, CIDR и домены в одном списке — вид определяется автоматически.'
|
||||||
|
: 'Записи из внешнего источника (только чтение).'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Entries"
|
title="Entries"
|
||||||
hideHeader
|
hideHeader
|
||||||
data={entries}
|
data={items}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowId={(r) => r.id}
|
getRowId={(r) => r.value}
|
||||||
filterFields={filterFields}
|
filterFields={filterFields}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
onClearFilters={() => setFilters([])}
|
onClearFilters={() => setFilters([])}
|
||||||
getFilterFieldValue={(item, field) =>
|
getFilterFieldValue={(item, field) => {
|
||||||
field === 'cidr' ? item.cidr : undefined
|
if (field === 'value') return item.value
|
||||||
}
|
if (field === 'kind') return item.kind
|
||||||
|
return undefined
|
||||||
|
}}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет entries',
|
title: 'Нет записей',
|
||||||
description:
|
description: manual
|
||||||
list.type === 'static'
|
? 'Добавьте IP, CIDR или домен — по одной строке или списком.'
|
||||||
? 'Добавьте CIDR при создании или обновите список.'
|
: 'Нажмите Refresh или проверьте источник.',
|
||||||
: 'Нажмите Refresh или проверьте источник.',
|
action: manual ? (
|
||||||
|
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
) : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</DetailPanel.Section>
|
</DetailPanel.Section>
|
||||||
|
|
||||||
{list.last_error ? (
|
{list.last_error ? (
|
||||||
<DataGridMutedCell className="text-destructive px-1">
|
<p className="text-destructive text-sm">{list.last_error}</p>
|
||||||
{list.last_error}
|
|
||||||
</DataGridMutedCell>
|
|
||||||
) : null}
|
) : null}
|
||||||
</DetailPanel>
|
</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>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import type { ColumnDef } from '@tanstack/react-table'
|
|||||||
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 { 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 { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import {
|
||||||
|
DataGridMutedCell,
|
||||||
|
DataGridPrimaryCell,
|
||||||
|
} 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 { listsQueryOptions } from '@/queries'
|
||||||
@@ -15,7 +18,6 @@ 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,
|
||||||
@@ -31,16 +33,23 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@evofw/ui/components/sheet'
|
} from '@evofw/ui/components/sheet'
|
||||||
import type { IpList } from '@evofw/shared'
|
import {
|
||||||
|
guessListSourceFromInput,
|
||||||
|
isManualListType,
|
||||||
|
type IpList,
|
||||||
|
} from '@evofw/shared'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/lists/')({
|
export const Route = createFileRoute('/_auth/lists/')({
|
||||||
component: ListsPage,
|
component: ListsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IP lists — ResourcePage.
|
* IP lists catalog — ResourcePage.
|
||||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* 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()
|
||||||
@@ -48,9 +57,7 @@ function ListsPage() {
|
|||||||
const listsQ = useQuery(listsQueryOptions())
|
const listsQ = useQuery(listsQueryOptions())
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [type, setType] = useState<
|
const [source, setSource] = useState<CreateSource>('static')
|
||||||
'static' | 'json_url' | 'domains' | 'evobgp_community'
|
|
||||||
>('static')
|
|
||||||
const [extra, setExtra] = useState('')
|
const [extra, setExtra] = useState('')
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
@@ -59,31 +66,21 @@ function ListsPage() {
|
|||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const config: Record<string, unknown> = {}
|
const config: Record<string, unknown> = {}
|
||||||
let entries: string[] | undefined
|
if (source === 'json_url') {
|
||||||
if (type === 'static') {
|
|
||||||
entries = extra
|
|
||||||
.split(/[\s,]+/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
} else if (type === 'json_url') {
|
|
||||||
config.url = extra.trim()
|
config.url = extra.trim()
|
||||||
} else if (type === 'domains') {
|
} else if (source === 'evobgp_community') {
|
||||||
config.domains = extra
|
|
||||||
.split(/[\s,]+/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
} else {
|
|
||||||
config.community_id = extra.trim()
|
config.community_id = extra.trim()
|
||||||
}
|
}
|
||||||
return apiFetch<{ id: string }>('/api/v1/lists', {
|
return apiFetch<{ id: string }>('/api/v1/lists', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, type, config, entries }),
|
body: JSON.stringify({ name, type: source, config }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: (row) => {
|
onSuccess: (row) => {
|
||||||
toast.success('Список создан')
|
toast.success('Список создан')
|
||||||
setName('')
|
setName('')
|
||||||
setExtra('')
|
setExtra('')
|
||||||
|
setSource('static')
|
||||||
setSheetOpen(false)
|
setSheetOpen(false)
|
||||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||||
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
||||||
@@ -117,13 +114,13 @@ function ListsPage() {
|
|||||||
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
|
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
|
||||||
{
|
{
|
||||||
key: 'type',
|
key: 'type',
|
||||||
label: 'Тип',
|
label: 'Источник',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'static', label: 'static' },
|
{ value: 'static', label: 'Ручной' },
|
||||||
{ value: 'json_url', label: 'json_url' },
|
{ value: 'json_url', label: 'JSON по URL' },
|
||||||
{ value: 'domains', label: 'domains' },
|
{ value: 'evobgp_community', label: 'EvoBGP community' },
|
||||||
{ value: 'evobgp_community', label: 'evobgp_community' },
|
{ value: 'domains', label: 'Ручной' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -138,6 +135,7 @@ function ListsPage() {
|
|||||||
|
|
||||||
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)
|
||||||
return item.type === tabId
|
return item.type === tabId
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -161,14 +159,14 @@ function ListsPage() {
|
|||||||
{
|
{
|
||||||
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="Entries" />
|
<DataGridColumnHeader column={column} title="Записей" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="tabular-nums">{row.original.entry_count ?? 0}</span>
|
<span className="tabular-nums">{row.original.entry_count ?? 0}</span>
|
||||||
@@ -177,7 +175,7 @@ function ListsPage() {
|
|||||||
{
|
{
|
||||||
id: 'refresh',
|
id: 'refresh',
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Refresh" />
|
<DataGridColumnHeader column={column} title="Обновление" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridMutedCell>
|
<DataGridMutedCell>
|
||||||
@@ -196,13 +194,11 @@ function ListsPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
render={
|
render={<Link to="/lists/$id" params={{ id: l.id }} />}
|
||||||
<Link to="/lists/$id" params={{ id: l.id }} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Открыть
|
Открыть
|
||||||
</Button>
|
</Button>
|
||||||
{l.type !== 'static' ? (
|
{!isManualListType(l.type) ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -228,11 +224,15 @@ function ListsPage() {
|
|||||||
[refresh],
|
[refresh],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const canCreate =
|
||||||
|
Boolean(name.trim()) &&
|
||||||
|
(source === 'static' || Boolean(extra.trim()))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Списки IP"
|
title="Списки IP"
|
||||||
description="static · JSON URL · domains · EvoBGP community"
|
description="Ручные plaintext-списки (IP, CIDR, домены) и внешние источники"
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||||
Новый список
|
Новый список
|
||||||
@@ -253,10 +253,9 @@ function ListsPage() {
|
|||||||
getFilterFieldValue={getFilterFieldValue}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
{ id: 'static', label: 'static' },
|
{ id: 'manual', label: 'Ручной' },
|
||||||
{ id: 'domains', label: 'domains' },
|
{ id: 'json_url', label: 'JSON по URL' },
|
||||||
{ id: 'json_url', label: 'json_url' },
|
{ id: 'evobgp_community', label: 'EvoBGP' },
|
||||||
{ id: 'evobgp_community', label: 'evobgp' },
|
|
||||||
]}
|
]}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
@@ -282,7 +281,7 @@ function ListsPage() {
|
|||||||
if (!open) setDeleteId(null)
|
if (!open) setDeleteId(null)
|
||||||
}}
|
}}
|
||||||
title="Удалить список?"
|
title="Удалить список?"
|
||||||
description="Entries и ссылки из правил останутся неконсистентны — удаляйте осторожно."
|
description="Записи списка будут удалены."
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (deleteId) remove.mutate(deleteId)
|
if (deleteId) remove.mutate(deleteId)
|
||||||
}}
|
}}
|
||||||
@@ -293,7 +292,10 @@ function ListsPage() {
|
|||||||
<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>
|
<SheetDescription>
|
||||||
|
Записи IP / CIDR / домены добавляются в таблице на странице
|
||||||
|
списка.
|
||||||
|
</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">
|
||||||
<Field>
|
<Field>
|
||||||
@@ -305,50 +307,59 @@ function ListsPage() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Тип</FieldLabel>
|
<FieldLabel>Источник</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={type}
|
value={source}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v) setType(v as typeof type)
|
if (v) setSource(v as CreateSource)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="static">static</SelectItem>
|
<SelectItem value="static">Ручной</SelectItem>
|
||||||
<SelectItem value="json_url">json_url</SelectItem>
|
<SelectItem value="json_url">JSON по URL</SelectItem>
|
||||||
<SelectItem value="domains">domains</SelectItem>
|
|
||||||
<SelectItem value="evobgp_community">
|
<SelectItem value="evobgp_community">
|
||||||
evobgp_community
|
EvoBGP community
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
{source === 'json_url' ? (
|
||||||
<FieldLabel htmlFor="list-extra">
|
<Field>
|
||||||
{type === 'static'
|
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||||
? 'CIDR (через пробел/запятую)'
|
<Input
|
||||||
: type === 'json_url'
|
id="list-url"
|
||||||
? 'URL JSON'
|
value={extra}
|
||||||
: type === 'domains'
|
placeholder="https://…"
|
||||||
? 'Домены'
|
onChange={(e) => {
|
||||||
: 'Community ID'}
|
const v = e.target.value
|
||||||
</FieldLabel>
|
setExtra(v)
|
||||||
<Textarea
|
if (guessListSourceFromInput(v) === 'json_url') {
|
||||||
id="list-extra"
|
setSource('json_url')
|
||||||
value={extra}
|
}
|
||||||
onChange={(e) => setExtra(e.target.value)}
|
}}
|
||||||
rows={3}
|
/>
|
||||||
/>
|
</Field>
|
||||||
</Field>
|
) : null}
|
||||||
|
{source === 'evobgp_community' ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="list-comm">Community ID</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="list-comm"
|
||||||
|
value={extra}
|
||||||
|
onChange={(e) => setExtra(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
</div>
|
</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={() => setSheetOpen(false)}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={!name || create.isPending}
|
disabled={!canCreate || create.isPending}
|
||||||
onClick={() => create.mutate()}
|
onClick={() => create.mutate()}
|
||||||
>
|
>
|
||||||
Создать
|
Создать
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ export function bumpAllApprovedAgents(db: Db) {
|
|||||||
for (const r of rows) bumpAgentGeneration(db, r.id)
|
for (const r of rows) bumpAgentGeneration(db, r.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bump agents that have a policy rule referencing this IP list. */
|
||||||
|
export function bumpAgentsForList(db: Db, listId: string) {
|
||||||
|
const rules = db
|
||||||
|
.select({ setId: policyRules.setId })
|
||||||
|
.from(policyRules)
|
||||||
|
.where(eq(policyRules.listId, listId))
|
||||||
|
.all()
|
||||||
|
const setIds = [...new Set(rules.map((r) => r.setId))]
|
||||||
|
if (setIds.length === 0) {
|
||||||
|
bumpAllApprovedAgents(db)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const setId of setIds) bumpAgentsForSet(db, setId)
|
||||||
|
}
|
||||||
|
|
||||||
export function listIpLists(db: Db) {
|
export function listIpLists(db: Db) {
|
||||||
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
|
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
|
||||||
}
|
}
|
||||||
@@ -429,6 +444,7 @@ export const repos = {
|
|||||||
bumpAgentGeneration,
|
bumpAgentGeneration,
|
||||||
bumpAgentsForSet,
|
bumpAgentsForSet,
|
||||||
bumpAllApprovedAgents,
|
bumpAllApprovedAgents,
|
||||||
|
bumpAgentsForList,
|
||||||
listIpLists,
|
listIpLists,
|
||||||
getIpList,
|
getIpList,
|
||||||
insertIpList,
|
insertIpList,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const ipOverrideSchema = z.object({
|
|||||||
|
|
||||||
export const createIpListBodySchema = z.object({
|
export const createIpListBodySchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
/** Prefer static | json_url | evobgp_community; domains accepted for legacy. */
|
||||||
type: ipListTypeSchema,
|
type: ipListTypeSchema,
|
||||||
config: z.record(z.string(), z.unknown()).optional(),
|
config: z.record(z.string(), z.unknown()).optional(),
|
||||||
entries: z.array(z.string()).optional(),
|
entries: z.array(z.string()).optional(),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './contracts.js'
|
export * from './contracts.js'
|
||||||
|
export * from './list-entries.js'
|
||||||
export * from './permissions.js'
|
export * from './permissions.js'
|
||||||
export * from './app-switcher.js'
|
export * from './app-switcher.js'
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/** DB type codes — never show raw in UI. */
|
||||||
|
export const IP_LIST_SOURCE_LABELS = {
|
||||||
|
static: 'Ручной',
|
||||||
|
domains: 'Ручной',
|
||||||
|
json_url: 'JSON по URL',
|
||||||
|
evobgp_community: 'EvoBGP community',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type IpListDbType = keyof typeof IP_LIST_SOURCE_LABELS
|
||||||
|
|
||||||
|
export function ipListSourceLabel(type: string): string {
|
||||||
|
return (
|
||||||
|
IP_LIST_SOURCE_LABELS[type as IpListDbType] ?? type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual lists are editable plaintext (legacy `domains` included). */
|
||||||
|
export function isManualListType(type: string): boolean {
|
||||||
|
return type === 'static' || type === 'domains'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listEntryKindSchema = z.enum(['ip', 'cidr', 'hostname'])
|
||||||
|
export type ListEntryKind = z.infer<typeof listEntryKindSchema>
|
||||||
|
|
||||||
|
export const listConfigItemSchema = z.object({
|
||||||
|
kind: listEntryKindSchema,
|
||||||
|
value: z.string().min(1),
|
||||||
|
resolved_cidrs: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
export type ListConfigItem = z.infer<typeof listConfigItemSchema>
|
||||||
|
|
||||||
|
export const listEntryKindLabels: Record<ListEntryKind, string> = {
|
||||||
|
ip: 'IP',
|
||||||
|
cidr: 'CIDR',
|
||||||
|
hostname: 'Домен',
|
||||||
|
}
|
||||||
|
|
||||||
|
const IPV4 =
|
||||||
|
/^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/
|
||||||
|
const IPV4_CIDR =
|
||||||
|
/^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\/(?:3[0-2]|[12]?\d)$/
|
||||||
|
const IPV6 =
|
||||||
|
/^(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$|^::(?:[0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^(?:[0-9a-fA-F]{0,4}:){1,7}:$/
|
||||||
|
const IPV6_CIDR =
|
||||||
|
/^(.+)\/(?:12[0-8]|1[01]\d|[1-9]?\d)$/
|
||||||
|
const HOSTNAME =
|
||||||
|
/^(?=.{1,253}$)(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.(?!-)[a-zA-Z0-9-]{1,63}(?<!-))+$/
|
||||||
|
|
||||||
|
export function parseListEntry(raw: string): ListConfigItem {
|
||||||
|
const value = raw.trim().replace(/\.$/, '')
|
||||||
|
if (!value) throw new Error('Пустая строка')
|
||||||
|
|
||||||
|
if (IPV4_CIDR.test(value)) {
|
||||||
|
return { kind: 'cidr', value }
|
||||||
|
}
|
||||||
|
if (IPV4.test(value)) {
|
||||||
|
return { kind: 'ip', value }
|
||||||
|
}
|
||||||
|
|
||||||
|
const v6Cidr = IPV6_CIDR.exec(value)
|
||||||
|
if (v6Cidr && IPV6.test(v6Cidr[1]!)) {
|
||||||
|
return { kind: 'cidr', value }
|
||||||
|
}
|
||||||
|
if (IPV6.test(value) && !value.includes('/')) {
|
||||||
|
return { kind: 'ip', value }
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = value.toLowerCase()
|
||||||
|
if (HOSTNAME.test(host) || (host.includes('.') && !host.includes(' '))) {
|
||||||
|
if (/^https?:\/\//i.test(host)) {
|
||||||
|
throw new Error('URL не является записью списка — создайте список «JSON по URL»')
|
||||||
|
}
|
||||||
|
return { kind: 'hostname', value: host }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Не удалось распознать: ${raw.trim()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split plaintext paste into non-empty lines / tokens. */
|
||||||
|
export function splitListPlaintext(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split(/[\n,;\s]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeItemToCidrs(
|
||||||
|
item: ListConfigItem,
|
||||||
|
resolvedHostCidrs?: string[],
|
||||||
|
): string[] {
|
||||||
|
if (item.kind === 'ip') {
|
||||||
|
return [item.value.includes(':') ? `${item.value}/128` : `${item.value}/32`]
|
||||||
|
}
|
||||||
|
if (item.kind === 'cidr') {
|
||||||
|
return [item.value]
|
||||||
|
}
|
||||||
|
return resolvedHostCidrs ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readManualItems(
|
||||||
|
configJson: string,
|
||||||
|
): ListConfigItem[] {
|
||||||
|
let config: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
config = JSON.parse(configJson || '{}') as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
config = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(config.items)) {
|
||||||
|
const out: ListConfigItem[] = []
|
||||||
|
for (const raw of config.items) {
|
||||||
|
const parsed = listConfigItemSchema.safeParse(raw)
|
||||||
|
if (parsed.success) out.push(parsed.data)
|
||||||
|
else if (typeof raw === 'string' && raw.trim()) {
|
||||||
|
try {
|
||||||
|
out.push(parseListEntry(raw))
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.length) return out
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(config.domains)) {
|
||||||
|
return (config.domains as string[])
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((value) => ({ kind: 'hostname' as const, value: value.toLowerCase() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function guessListSourceFromInput(extra: string): 'static' | 'json_url' {
|
||||||
|
const t = extra.trim()
|
||||||
|
if (/^https?:\/\//i.test(t)) return 'json_url'
|
||||||
|
return 'static'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listEntriesBodySchema = z.object({
|
||||||
|
values: z.array(z.string().min(1)).min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteListEntryBodySchema = z.object({
|
||||||
|
value: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createIpListSourceSchema = z.enum([
|
||||||
|
'static',
|
||||||
|
'json_url',
|
||||||
|
'evobgp_community',
|
||||||
|
])
|
||||||
Reference in New Issue
Block a user