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:
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user