feat(api, web): enhance EvoBGP community handling and UI integration
- Introduced a new function to resolve EvoBGP community IDs, allowing for better handling of legacy labels and UUIDs. - Updated `fetchEvobgpCommunity` to return both resolved community IDs and CIDRs, improving data retrieval. - Enhanced the ListsPage component to manage community IDs separately from user input, ensuring accurate configuration during list creation. - Updated documentation to reflect changes in community ID resolution and integration with the UI. This update improves the user experience by ensuring that community IDs are correctly resolved and stored, facilitating smoother interactions with the EvoBGP API.
This commit is contained in:
@@ -51,13 +51,52 @@ async function fetchJsonUrl(url: string): Promise<string[]> {
|
|||||||
return uniq(out)
|
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<string> {
|
||||||
|
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(
|
async function fetchEvobgpCommunity(
|
||||||
apiUrl: string,
|
apiUrl: string,
|
||||||
token: string,
|
token: string,
|
||||||
communityId: string,
|
communityId: string,
|
||||||
): Promise<string[]> {
|
): Promise<{ cidrs: string[]; resolvedId: string }> {
|
||||||
const base = apiUrl.replace(/\/$/, '')
|
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, {
|
const res = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
@@ -72,13 +111,13 @@ async function fetchEvobgpCommunity(
|
|||||||
items?: { prefix?: string }[]
|
items?: { prefix?: string }[]
|
||||||
prefixes?: string[]
|
prefixes?: string[]
|
||||||
}
|
}
|
||||||
|
let cidrs: string[] = []
|
||||||
if (Array.isArray(data.prefixes) && data.prefixes.length > 0) {
|
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 { cidrs, resolvedId }
|
||||||
return uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean))
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
||||||
@@ -111,8 +150,18 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
|
|||||||
if (!apiUrl || !token || !communityId) {
|
if (!apiUrl || !token || !communityId) {
|
||||||
throw new Error('evobgp_api_url, token and community_id required')
|
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)
|
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)
|
const contentHash = hashCidrs(cidrs)
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ function ListsPage() {
|
|||||||
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('')
|
||||||
|
/** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */
|
||||||
|
const [communityId, setCommunityId] = useState('')
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
@@ -90,13 +92,28 @@ function ListsPage() {
|
|||||||
[communitiesQ.data?.items],
|
[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({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const config: Record<string, unknown> = {}
|
const config: Record<string, unknown> = {}
|
||||||
if (source === 'json_url') {
|
if (source === 'json_url') {
|
||||||
config.url = extra.trim()
|
config.url = extra.trim()
|
||||||
} else if (source === 'evobgp_community') {
|
} else if (source === 'evobgp_community') {
|
||||||
config.community_id = extra.trim()
|
config.community_id = communityId || resolveCommunityId(extra)
|
||||||
}
|
}
|
||||||
return apiFetch<{ id: string }>('/api/v1/lists', {
|
return apiFetch<{ id: string }>('/api/v1/lists', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -107,6 +124,7 @@ function ListsPage() {
|
|||||||
toast.success('Список создан')
|
toast.success('Список создан')
|
||||||
setName('')
|
setName('')
|
||||||
setExtra('')
|
setExtra('')
|
||||||
|
setCommunityId('')
|
||||||
setSource('static')
|
setSource('static')
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||||
@@ -157,7 +175,10 @@ function ListsPage() {
|
|||||||
|
|
||||||
const canCreate =
|
const canCreate =
|
||||||
Boolean(name.trim()) &&
|
Boolean(name.trim()) &&
|
||||||
(source === 'static' || Boolean(extra.trim()))
|
(source === 'static' ||
|
||||||
|
(source === 'json_url' && Boolean(extra.trim())) ||
|
||||||
|
(source === 'evobgp_community' &&
|
||||||
|
Boolean(communityId || resolveCommunityId(extra))))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -260,7 +281,10 @@ function ListsPage() {
|
|||||||
items={[...CREATE_SOURCE_ITEMS]}
|
items={[...CREATE_SOURCE_ITEMS]}
|
||||||
value={source}
|
value={source}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v) setSource(v as CreateSource)
|
if (!v) return
|
||||||
|
setSource(v as CreateSource)
|
||||||
|
setExtra('')
|
||||||
|
setCommunityId('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
@@ -298,7 +322,16 @@ function ListsPage() {
|
|||||||
<Autocomplete
|
<Autocomplete
|
||||||
items={communityItems}
|
items={communityItems}
|
||||||
value={extra}
|
value={extra}
|
||||||
onValueChange={setExtra}
|
onValueChange={(v) => {
|
||||||
|
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}
|
||||||
>
|
>
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
placeholder={
|
placeholder={
|
||||||
|
|||||||
@@ -13,14 +13,18 @@ EvoFirewall использует EvoBGP как **источник префикс
|
|||||||
|
|
||||||
При refresh списка `evobgp_community`:
|
При refresh списка `evobgp_community`:
|
||||||
|
|
||||||
1. `GET {api}/v1/communities/{id}/prefixes?limit=5000`
|
1. Если `config.community_id` не UUID (legacy: Autocomplete сохранил label) — resolve через `GET /v1/communities`
|
||||||
2. Ответ: `{ items: [{ prefix }], prefixes: string[], has_more, next_cursor }`
|
2. `GET {api}/v1/communities/{id}/prefixes?limit=5000` (id — UUID; EvoBGP также принимает community/label)
|
||||||
3. Entries заменяются; generation агентов с правилами на этот list бампится
|
3. Ответ: `{ items: [{ prefix }], prefixes: string[], has_more, next_cursor }`
|
||||||
|
4. Entries заменяются; generation агентов с правилами на этот list бампится
|
||||||
|
5. В config пишется resolved UUID, если был label
|
||||||
|
|
||||||
## Autocomplete в UI
|
## Autocomplete в UI
|
||||||
|
|
||||||
`GET /api/v1/integrations/evobgp/communities` — proxy к EvoBGP `GET /v1/communities?limit=200` (нужны settings выше).
|
`GET /api/v1/integrations/evobgp/communities` — proxy к EvoBGP `GET /v1/communities?limit=200` (нужны settings выше).
|
||||||
|
|
||||||
|
В форме создания списка Base UI Autocomplete показывает label, а в `config.community_id` сохраняется UUID.
|
||||||
|
|
||||||
## Список
|
## Список
|
||||||
|
|
||||||
Создайте IP list type `evobgp_community` с `config.community_id`. Cron / кнопка Refresh обновляет entries.
|
Создайте IP list type `evobgp_community` с `config.community_id`. Cron / кнопка Refresh обновляет entries.
|
||||||
|
|||||||
Reference in New Issue
Block a user