diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx index 7ffc574..5c70698 100644 --- a/app/(main)/settings/page.tsx +++ b/app/(main)/settings/page.tsx @@ -875,8 +875,11 @@ export default function SettingsPage() { await evo.saveSettings(patch) setEvoKeyDraft("") markSaved() + toast.success("Настройки EvoBGP сохранены") } catch (e) { - setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения") + const msg = e instanceof Error ? e.message : "Ошибка сохранения" + setEvoSaveErr(msg) + toast.error(msg) } finally { setEvoSaveBusy(false) } diff --git a/backend/src/routes/evobgp.ts b/backend/src/routes/evobgp.ts index 493fe0a..b869c46 100644 --- a/backend/src/routes/evobgp.ts +++ b/backend/src/routes/evobgp.ts @@ -37,6 +37,12 @@ function normalizeBaseUrl(raw: string): string { } } +/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */ +function normalizeApiKey(raw: string): string { + const trimmed = raw.trim() + return trimmed.replace(/^Bearer\s+/i, "").trim() +} + interface EvoCatalogRaw { modules: { items: Array<{ id: string; name: string; type: string }> } domains: { @@ -158,7 +164,7 @@ async function fetchEvoJson(root: string, path: string, token: string): Promi function credentialsFromDb(): { root: string; apiKey: string } | null { const row = ensureEvobgpRow() const root = normalizeBaseUrl(row.baseUrl) - const apiKey = row.apiKey.trim() + const apiKey = normalizeApiKey(row.apiKey) if (!root || !apiKey) return null return { root, apiKey } } @@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { const row = ensureEvobgpRow() return reply.send({ baseUrl: row.baseUrl ?? "", - enabled: row.enabled ?? false, - secretConfigured: Boolean(row.apiKey?.trim()), + enabled: Boolean(row.enabled), + secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")), }) }) @@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { let nextEnabled = cur.enabled let nextKey = cur.apiKey - if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim() + if (parsed.data.baseUrl !== undefined) { + nextBase = normalizeBaseUrl(parsed.data.baseUrl) + } if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled if (parsed.data.apiKey !== undefined) { - nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim() + nextKey = + parsed.data.apiKey === null || parsed.data.apiKey === "" + ? "" + : normalizeApiKey(parsed.data.apiKey) } db.update(evobgpSettings) @@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { const row = ensureEvobgpRow() return reply.send({ baseUrl: row.baseUrl ?? "", - enabled: row.enabled ?? false, - secretConfigured: Boolean(row.apiKey?.trim()), + enabled: Boolean(row.enabled), + secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")), }) }) @@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { const keyRaw = d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey const root = normalizeBaseUrl(urlRaw.trim()) - const token = keyRaw.trim() + const token = normalizeApiKey(keyRaw) if (!root || !token) { return reply.status(400).send({ error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)", diff --git a/lib/evobgp-context.tsx b/lib/evobgp-context.tsx index 82c413c..c150c40 100644 --- a/lib/evobgp-context.tsx +++ b/lib/evobgp-context.tsx @@ -10,6 +10,7 @@ import { } from "react" import { useDataSource } from "@/lib/data-source" import type { Domain, IpRange, Asn } from "@/lib/data" +import { ApiClientError, requestJson } from "@/shared/api/http-client" export interface EvoBgpCommunityRow { id: string @@ -66,6 +67,12 @@ interface EvoBgpContextValue { const EvoBgpContext = createContext(null) +function errorMessage(e: unknown, fallback: string): string { + if (e instanceof ApiClientError) return e.message || fallback + if (e instanceof Error) return e.message || fallback + return fallback +} + export function EvoBGPProvider({ children }: { children: React.ReactNode }) { const { mode, backendUrl, backendStatus } = useDataSource() const [baseUrl, setBaseUrlState] = useState("") @@ -86,24 +93,15 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) { setLoading(true) setError(null) try { - const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/catalog`, { - method: "POST", - }) - const text = await res.text() - if (!res.ok) { - let msg = res.statusText - try { - const j = JSON.parse(text) as { error?: string; detail?: string } - msg = j.error ?? j.detail ?? msg - } catch { - if (text) msg = text - } - throw new Error(msg || "Ошибка EvoBGP") - } - setSnapshot(JSON.parse(text) as EvoBgpCatalogSnapshot) + const data = await requestJson( + backendUrl, + "/api/evobgp/catalog", + { method: "POST" }, + ) + setSnapshot(data) } catch (e) { setSnapshot(null) - setError(e instanceof Error ? e.message : "Ошибка загрузки") + setError(errorMessage(e, "Ошибка загрузки")) } finally { setLoading(false) } @@ -119,16 +117,19 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) { return } try { - const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`) - if (!res.ok) throw new Error(await res.text()) - const data = (await res.json()) as EvoBgpSettingsDto + const data = await requestJson( + backendUrl, + "/api/evobgp/settings", + ) setBaseUrlState(data.baseUrl ?? "") - setEnabledState(data.enabled ?? false) - setSecretConfigured(data.secretConfigured ?? false) + setEnabledState(Boolean(data.enabled)) + setSecretConfigured(Boolean(data.secretConfigured)) setSettingsLoaded(true) - await pullCatalog(data.enabled ?? false) - } catch { + setError(null) + await pullCatalog(Boolean(data.enabled)) + } catch (e) { setSettingsLoaded(true) + setError(errorMessage(e, "Не удалось загрузить настройки EvoBGP")) } }, [mode, backendStatus, backendUrl, pullCatalog]) @@ -140,27 +141,25 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) { const saveSettings = useCallback( async (patch: EvoBgpSavePayload) => { - const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }) - const text = await res.text() - if (!res.ok) { - let msg = res.statusText - try { - const j = JSON.parse(text) as { error?: string } - msg = j.error ?? msg - } catch { - if (text) msg = text - } - throw new Error(msg || "Не удалось сохранить") - } - const data = JSON.parse(text) as EvoBgpSettingsDto + const data = await requestJson( + backendUrl, + "/api/evobgp/settings", + { + method: "PUT", + body: JSON.stringify(patch), + }, + ) + const nextEnabled = Boolean(data.enabled) setBaseUrlState(data.baseUrl ?? "") - setEnabledState(data.enabled ?? false) - setSecretConfigured(data.secretConfigured ?? false) - await pullCatalog(data.enabled ?? false) + setEnabledState(nextEnabled) + setSecretConfigured(Boolean(data.secretConfigured)) + setError(null) + // Каталог не должен ронять успех сохранения (401/502 на catalog ≠ «настройки не сохранились») + try { + await pullCatalog(nextEnabled) + } catch { + /* pullCatalog already sets error state */ + } }, [backendUrl, pullCatalog], ) @@ -169,20 +168,20 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) { await pullCatalog(enabled) }, [enabled, pullCatalog]) - const testConnection = useCallback(async (draft?: EvoBgpTestDraft) => { - try { - const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/test`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(draft ?? {}), - }) - const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string } - if (!res.ok) throw new Error(data.error ?? res.statusText) - return { ok: true, message: "Соединение с EvoBGP установлено" } - } catch (e) { - return { ok: false, message: e instanceof Error ? e.message : "Ошибка" } - } - }, [backendUrl]) + const testConnection = useCallback( + async (draft?: EvoBgpTestDraft) => { + try { + await requestJson<{ ok?: boolean }>(backendUrl, "/api/evobgp/test", { + method: "POST", + body: JSON.stringify(draft ?? {}), + }) + return { ok: true, message: "Соединение с EvoBGP установлено" } + } catch (e) { + return { ok: false, message: errorMessage(e, "Ошибка") } + } + }, + [backendUrl], + ) const value = useMemo( () => ({