refactor(api): streamline API requests with requestJson and requestBlob functions

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.
This commit is contained in:
Denozordec
2026-09-05 01:42:44 +07:00
parent b9f430de16
commit 0071da24a2
10 changed files with 140 additions and 119 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)
+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()
+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)
+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)
}
} }