fix(settings): improve error handling and success notifications in EvoBGP settings

Enhanced the error handling in the settings page by introducing a dedicated error message function. Added success and error toast notifications for better user feedback during settings save operations. Updated API key normalization to ensure consistent handling across the application.
This commit is contained in:
Denozordec
2026-09-05 00:07:58 +07:00
parent f2df990746
commit 39b43dd3dc
3 changed files with 79 additions and 66 deletions
+4 -1
View File
@@ -875,8 +875,11 @@ export default function SettingsPage() {
await evo.saveSettings(patch) await evo.saveSettings(patch)
setEvoKeyDraft("") setEvoKeyDraft("")
markSaved() markSaved()
toast.success("Настройки EvoBGP сохранены")
} catch (e) { } catch (e) {
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения") const msg = e instanceof Error ? e.message : "Ошибка сохранения"
setEvoSaveErr(msg)
toast.error(msg)
} finally { } finally {
setEvoSaveBusy(false) setEvoSaveBusy(false)
} }
+19 -8
View File
@@ -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 { interface EvoCatalogRaw {
modules: { items: Array<{ id: string; name: string; type: string }> } modules: { items: Array<{ id: string; name: string; type: string }> }
domains: { domains: {
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
function credentialsFromDb(): { root: string; apiKey: string } | null { function credentialsFromDb(): { root: string; apiKey: string } | null {
const row = ensureEvobgpRow() const row = ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl) const root = normalizeBaseUrl(row.baseUrl)
const apiKey = row.apiKey.trim() const apiKey = normalizeApiKey(row.apiKey)
if (!root || !apiKey) return null if (!root || !apiKey) return null
return { root, apiKey } return { root, apiKey }
} }
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow() const row = ensureEvobgpRow()
return reply.send({ return reply.send({
baseUrl: row.baseUrl ?? "", baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false, enabled: Boolean(row.enabled),
secretConfigured: Boolean(row.apiKey?.trim()), secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
}) })
}) })
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
let nextEnabled = cur.enabled let nextEnabled = cur.enabled
let nextKey = cur.apiKey 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.enabled !== undefined) nextEnabled = parsed.data.enabled
if (parsed.data.apiKey !== undefined) { 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) db.update(evobgpSettings)
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow() const row = ensureEvobgpRow()
return reply.send({ return reply.send({
baseUrl: row.baseUrl ?? "", baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false, enabled: Boolean(row.enabled),
secretConfigured: Boolean(row.apiKey?.trim()), secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
}) })
}) })
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const keyRaw = const keyRaw =
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
const root = normalizeBaseUrl(urlRaw.trim()) const root = normalizeBaseUrl(urlRaw.trim())
const token = keyRaw.trim() const token = normalizeApiKey(keyRaw)
if (!root || !token) { if (!root || !token) {
return reply.status(400).send({ return reply.status(400).send({
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)", error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
+56 -57
View File
@@ -10,6 +10,7 @@ import {
} from "react" } from "react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import type { Domain, IpRange, Asn } from "@/lib/data" import type { Domain, IpRange, Asn } from "@/lib/data"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
export interface EvoBgpCommunityRow { export interface EvoBgpCommunityRow {
id: string id: string
@@ -66,6 +67,12 @@ interface EvoBgpContextValue {
const EvoBgpContext = createContext<EvoBgpContextValue | null>(null) const EvoBgpContext = createContext<EvoBgpContextValue | null>(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 }) { export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
const { mode, backendUrl, backendStatus } = useDataSource() const { mode, backendUrl, backendStatus } = useDataSource()
const [baseUrl, setBaseUrlState] = useState("") const [baseUrl, setBaseUrlState] = useState("")
@@ -86,24 +93,15 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/catalog`, { const data = await requestJson<EvoBgpCatalogSnapshot>(
method: "POST", backendUrl,
}) "/api/evobgp/catalog",
const text = await res.text() { method: "POST" },
if (!res.ok) { )
let msg = res.statusText setSnapshot(data)
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)
} catch (e) { } catch (e) {
setSnapshot(null) setSnapshot(null)
setError(e instanceof Error ? e.message : "Ошибка загрузки") setError(errorMessage(e, "Ошибка загрузки"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -119,16 +117,19 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
return return
} }
try { try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`) const data = await requestJson<EvoBgpSettingsDto>(
if (!res.ok) throw new Error(await res.text()) backendUrl,
const data = (await res.json()) as EvoBgpSettingsDto "/api/evobgp/settings",
)
setBaseUrlState(data.baseUrl ?? "") setBaseUrlState(data.baseUrl ?? "")
setEnabledState(data.enabled ?? false) setEnabledState(Boolean(data.enabled))
setSecretConfigured(data.secretConfigured ?? false) setSecretConfigured(Boolean(data.secretConfigured))
setSettingsLoaded(true) setSettingsLoaded(true)
await pullCatalog(data.enabled ?? false) setError(null)
} catch { await pullCatalog(Boolean(data.enabled))
} catch (e) {
setSettingsLoaded(true) setSettingsLoaded(true)
setError(errorMessage(e, "Не удалось загрузить настройки EvoBGP"))
} }
}, [mode, backendStatus, backendUrl, pullCatalog]) }, [mode, backendStatus, backendUrl, pullCatalog])
@@ -140,27 +141,25 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
const saveSettings = useCallback( const saveSettings = useCallback(
async (patch: EvoBgpSavePayload) => { async (patch: EvoBgpSavePayload) => {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`, { const data = await requestJson<EvoBgpSettingsDto>(
method: "PUT", backendUrl,
headers: { "Content-Type": "application/json" }, "/api/evobgp/settings",
body: JSON.stringify(patch), {
}) method: "PUT",
const text = await res.text() body: JSON.stringify(patch),
if (!res.ok) { },
let msg = res.statusText )
try { const nextEnabled = Boolean(data.enabled)
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
setBaseUrlState(data.baseUrl ?? "") setBaseUrlState(data.baseUrl ?? "")
setEnabledState(data.enabled ?? false) setEnabledState(nextEnabled)
setSecretConfigured(data.secretConfigured ?? false) setSecretConfigured(Boolean(data.secretConfigured))
await pullCatalog(data.enabled ?? false) setError(null)
// Каталог не должен ронять успех сохранения (401/502 на catalog ≠ «настройки не сохранились»)
try {
await pullCatalog(nextEnabled)
} catch {
/* pullCatalog already sets error state */
}
}, },
[backendUrl, pullCatalog], [backendUrl, pullCatalog],
) )
@@ -169,20 +168,20 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
await pullCatalog(enabled) await pullCatalog(enabled)
}, [enabled, pullCatalog]) }, [enabled, pullCatalog])
const testConnection = useCallback(async (draft?: EvoBgpTestDraft) => { const testConnection = useCallback(
try { async (draft?: EvoBgpTestDraft) => {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/test`, { try {
method: "POST", await requestJson<{ ok?: boolean }>(backendUrl, "/api/evobgp/test", {
headers: { "Content-Type": "application/json" }, method: "POST",
body: JSON.stringify(draft ?? {}), body: JSON.stringify(draft ?? {}),
}) })
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string } return { ok: true, message: "Соединение с EvoBGP установлено" }
if (!res.ok) throw new Error(data.error ?? res.statusText) } catch (e) {
return { ok: true, message: "Соединение с EvoBGP установлено" } return { ok: false, message: errorMessage(e, "Ошибка") }
} catch (e) { }
return { ok: false, message: e instanceof Error ? e.message : "Ошибка" } },
} [backendUrl],
}, [backendUrl]) )
const value = useMemo( const value = useMemo(
() => ({ () => ({