chore: enhance frontend configuration for backend integration
Docker images / backend-image (push) Successful in 39s
Docker images / frontend-image (push) Successful in 1m36s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped

- Added environment variables for backend URLs in the Dockerfile to facilitate dynamic configuration.
- Updated Next.js configuration to include rewrites for health and API endpoints, improving backend connectivity.
- Modified settings page to handle backend URL locking and normalization, enhancing user experience and data integrity.
This commit is contained in:
Denozordec
2026-05-12 15:28:20 +07:00
parent 4b240d70d0
commit 4489dfb2d6
6 changed files with 117 additions and 24 deletions
+4
View File
@@ -125,6 +125,10 @@ jobs:
file: Dockerfile.frontend
platforms: linux/amd64
push: true
build-args: |
BACKEND_INTERNAL_URL=http://backend:8000
NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
+6
View File
@@ -8,6 +8,12 @@ COPY packages/contracts/package.json packages/contracts/
RUN npm ci --workspace=@mmapp/contracts --include-workspace-root --ignore-scripts
FROM deps AS build
ARG BACKEND_INTERNAL_URL=http://backend:8000
ARG NEXT_PUBLIC_BACKEND_URL=same-origin
ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
COPY packages/contracts packages/contracts
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
COPY app app
+29 -4
View File
@@ -825,7 +825,7 @@ export default function SettingsPage() {
const [copied, setCopied] = useState<string | null>(null)
// data source
const { mode, setMode, backendUrl, setBackendUrl, backendStatus, checkBackend } = useDataSource()
const { mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, backendStatus, checkBackend } = useDataSource()
const evo = useEvoBGP()
const [evoBaseDraft, setEvoBaseDraft] = useState("")
const [evoEnabledDraft, setEvoEnabledDraft] = useState(false)
@@ -834,6 +834,17 @@ export default function SettingsPage() {
const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null)
const [urlDraft, setUrlDraft] = useState(backendUrl)
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
useEffect(() => {
setUrlDraft(backendUrl)
}, [backendUrl])
const commitBackendUrl = useCallback(() => {
const normalized = urlDraft.trim().replace(/\/$/, "")
setUrlDraft(normalized)
setBackendUrl(normalized)
void checkBackend()
}, [urlDraft, setBackendUrl, checkBackend])
const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
const [showEvoKey, setShowEvoKey] = useState(false)
@@ -984,17 +995,31 @@ export default function SettingsPage() {
</SettingRow>
{/* backend URL — visible in both modes so user can configure before switching */}
<SettingRow label="URL бекенда" description="Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)">
<SettingRow
label="URL бекенда"
description={
backendUrlLocked
? "В Docker-образе URL API задаётся через прокси фронтенда на backend; поле только для просмотра."
: "Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)"
}
>
<div className="flex items-center gap-2">
<Input
className="w-56 h-8 text-sm font-mono"
value={urlDraft}
onChange={e => setUrlDraft(e.target.value)}
onBlur={() => { setBackendUrl(urlDraft.replace(/\/$/, "")) }}
onBlur={commitBackendUrl}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault()
commitBackendUrl()
}
}}
readOnly={backendUrlLocked}
placeholder="http://localhost:8000"
/>
<Button variant="outline" size="sm" className="h-8 px-2.5 shrink-0"
onClick={() => { setBackendUrl(urlDraft.replace(/\/$/, "")); checkBackend() }}
onClick={commitBackendUrl}
title="Проверить соединение">
<RefreshCwIcon className="size-3.5" />
</Button>
+32
View File
@@ -0,0 +1,32 @@
export const LOCAL_DEFAULT_BACKEND_URL = "http://localhost:8000"
export type ConfiguredBackendUrl =
| { kind: "local" }
| { kind: "fixed"; url: string }
| { kind: "same-origin" }
export function configuredBackendUrl(): ConfiguredBackendUrl {
const raw = process.env.NEXT_PUBLIC_BACKEND_URL
if (raw === undefined || raw === "local") return { kind: "local" }
if (raw === "" || raw === "same-origin") return { kind: "same-origin" }
return { kind: "fixed", url: raw.replace(/\/$/, "") }
}
export function defaultDataSourceMode(): "mock" | "live" {
return process.env.NEXT_PUBLIC_DEFAULT_DATA_SOURCE === "live" ? "live" : "mock"
}
export function isBackendUrlLocked(): boolean {
const cfg = configuredBackendUrl()
return cfg.kind === "same-origin" || cfg.kind === "fixed"
}
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") {
return window.location.origin
}
const trimmed = stored?.trim().replace(/\/$/, "")
return trimmed || LOCAL_DEFAULT_BACKEND_URL
}
+35 -20
View File
@@ -1,6 +1,13 @@
"use client"
import { createContext, useContext, useEffect, useState, useCallback } from "react"
import {
configuredBackendUrl,
defaultDataSourceMode,
isBackendUrlLocked,
LOCAL_DEFAULT_BACKEND_URL,
resolveStoredBackendUrl,
} from "@/lib/backend-url"
// ── types ─────────────────────────────────────────────────────────────────────
@@ -11,6 +18,7 @@ interface DataSourceContextValue {
setMode: (m: DataSourceMode) => void
backendUrl: string
setBackendUrl: (url: string) => void
backendUrlLocked: boolean
/** undefined = не проверялось, true = OK, false = недоступен */
backendStatus: boolean | undefined
checkBackend: () => Promise<void>
@@ -22,31 +30,35 @@ const DataSourceContext = createContext<DataSourceContextValue | null>(null)
const LS_MODE = "routerlists:data-source"
const LS_BACKEND = "routerlists:backend-url"
const DEFAULT_URL = "http://localhost:8000"
function readStoredMode(): DataSourceMode {
if (typeof window === "undefined") return "mock"
if (typeof window === "undefined") return defaultDataSourceMode()
const stored = localStorage.getItem(LS_MODE) as DataSourceMode | null
return stored === "mock" || stored === "live" ? stored : "mock"
return stored === "mock" || stored === "live" ? stored : defaultDataSourceMode()
}
function readStoredBackendUrl(): string {
if (typeof window === "undefined") return DEFAULT_URL
return localStorage.getItem(LS_BACKEND) ?? DEFAULT_URL
if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL
return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
}
function normalizeBackendUrl(url: string): string {
return url.trim().replace(/\/$/, "")
}
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>("mock")
const [backendUrl, setBackendUrlState] = useState(DEFAULT_URL)
const [mode, setModeState] = useState<DataSourceMode>(() =>
typeof window === "undefined" ? defaultDataSourceMode() : readStoredMode(),
)
const [backendUrl, setBackendUrlState] = useState(() =>
typeof window === "undefined" ? LOCAL_DEFAULT_BACKEND_URL : readStoredBackendUrl(),
)
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked()
useEffect(() => {
queueMicrotask(() => {
const nextMode = readStoredMode()
const nextBackendUrl = readStoredBackendUrl()
setModeState(nextMode)
setBackendUrlState(nextBackendUrl)
})
if (configuredBackendUrl().kind !== "same-origin") return
setBackendUrlState(window.location.origin)
}, [])
const setMode = useCallback((m: DataSourceMode) => {
@@ -55,27 +67,30 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
}, [])
const setBackendUrl = useCallback((url: string) => {
setBackendUrlState(url)
localStorage.setItem(LS_BACKEND, url)
}, [])
if (backendUrlLocked) return
const normalized = normalizeBackendUrl(url)
setBackendUrlState(normalized)
localStorage.setItem(LS_BACKEND, normalized)
}, [backendUrlLocked])
const checkBackend = useCallback(async () => {
const url = (localStorage.getItem(LS_BACKEND) ?? DEFAULT_URL).replace(/\/$/, "")
const url = normalizeBackendUrl(backendUrl)
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) })
setBackendStatus(res.ok)
} catch {
setBackendStatus(false)
}
}, [])
}, [backendUrl])
// Auto-check when mode switches to live (defer to avoid set-state-in-effect rule on async loader)
useEffect(() => {
if (mode === "live") queueMicrotask(() => { void checkBackend() })
}, [mode, checkBackend])
return (
<DataSourceContext.Provider value={{ mode, setMode, backendUrl, setBackendUrl, backendStatus, checkBackend }}>
<DataSourceContext.Provider
value={{ mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, backendStatus, checkBackend }}
>
{children}
</DataSourceContext.Provider>
)
+11
View File
@@ -1,7 +1,18 @@
import type { NextConfig } from "next";
const backendInternalUrl = (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(
/\/$/,
"",
);
const nextConfig: NextConfig = {
output: "standalone",
async rewrites() {
return [
{ source: "/health", destination: `${backendInternalUrl}/health` },
{ source: "/api/:path*", destination: `${backendInternalUrl}/api/:path*` },
];
},
};
export default nextConfig;