From 883842636b36689ee2de3ac6a8d53e89b0c7768f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 5 Sep 2026 01:42:44 +0700 Subject: [PATCH] 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. --- app/(main)/backups/page.tsx | 4 +- app/(main)/bgp/page.tsx | 7 +-- app/(main)/ospf/page.tsx | 19 +++---- app/(main)/terminal/page.tsx | 26 ++++----- components/app-sidebar.tsx | 30 ++++------- components/system-monitor-popover.tsx | 19 +++---- lib/backend-url.ts | 16 +++++- lib/data-source.tsx | 22 ++++---- shared/api/http-client.ts | 76 ++++++++++++++++++++++----- shared/api/system-database.ts | 40 ++------------ 10 files changed, 140 insertions(+), 119 deletions(-) diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index bdc6a9a..d7c0adf 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -28,6 +28,7 @@ import { useDataSource } from "@/lib/data-source" import { listServers } from "@/shared/api/servers" import { toFrontendServer } from "@/entities/server/model/mappers" 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 { Stepper, @@ -276,8 +277,7 @@ export default function BackupsPage() { } async function handleDownload(id: string, fallbackFilename: string) { - const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`) - if (!res.ok) throw new Error("Не удалось скачать файл") + const res = await requestBlob(backendUrl, `/api/backups/${id}/download`) const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement("a") diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx index ebd5153..a2d5d6a 100644 --- a/app/(main)/bgp/page.tsx +++ b/app/(main)/bgp/page.tsx @@ -23,6 +23,7 @@ import { XIcon, AlertCircleIcon, } from "lucide-react" import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" // ─── types ──────────────────────────────────────────────────────────────────── @@ -621,11 +622,7 @@ export default function BgpPage() { if (cancelled) return setLoading(true) setLiveError(null) - fetch(`${backendUrl}/api/bgp/sessions`) - .then(r => { - if (!r.ok) throw new Error(`HTTP ${r.status}`) - return r.json() as Promise - }) + void requestJson(backendUrl, "/api/bgp/sessions") .then(data => { if (cancelled) return setLiveSessions(data.map(backendToFrontend)) diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 1a22801..a06f5f5 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -16,6 +16,7 @@ import { } from "lucide-react" import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" import { Flag } from "@/components/flag" import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data" @@ -733,13 +734,14 @@ function InterfacesTab({ const ra = readStoredRouteOptimizerSettings() setOptimizing(true) try { - const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pingWeight: ra.pingWeight }), - }) - if (!r.ok) throw new Error(`HTTP ${r.status}`) - const data = await r.json() as BackendOspfOptimizeResponse + const data = await requestJson( + backendUrl, + `/api/servers/${filterServerId}/ospf/optimize`, + { + method: "POST", + body: JSON.stringify({ pingWeight: ra.pingWeight }), + }, + ) const byKey: Record = {} data.interfaces.forEach((row) => { byKey[`${data.serverId}-${row.id}`] = row.optimalCost @@ -1120,8 +1122,7 @@ export default function OspfPage() { if (cancelled) return setLoading(true) setLiveError(null) - fetch(`${backendUrl}/api/ospf/all`) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise }) + void requestJson(backendUrl, "/api/ospf/all") .then(data => { if (cancelled) return setLiveData(data); setFetchedAt(new Date()); setLoading(false) diff --git a/app/(main)/terminal/page.tsx b/app/(main)/terminal/page.tsx index 92b6f76..200abde 100644 --- a/app/(main)/terminal/page.tsx +++ b/app/(main)/terminal/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button" import { servers as mockServers } from "@/lib/data" import { Flag } from "@/components/flag" import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" import { TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, } from "lucide-react" @@ -259,12 +260,14 @@ function Terminal({ if (isLive && server.backendId !== null) { setExecuting(true) try { - const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ command: cmd }), - }) - const data = await res.json() as { output?: string; error?: string } + const data = await requestJson<{ output?: string; error?: string }>( + backendUrl, + `/api/servers/${server.backendId}/exec`, + { + method: "POST", + body: JSON.stringify({ command: cmd }), + }, + ) const text = data.output ?? data.error ?? "(empty response)" const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output" text.split("\n").forEach(line => @@ -427,7 +430,7 @@ interface BackendServer { } export default function TerminalPage() { - const { mode, backendUrl } = useDataSource() + const { mode, backendUrl, prefsHydrated } = useDataSource() const isLive = mode === "live" // Server list state @@ -437,14 +440,13 @@ export default function TerminalPage() { // Load servers from backend when in live mode useEffect(() => { - if (!isLive) return + if (!isLive || !prefsHydrated) return let cancelled = false queueMicrotask(() => { if (cancelled) return setServersLoading(true) - fetch(`${backendUrl}/api/servers`) - .then(r => r.json() as Promise) - .then(data => { + void requestJson(backendUrl, "/api/servers") + .then((data) => { if (cancelled) return setLiveServers(data.map(s => ({ uid: String(s.id), @@ -462,7 +464,7 @@ export default function TerminalPage() { .catch(() => { if (!cancelled) setServersLoading(false) }) }) return () => { cancelled = true } - }, [isLive, backendUrl, refreshKey]) + }, [isLive, backendUrl, refreshKey, prefsHydrated]) const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers() diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 34f678e..7f9f397 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -38,6 +38,7 @@ import { } from "lucide-react" import { useDataSource } from "@/lib/data-source" import { useEvoBGP } from "@/lib/evobgp-context" +import { requestJson } from "@/shared/api/http-client" import { formatSidebarBadgeCount, mockSidebarBadgesByUrl, @@ -104,7 +105,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [ type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number } export function AppSidebar({ ...props }: React.ComponentProps) { - const { mode, backendUrl } = useDataSource() + const { mode, backendUrl, prefsHydrated } = useDataSource() const evo = useEvoBGP() const [mounted, setMounted] = React.useState(false) const [liveCounts, setLiveCounts] = React.useState(null) @@ -118,30 +119,21 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }, []) React.useEffect(() => { - if (mode !== "live") { - setLiveCounts(null) + if (!prefsHydrated || mode !== "live") { + if (mode !== "live") setLiveCounts(null) return } let cancelled = false const load = async () => { try { - const base = backendUrl.replace(/\/$/, "") - const [cRes, gRes] = await Promise.all([ - fetch(`${base}/api/sidebar-counts`), - fetch(`${base}/api/filters/gre-tunnels`), + const [cJson, gJson] = await Promise.all([ + requestJson(backendUrl, "/api/sidebar-counts"), + requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch( + () => ({ tunnels: [] as unknown[] }), + ), ]) if (cancelled) return - if (!cRes.ok) { - 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 }) + setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length }) } catch { if (!cancelled) setLiveCounts(null) } @@ -152,7 +144,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { cancelled = true window.clearInterval(id) } - }, [mode, backendUrl]) + }, [mode, backendUrl, prefsHydrated]) const navGroups = React.useMemo((): NavGroup[] => { function badgeFor(url: string): string | undefined { diff --git a/components/system-monitor-popover.tsx b/components/system-monitor-popover.tsx index c278808..0f14c40 100644 --- a/components/system-monitor-popover.tsx +++ b/components/system-monitor-popover.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" import { filters, pingProbes, servers } from "@/lib/data" import type { SidebarCountsDto } from "@/lib/sidebar-badges" +import { resolveApiUrl, requestJson } from "@/shared/api/http-client" type MonitorMetric = { 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 */ export function SystemMonitorPopover() { - const { mode, backendUrl } = useDataSource() + const { mode, backendUrl, prefsHydrated } = useDataSource() const [healthOk, setHealthOk] = useState(null) const [counts, setCounts] = useState(null) useEffect(() => { + if (!prefsHydrated) return if (mode !== "live") { setHealthOk(true) setCounts({ @@ -98,11 +100,10 @@ export function SystemMonitorPopover() { let cancelled = false const load = async () => { - const base = backendUrl.replace(/\/$/, "") try { - const [hRes, cRes] = await Promise.all([ - fetch(`${base}/health`), - fetch(`${base}/api/sidebar-counts`), + const [hRes, counts] = await Promise.all([ + fetch(resolveApiUrl(backendUrl, "/health"), { signal: AbortSignal.timeout(3000) }), + requestJson(backendUrl, "/api/sidebar-counts"), ]) if (cancelled) return if (hRes.ok) { @@ -111,11 +112,7 @@ export function SystemMonitorPopover() { } else { setHealthOk(false) } - if (cRes.ok) { - setCounts((await cRes.json()) as SidebarCountsDto) - } else { - setCounts(null) - } + setCounts(counts) } catch { if (!cancelled) { setHealthOk(false) @@ -129,7 +126,7 @@ export function SystemMonitorPopover() { cancelled = true window.clearInterval(id) } - }, [mode, backendUrl]) + }, [mode, backendUrl, prefsHydrated]) const serversCount = counts?.servers ?? 0 const filtersCount = counts?.filterRules ?? 0 diff --git a/lib/backend-url.ts b/lib/backend-url.ts index 557007e..09ead17 100644 --- a/lib/backend-url.ts +++ b/lib/backend-url.ts @@ -26,12 +26,26 @@ export function isBackendUrlLocked(): boolean { 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 { const cfg = configuredBackendUrl() 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 } 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 } diff --git a/lib/data-source.tsx b/lib/data-source.tsx index 878ca98..c3e9744 100644 --- a/lib/data-source.tsx +++ b/lib/data-source.tsx @@ -9,6 +9,7 @@ import { LOCAL_DEFAULT_BACKEND_URL, resolveStoredBackendUrl, } from "@/lib/backend-url" +import { resolveApiUrl } from "@/shared/api/http-client" // ── types ───────────────────────────────────────────────────────────────────── @@ -49,8 +50,13 @@ function readStoredMode(): DataSourceMode { return defaultDataSourceMode() } -function readStoredBackendUrl(): string { - if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL +function initialBackendUrl(): string { + 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)) } @@ -60,7 +66,7 @@ function normalizeBackendUrl(url: string): string { export function DataSourceProvider({ children }: { children: React.ReactNode }) { const [mode, setModeState] = useState(defaultDataSourceMode) - const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL) + const [backendUrl, setBackendUrlState] = useState(initialBackendUrl) const [prefsHydrated, setPrefsHydrated] = useState(false) const [backendStatus, setBackendStatus] = useState(undefined) const backendUrlLocked = isBackendUrlLocked() @@ -68,10 +74,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode }) useEffect(() => { const storedMode = readStoredMode() - let url = readStoredBackendUrl() - if (configuredBackendUrl().kind === "same-origin") { - url = window.location.origin - } + const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND)) setModeState(storedMode) setBackendUrlState(url) setPrefsHydrated(true) @@ -91,10 +94,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode }) }, [backendUrlLocked]) const checkBackend = useCallback(async () => { - const healthUrl = - configuredBackendUrl().kind === "same-origin" - ? "/health" - : `${normalizeBackendUrl(backendUrl)}/health` + const healthUrl = resolveApiUrl(backendUrl, "/health") try { const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) }) setBackendStatus(res.ok) diff --git a/shared/api/http-client.ts b/shared/api/http-client.ts index 48b0a25..2df2a88 100644 --- a/shared/api/http-client.ts +++ b/shared/api/http-client.ts @@ -21,38 +21,72 @@ function trimBaseUrl(baseUrl: string): string { 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") { 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 } +/** 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 { + 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( baseUrl: string, path: string, init?: RequestInit, ): Promise { const hasBody = init?.body != null - const headers = new Headers(init?.headers) + const headers = withAuthHeaders(init?.headers) if (hasBody && !headers.has("Content-Type")) { 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, headers, }) - if (res.status === 401 && typeof window !== "undefined" && isAuthEnabled()) { - const ok = redirectToPortalLogin() - if (!ok) redirectToPortalLoginInteractive() - throw new ApiClientError("Unauthorized", 401) - } + if (res.status === 401) handleUnauthorized() if (res.status === 204) return undefined as T @@ -70,3 +104,21 @@ export async function requestJson( 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 { + 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 +} diff --git a/shared/api/system-database.ts b/shared/api/system-database.ts index eca80e0..838c986 100644 --- a/shared/api/system-database.ts +++ b/shared/api/system-database.ts @@ -1,19 +1,7 @@ -import { ApiClientError } from "@/shared/api/http-client" -import { configuredBackendUrl } from "@/lib/backend-url" +import { ApiClientError, requestBlob } from "@/shared/api/http-client" 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 { if (!contentDisposition) return fallback const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition) @@ -32,18 +20,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str export async function downloadSystemDatabaseBackup( baseUrl: string, ): Promise<{ blob: Blob; filename: string }> { - const res = await fetch(resolveDatabaseApiUrl(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 res = await requestBlob(baseUrl, "/api/system/database/backup") const blob = await res.blob() const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db") return { blob, filename } @@ -56,20 +33,9 @@ export async function restoreSystemDatabaseBackup(baseUrl: string, file: File): 413, ) } - const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), { + await requestBlob(baseUrl, "/api/system/database/restore", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, 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) - } }