Compare commits

...
3 Commits
Author SHA1 Message Date
Denozordec 883842636b refactor(api): streamline API requests with requestJson and requestBlob functions
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m24s
Docker images / frontend-image (push) Successful in 2m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 7s
Replaced direct fetch calls with requestJson and requestBlob utility functions across multiple components for improved consistency and error handling. This change enhances the maintainability of the codebase by centralizing API request logic and ensuring uniform handling of authentication and response parsing.
2026-09-05 01:42:44 +07:00
Denozordec b9f430de16 refactor(acme-cloudflare): enhance upsertARecord and syncCertificateDomainRecords functions
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 2m21s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Updated the upsertARecord function to return status messages ("updated", "created", "skipped_cname") instead of void, improving clarity on record handling. Modified syncCertificateDomainRecords to collect and return skipped CNAME records, enhancing error handling and feedback during DNS operations.
2026-09-05 01:29:58 +07:00
Denozordec 25e040a5dd fix(settings): improve error handling and success notifications in EvoBGP settings
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m26s
Docker images / frontend-image (push) Successful in 2m26s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
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.
2026-09-05 00:07:58 +07:00
14 changed files with 249 additions and 193 deletions
+2 -2
View File
@@ -28,6 +28,7 @@ import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers" import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers" import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups" import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { requestBlob } from "@/shared/api/http-client"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
Stepper, Stepper,
@@ -276,8 +277,7 @@ export default function BackupsPage() {
} }
async function handleDownload(id: string, fallbackFilename: string) { async function handleDownload(id: string, fallbackFilename: string) {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`) const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
if (!res.ok) throw new Error("Не удалось скачать файл")
const blob = await res.blob() const blob = await res.blob()
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement("a") const a = document.createElement("a")
+2 -5
View File
@@ -23,6 +23,7 @@ import {
XIcon, AlertCircleIcon, XIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -621,11 +622,7 @@ export default function BgpPage() {
if (cancelled) return if (cancelled) return
setLoading(true) setLoading(true)
setLiveError(null) setLiveError(null)
fetch(`${backendUrl}/api/bgp/sessions`) void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<BackendBgpSession[]>
})
.then(data => { .then(data => {
if (cancelled) return if (cancelled) return
setLiveSessions(data.map(backendToFrontend)) setLiveSessions(data.map(backendToFrontend))
+10 -9
View File
@@ -16,6 +16,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data" import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
@@ -733,13 +734,14 @@ function InterfacesTab({
const ra = readStoredRouteOptimizerSettings() const ra = readStoredRouteOptimizerSettings()
setOptimizing(true) setOptimizing(true)
try { try {
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, { const data = await requestJson<BackendOspfOptimizeResponse>(
method: "POST", backendUrl,
headers: { "Content-Type": "application/json" }, `/api/servers/${filterServerId}/ospf/optimize`,
body: JSON.stringify({ pingWeight: ra.pingWeight }), {
}) method: "POST",
if (!r.ok) throw new Error(`HTTP ${r.status}`) body: JSON.stringify({ pingWeight: ra.pingWeight }),
const data = await r.json() as BackendOspfOptimizeResponse },
)
const byKey: Record<string, number> = {} const byKey: Record<string, number> = {}
data.interfaces.forEach((row) => { data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost byKey[`${data.serverId}-${row.id}`] = row.optimalCost
@@ -1120,8 +1122,7 @@ export default function OspfPage() {
if (cancelled) return if (cancelled) return
setLoading(true) setLoading(true)
setLiveError(null) setLiveError(null)
fetch(`${backendUrl}/api/ospf/all`) void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
.then(data => { .then(data => {
if (cancelled) return if (cancelled) return
setLiveData(data); setFetchedAt(new Date()); setLoading(false) setLiveData(data); setFetchedAt(new Date()); setLoading(false)
+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)
} }
+14 -12
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data" import { servers as mockServers } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react" } from "lucide-react"
@@ -259,12 +260,14 @@ function Terminal({
if (isLive && server.backendId !== null) { if (isLive && server.backendId !== null) {
setExecuting(true) setExecuting(true)
try { try {
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, { const data = await requestJson<{ output?: string; error?: string }>(
method: "POST", backendUrl,
headers: { "Content-Type": "application/json" }, `/api/servers/${server.backendId}/exec`,
body: JSON.stringify({ command: cmd }), {
}) method: "POST",
const data = await res.json() as { output?: string; error?: string } body: JSON.stringify({ command: cmd }),
},
)
const text = data.output ?? data.error ?? "(empty response)" const text = data.output ?? data.error ?? "(empty response)"
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output" const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
text.split("\n").forEach(line => text.split("\n").forEach(line =>
@@ -427,7 +430,7 @@ interface BackendServer {
} }
export default function TerminalPage() { export default function TerminalPage() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
// Server list state // Server list state
@@ -437,14 +440,13 @@ export default function TerminalPage() {
// Load servers from backend when in live mode // Load servers from backend when in live mode
useEffect(() => { useEffect(() => {
if (!isLive) return if (!isLive || !prefsHydrated) return
let cancelled = false let cancelled = false
queueMicrotask(() => { queueMicrotask(() => {
if (cancelled) return if (cancelled) return
setServersLoading(true) setServersLoading(true)
fetch(`${backendUrl}/api/servers`) void requestJson<BackendServer[]>(backendUrl, "/api/servers")
.then(r => r.json() as Promise<BackendServer[]>) .then((data) => {
.then(data => {
if (cancelled) return if (cancelled) return
setLiveServers(data.map(s => ({ setLiveServers(data.map(s => ({
uid: String(s.id), uid: String(s.id),
@@ -462,7 +464,7 @@ export default function TerminalPage() {
.catch(() => { if (!cancelled) setServersLoading(false) }) .catch(() => { if (!cancelled) setServersLoading(false) })
}) })
return () => { cancelled = true } return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey]) }, [isLive, backendUrl, refreshKey, prefsHydrated])
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers() const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
+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-ключ (в форме или уже сохранённые в БД)",
+30 -8
View File
@@ -161,11 +161,11 @@ async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string)
) )
} }
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> { async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<"updated" | "created" | "skipped_cname"> {
const records = await listDnsRecordsByName(token, zoneId, fqdn) const records = await listDnsRecordsByName(token, zoneId, fqdn)
const existingA = records.find((record) => record.type === "A") const existingA = records.find((record) => record.type === "A")
if (existingA) { if (existingA) {
if (existingA.content === ip) return if (existingA.content === ip) return "updated"
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, { await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
@@ -176,11 +176,12 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false, proxied: false,
}), }),
}) })
return return "updated"
} }
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
if (records.some((record) => record.type === "CNAME")) { if (records.some((record) => record.type === "CNAME")) {
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`) return "skipped_cname"
} }
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, { await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
@@ -193,6 +194,7 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false, proxied: false,
}), }),
}) })
return "created"
} }
async function syncCertificateDomainRecords( async function syncCertificateDomainRecords(
@@ -200,11 +202,14 @@ async function syncCertificateDomainRecords(
domains: string[], domains: string[],
serverIp: string, serverIp: string,
defaultZoneId?: string, defaultZoneId?: string,
): Promise<void> { ): Promise<{ skippedCname: string[] }> {
const skippedCname: string[] = []
for (const domain of domains) { for (const domain of domains) {
const zoneId = await resolveZoneId(token, domain, defaultZoneId) const zoneId = await resolveZoneId(token, domain, defaultZoneId)
await upsertARecord(token, zoneId, domain, serverIp) const result = await upsertARecord(token, zoneId, domain, serverIp)
if (result === "skipped_cname") skippedCname.push(domain)
} }
return { skippedCname }
} }
async function sleep(ms: number) { async function sleep(ms: number) {
@@ -296,9 +301,26 @@ export async function issueCertificateWithCloudflareDns(params: {
const finalized = await client.finalizeOrder(order, csr) const finalized = await client.finalizeOrder(order, csr)
const certPem = await client.getCertificate(finalized) const certPem = await client.getCertificate(finalized)
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
const clientRos = MikrotikClient.fromServer(params.server) const clientRos = MikrotikClient.fromServer(params.server)
const serverIp = await resolveServerPublicIp(params.server, clientRos) try {
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId) params.onStep?.("dns_a_sync")
const serverIp = await resolveServerPublicIp(params.server, clientRos)
const { skippedCname } = await syncCertificateDomainRecords(
token,
domains,
serverIp,
settings.defaultZoneId,
)
if (skippedCname.length > 0) {
params.onStep?.(
`dns_a_sync_skip_cname:${skippedCname.join(",")}`,
)
}
} catch (e) {
const msg = e instanceof Error ? e.message : "ошибка DNS A-sync"
params.onStep?.(`dns_a_sync_warn:${msg}`)
}
const trustStores = params.trustStore.filter(Boolean) const trustStores = params.trustStore.filter(Boolean)
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"] const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
+11 -19
View File
@@ -38,6 +38,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context" import { useEvoBGP } from "@/lib/evobgp-context"
import { requestJson } from "@/shared/api/http-client"
import { import {
formatSidebarBadgeCount, formatSidebarBadgeCount,
mockSidebarBadgesByUrl, mockSidebarBadgesByUrl,
@@ -104,7 +105,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number } type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
const evo = useEvoBGP() const evo = useEvoBGP()
const [mounted, setMounted] = React.useState(false) const [mounted, setMounted] = React.useState(false)
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null) const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
@@ -118,30 +119,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
if (mode !== "live") { if (!prefsHydrated || mode !== "live") {
setLiveCounts(null) if (mode !== "live") setLiveCounts(null)
return return
} }
let cancelled = false let cancelled = false
const load = async () => { const load = async () => {
try { try {
const base = backendUrl.replace(/\/$/, "") const [cJson, gJson] = await Promise.all([
const [cRes, gRes] = await Promise.all([ requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
fetch(`${base}/api/sidebar-counts`), requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch(
fetch(`${base}/api/filters/gre-tunnels`), () => ({ tunnels: [] as unknown[] }),
),
]) ])
if (cancelled) return if (cancelled) return
if (!cRes.ok) { setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length })
setLiveCounts(null)
return
}
const cJson = (await cRes.json()) as SidebarCountsDto
let greN = 0
if (gRes.ok) {
const gJson = (await gRes.json()) as { tunnels?: unknown[] }
greN = (gJson.tunnels ?? []).length
}
setLiveCounts({ ...cJson, greTunnels: greN })
} catch { } catch {
if (!cancelled) setLiveCounts(null) if (!cancelled) setLiveCounts(null)
} }
@@ -152,7 +144,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
cancelled = true cancelled = true
window.clearInterval(id) window.clearInterval(id)
} }
}, [mode, backendUrl]) }, [mode, backendUrl, prefsHydrated])
const navGroups = React.useMemo((): NavGroup[] => { const navGroups = React.useMemo((): NavGroup[] => {
function badgeFor(url: string): string | undefined { function badgeFor(url: string): string | undefined {
+8 -11
View File
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { filters, pingProbes, servers } from "@/lib/data" import { filters, pingProbes, servers } from "@/lib/data"
import type { SidebarCountsDto } from "@/lib/sidebar-badges" import type { SidebarCountsDto } from "@/lib/sidebar-badges"
import { resolveApiUrl, requestJson } from "@/shared/api/http-client"
type MonitorMetric = { type MonitorMetric = {
id: string id: string
@@ -78,11 +79,12 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */ /** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
export function SystemMonitorPopover() { export function SystemMonitorPopover() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
const [healthOk, setHealthOk] = useState<boolean | null>(null) const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [counts, setCounts] = useState<SidebarCountsDto | null>(null) const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
useEffect(() => { useEffect(() => {
if (!prefsHydrated) return
if (mode !== "live") { if (mode !== "live") {
setHealthOk(true) setHealthOk(true)
setCounts({ setCounts({
@@ -98,11 +100,10 @@ export function SystemMonitorPopover() {
let cancelled = false let cancelled = false
const load = async () => { const load = async () => {
const base = backendUrl.replace(/\/$/, "")
try { try {
const [hRes, cRes] = await Promise.all([ const [hRes, counts] = await Promise.all([
fetch(`${base}/health`), fetch(resolveApiUrl(backendUrl, "/health"), { signal: AbortSignal.timeout(3000) }),
fetch(`${base}/api/sidebar-counts`), requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
]) ])
if (cancelled) return if (cancelled) return
if (hRes.ok) { if (hRes.ok) {
@@ -111,11 +112,7 @@ export function SystemMonitorPopover() {
} else { } else {
setHealthOk(false) setHealthOk(false)
} }
if (cRes.ok) { setCounts(counts)
setCounts((await cRes.json()) as SidebarCountsDto)
} else {
setCounts(null)
}
} catch { } catch {
if (!cancelled) { if (!cancelled) {
setHealthOk(false) setHealthOk(false)
@@ -129,7 +126,7 @@ export function SystemMonitorPopover() {
cancelled = true cancelled = true
window.clearInterval(id) window.clearInterval(id)
} }
}, [mode, backendUrl]) }, [mode, backendUrl, prefsHydrated])
const serversCount = counts?.servers ?? 0 const serversCount = counts?.servers ?? 0
const filtersCount = counts?.filterRules ?? 0 const filtersCount = counts?.filterRules ?? 0
+15 -1
View File
@@ -26,12 +26,26 @@ export function isBackendUrlLocked(): boolean {
return cfg.kind === "same-origin" || cfg.kind === "fixed" return cfg.kind === "same-origin" || cfg.kind === "fixed"
} }
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1"
}
/** Prefer same-origin when the UI is not on loopback — never point the browser at localhost. */
export function resolveStoredBackendUrl(stored: string | null): string { export function resolveStoredBackendUrl(stored: string | null): string {
const cfg = configuredBackendUrl() const cfg = configuredBackendUrl()
if (cfg.kind === "fixed") return cfg.url if (cfg.kind === "fixed") return cfg.url
if (cfg.kind === "same-origin" && typeof window !== "undefined") { if (cfg.kind === "same-origin") {
if (typeof window !== "undefined") return window.location.origin
return ""
}
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
return window.location.origin return window.location.origin
} }
const trimmed = stored?.trim().replace(/\/$/, "") const trimmed = stored?.trim().replace(/\/$/, "")
if (trimmed && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimmed)) {
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
return window.location.origin
}
}
return trimmed || LOCAL_DEFAULT_BACKEND_URL return trimmed || LOCAL_DEFAULT_BACKEND_URL
} }
+11 -11
View File
@@ -9,6 +9,7 @@ import {
LOCAL_DEFAULT_BACKEND_URL, LOCAL_DEFAULT_BACKEND_URL,
resolveStoredBackendUrl, resolveStoredBackendUrl,
} from "@/lib/backend-url" } from "@/lib/backend-url"
import { resolveApiUrl } from "@/shared/api/http-client"
// ── types ───────────────────────────────────────────────────────────────────── // ── types ─────────────────────────────────────────────────────────────────────
@@ -49,8 +50,13 @@ function readStoredMode(): DataSourceMode {
return defaultDataSourceMode() return defaultDataSourceMode()
} }
function readStoredBackendUrl(): string { function initialBackendUrl(): string {
if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL if (typeof window === "undefined") {
const cfg = configuredBackendUrl()
if (cfg.kind === "same-origin") return ""
if (cfg.kind === "fixed") return cfg.url
return LOCAL_DEFAULT_BACKEND_URL
}
return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND)) return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
} }
@@ -60,7 +66,7 @@ function normalizeBackendUrl(url: string): string {
export function DataSourceProvider({ children }: { children: React.ReactNode }) { export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode) const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL) const [backendUrl, setBackendUrlState] = useState(initialBackendUrl)
const [prefsHydrated, setPrefsHydrated] = useState(false) const [prefsHydrated, setPrefsHydrated] = useState(false)
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined) const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked() const backendUrlLocked = isBackendUrlLocked()
@@ -68,10 +74,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
useEffect(() => { useEffect(() => {
const storedMode = readStoredMode() const storedMode = readStoredMode()
let url = readStoredBackendUrl() const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
if (configuredBackendUrl().kind === "same-origin") {
url = window.location.origin
}
setModeState(storedMode) setModeState(storedMode)
setBackendUrlState(url) setBackendUrlState(url)
setPrefsHydrated(true) setPrefsHydrated(true)
@@ -91,10 +94,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
}, [backendUrlLocked]) }, [backendUrlLocked])
const checkBackend = useCallback(async () => { const checkBackend = useCallback(async () => {
const healthUrl = const healthUrl = resolveApiUrl(backendUrl, "/health")
configuredBackendUrl().kind === "same-origin"
? "/health"
: `${normalizeBackendUrl(backendUrl)}/health`
try { try {
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) }) const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
setBackendStatus(res.ok) setBackendStatus(res.ok)
+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(
() => ({ () => ({
+64 -12
View File
@@ -21,38 +21,72 @@ function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "") return baseUrl.replace(/\/$/, "")
} }
function resolveRequestUrl(baseUrl: string, path: string): string { /** Absolute or same-origin-relative URL for backend API paths. */
export function resolveApiUrl(baseUrl: string, path: string): string {
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") { if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
return path return path
} }
// Safety: never call browser localhost when the UI is served from a remote host
if (typeof window !== "undefined") {
const host = window.location.hostname
const remoteUi = host !== "localhost" && host !== "127.0.0.1"
const baseIsLocal =
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimBaseUrl(baseUrl))
if (remoteUi && (baseIsLocal || !baseUrl.trim())) {
return path.startsWith("/") ? path : `/${path}`
}
}
return trimBaseUrl(baseUrl) + path return trimBaseUrl(baseUrl) + path
} }
/** Attach portal JWT when present. */
export function withAuthHeaders(init?: HeadersInit): Headers {
const headers = new Headers(init)
const token = typeof window !== "undefined" ? getToken() : null
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`)
}
return headers
}
function handleUnauthorized(): never {
if (typeof window !== "undefined" && isAuthEnabled()) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
}
throw new ApiClientError("Unauthorized", 401)
}
async function parseErrorMessage(res: Response): Promise<string> {
const payload = await res.json().catch(() => undefined)
if (
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
) {
return (payload as { error: string }).error
}
return res.statusText || `HTTP ${res.status}`
}
export async function requestJson<T>( export async function requestJson<T>(
baseUrl: string, baseUrl: string,
path: string, path: string,
init?: RequestInit, init?: RequestInit,
): Promise<T> { ): Promise<T> {
const hasBody = init?.body != null const hasBody = init?.body != null
const headers = new Headers(init?.headers) const headers = withAuthHeaders(init?.headers)
if (hasBody && !headers.has("Content-Type")) { if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json") headers.set("Content-Type", "application/json")
} }
const token = typeof window !== "undefined" ? getToken() : null
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`)
}
const res = await fetch(resolveRequestUrl(baseUrl, path), { const res = await fetch(resolveApiUrl(baseUrl, path), {
...init, ...init,
headers, headers,
}) })
if (res.status === 401 && typeof window !== "undefined" && isAuthEnabled()) { if (res.status === 401) handleUnauthorized()
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
throw new ApiClientError("Unauthorized", 401)
}
if (res.status === 204) return undefined as T if (res.status === 204) return undefined as T
@@ -70,3 +104,21 @@ export async function requestJson<T>(
return payload as T return payload as T
} }
/** Binary/download endpoints (backup, backup file) with the same auth + URL rules. */
export async function requestBlob(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<Response> {
const headers = withAuthHeaders(init?.headers)
const res = await fetch(resolveApiUrl(baseUrl, path), {
...init,
headers,
})
if (res.status === 401) handleUnauthorized()
if (!res.ok) {
throw new ApiClientError(await parseErrorMessage(res), res.status)
}
return res
}
+3 -37
View File
@@ -1,19 +1,7 @@
import { ApiClientError } from "@/shared/api/http-client" import { ApiClientError, requestBlob } from "@/shared/api/http-client"
import { configuredBackendUrl } from "@/lib/backend-url"
const MAX_RESTORE_BYTES = 512 * 1024 * 1024 const MAX_RESTORE_BYTES = 512 * 1024 * 1024
function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "")
}
function resolveDatabaseApiUrl(baseUrl: string, path: string): string {
if (configuredBackendUrl().kind === "same-origin") {
return path
}
return `${trimBaseUrl(baseUrl)}${path}`
}
function parseFilename(contentDisposition: string | null, fallback: string): string { function parseFilename(contentDisposition: string | null, fallback: string): string {
if (!contentDisposition) return fallback if (!contentDisposition) return fallback
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition) const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
@@ -32,18 +20,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str
export async function downloadSystemDatabaseBackup( export async function downloadSystemDatabaseBackup(
baseUrl: string, baseUrl: string,
): Promise<{ blob: Blob; filename: string }> { ): Promise<{ blob: Blob; filename: string }> {
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/backup")) const res = await requestBlob(baseUrl, "/api/system/database/backup")
if (!res.ok) {
const payload = await res.json().catch(() => undefined)
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
const blob = await res.blob() const blob = await res.blob()
const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db") const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db")
return { blob, filename } return { blob, filename }
@@ -56,20 +33,9 @@ export async function restoreSystemDatabaseBackup(baseUrl: string, file: File):
413, 413,
) )
} }
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), { await requestBlob(baseUrl, "/api/system/database/restore", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/octet-stream" }, headers: { "Content-Type": "application/octet-stream" },
body: file, body: file,
}) })
if (!res.ok) {
const payload = await res.json().catch(() => undefined)
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
} }