diff --git a/apps/api/src/services/lists/refresh.ts b/apps/api/src/services/lists/refresh.ts index 2c38e28..e1527b3 100644 --- a/apps/api/src/services/lists/refresh.ts +++ b/apps/api/src/services/lists/refresh.ts @@ -51,13 +51,52 @@ async function fetchJsonUrl(url: string): Promise { return uniq(out) } +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +async function resolveEvobgpCommunityId( + base: string, + token: string, + communityIdOrLabel: string, +): Promise { + const key = communityIdOrLabel.trim() + if (!key) throw new Error('community_id required') + if (UUID_RE.test(key)) return key + + // Heal lists created when Autocomplete stored label instead of UUID. + const res = await fetch(`${base}/v1/communities?limit=200`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(20_000), + }) + if (!res.ok) { + throw new Error(`EvoBGP communities HTTP ${res.status}`) + } + const data = (await res.json()) as { + items?: { id?: string; community?: string; title?: string | null }[] + } + const hit = (data.items ?? []).find((c) => { + if (!c.id || !c.community) return false + if (c.id === key || c.community === key) return true + if (c.title && `${c.community} · ${c.title}` === key) return true + return false + }) + if (!hit?.id) { + throw new Error(`EvoBGP community not found: ${key}`) + } + return hit.id +} + async function fetchEvobgpCommunity( apiUrl: string, token: string, communityId: string, -): Promise { +): Promise<{ cidrs: string[]; resolvedId: string }> { const base = apiUrl.replace(/\/$/, '') - const url = `${base}/v1/communities/${encodeURIComponent(communityId)}/prefixes?limit=5000` + const resolvedId = await resolveEvobgpCommunityId(base, token, communityId) + const url = `${base}/v1/communities/${encodeURIComponent(resolvedId)}/prefixes?limit=5000` const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, @@ -72,13 +111,13 @@ async function fetchEvobgpCommunity( items?: { prefix?: string }[] prefixes?: string[] } + let cidrs: string[] = [] if (Array.isArray(data.prefixes) && data.prefixes.length > 0) { - return uniq(data.prefixes) + cidrs = uniq(data.prefixes) + } else if (Array.isArray(data.items)) { + cidrs = uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean)) } - if (Array.isArray(data.items)) { - return uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean)) - } - return [] + return { cidrs, resolvedId } } export async function refreshIpList(db: Db, listId: string): Promise { @@ -111,8 +150,18 @@ export async function refreshIpList(db: Db, listId: string): Promise { if (!apiUrl || !token || !communityId) { throw new Error('evobgp_api_url, token and community_id required') } - cidrs = await fetchEvobgpCommunity(apiUrl, token, communityId) + const fetched = await fetchEvobgpCommunity(apiUrl, token, communityId) + cidrs = fetched.cidrs repos.replaceIpListEntries(db, listId, cidrs) + // Persist resolved UUID if list was saved with autocomplete label. + if (fetched.resolvedId !== communityId) { + repos.updateIpList(db, listId, { + configJson: JSON.stringify({ + ...config, + community_id: fetched.resolvedId, + }), + }) + } } const contentHash = hashCidrs(cidrs) diff --git a/apps/web/src/routes/_auth/lists/index.tsx b/apps/web/src/routes/_auth/lists/index.tsx index 1c00397..65ed5f8 100644 --- a/apps/web/src/routes/_auth/lists/index.tsx +++ b/apps/web/src/routes/_auth/lists/index.tsx @@ -70,6 +70,8 @@ function ListsPage() { const [name, setName] = useState('') const [source, setSource] = useState('static') const [extra, setExtra] = useState('') + /** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */ + const [communityId, setCommunityId] = useState('') const [filters, setFilters] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [activeTab, setActiveTab] = useState('all') @@ -90,13 +92,28 @@ function ListsPage() { [communitiesQ.data?.items], ) + function resolveCommunityId(input: string): string { + const q = input.trim() + if (!q) return '' + const hit = communityItems.find((i) => i.value === q || i.label === q) + if (hit) return hit.value + const raw = communitiesQ.data?.items ?? [] + const byComm = raw.find( + (c) => + c.id === q || + c.community === q || + (c.title ? `${c.community} · ${c.title}` === q : false), + ) + return byComm?.id ?? q + } + const create = useMutation({ mutationFn: async () => { const config: Record = {} if (source === 'json_url') { config.url = extra.trim() } else if (source === 'evobgp_community') { - config.community_id = extra.trim() + config.community_id = communityId || resolveCommunityId(extra) } return apiFetch<{ id: string }>('/api/v1/lists', { method: 'POST', @@ -107,6 +124,7 @@ function ListsPage() { toast.success('Список создан') setName('') setExtra('') + setCommunityId('') setSource('static') setCreateOpen(false) void qc.invalidateQueries({ queryKey: ['lists'] }) @@ -157,7 +175,10 @@ function ListsPage() { const canCreate = Boolean(name.trim()) && - (source === 'static' || Boolean(extra.trim())) + (source === 'static' || + (source === 'json_url' && Boolean(extra.trim())) || + (source === 'evobgp_community' && + Boolean(communityId || resolveCommunityId(extra)))) return ( @@ -260,7 +281,10 @@ function ListsPage() { items={[...CREATE_SOURCE_ITEMS]} value={source} onValueChange={(v) => { - if (v) setSource(v as CreateSource) + if (!v) return + setSource(v as CreateSource) + setExtra('') + setCommunityId('') }} > @@ -298,7 +322,16 @@ function ListsPage() { { + setExtra(v) + // Base UI {value,label}: input stores label by default — + // keep UUID separately for config.community_id. + // Preview: https://reui.io/preview/base/components/c-autocomplete-9 + // Docs: https://reui.io/docs/components/base/autocomplete + // Base UI: https://base-ui.com/react/components/autocomplete + setCommunityId(resolveCommunityId(v)) + }} + itemToStringValue={(item) => item.label} >