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 file: Dockerfile.frontend
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
build-args: |
BACKEND_INTERNAL_URL=http://backend:8000
NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
tags: | tags: |
${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ 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 RUN npm ci --workspace=@mmapp/contracts --include-workspace-root --ignore-scripts
FROM deps AS build 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 packages/contracts packages/contracts
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./ COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
COPY app app COPY app app
+29 -4
View File
@@ -825,7 +825,7 @@ export default function SettingsPage() {
const [copied, setCopied] = useState<string | null>(null) const [copied, setCopied] = useState<string | null>(null)
// data source // data source
const { mode, setMode, backendUrl, setBackendUrl, backendStatus, checkBackend } = useDataSource() const { mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, backendStatus, checkBackend } = useDataSource()
const evo = useEvoBGP() const evo = useEvoBGP()
const [evoBaseDraft, setEvoBaseDraft] = useState("") const [evoBaseDraft, setEvoBaseDraft] = useState("")
const [evoEnabledDraft, setEvoEnabledDraft] = useState(false) const [evoEnabledDraft, setEvoEnabledDraft] = useState(false)
@@ -834,6 +834,17 @@ export default function SettingsPage() {
const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null) const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null)
const [urlDraft, setUrlDraft] = useState(backendUrl) const [urlDraft, setUrlDraft] = useState(backendUrl)
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [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 [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null) const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
const [showEvoKey, setShowEvoKey] = useState(false) const [showEvoKey, setShowEvoKey] = useState(false)
@@ -984,17 +995,31 @@ export default function SettingsPage() {
</SettingRow> </SettingRow>
{/* backend URL — visible in both modes so user can configure before switching */} {/* 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"> <div className="flex items-center gap-2">
<Input <Input
className="w-56 h-8 text-sm font-mono" className="w-56 h-8 text-sm font-mono"
value={urlDraft} value={urlDraft}
onChange={e => setUrlDraft(e.target.value)} 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" placeholder="http://localhost:8000"
/> />
<Button variant="outline" size="sm" className="h-8 px-2.5 shrink-0" <Button variant="outline" size="sm" className="h-8 px-2.5 shrink-0"
onClick={() => { setBackendUrl(urlDraft.replace(/\/$/, "")); checkBackend() }} onClick={commitBackendUrl}
title="Проверить соединение"> title="Проверить соединение">
<RefreshCwIcon className="size-3.5" /> <RefreshCwIcon className="size-3.5" />
</Button> </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" "use client"
import { createContext, useContext, useEffect, useState, useCallback } from "react" import { createContext, useContext, useEffect, useState, useCallback } from "react"
import {
configuredBackendUrl,
defaultDataSourceMode,
isBackendUrlLocked,
LOCAL_DEFAULT_BACKEND_URL,
resolveStoredBackendUrl,
} from "@/lib/backend-url"
// ── types ───────────────────────────────────────────────────────────────────── // ── types ─────────────────────────────────────────────────────────────────────
@@ -11,6 +18,7 @@ interface DataSourceContextValue {
setMode: (m: DataSourceMode) => void setMode: (m: DataSourceMode) => void
backendUrl: string backendUrl: string
setBackendUrl: (url: string) => void setBackendUrl: (url: string) => void
backendUrlLocked: boolean
/** undefined = не проверялось, true = OK, false = недоступен */ /** undefined = не проверялось, true = OK, false = недоступен */
backendStatus: boolean | undefined backendStatus: boolean | undefined
checkBackend: () => Promise<void> checkBackend: () => Promise<void>
@@ -22,31 +30,35 @@ const DataSourceContext = createContext<DataSourceContextValue | null>(null)
const LS_MODE = "routerlists:data-source" const LS_MODE = "routerlists:data-source"
const LS_BACKEND = "routerlists:backend-url" const LS_BACKEND = "routerlists:backend-url"
const DEFAULT_URL = "http://localhost:8000"
function readStoredMode(): DataSourceMode { function readStoredMode(): DataSourceMode {
if (typeof window === "undefined") return "mock" if (typeof window === "undefined") return defaultDataSourceMode()
const stored = localStorage.getItem(LS_MODE) as DataSourceMode | null 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 { function readStoredBackendUrl(): string {
if (typeof window === "undefined") return DEFAULT_URL if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL
return localStorage.getItem(LS_BACKEND) ?? DEFAULT_URL return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
}
function normalizeBackendUrl(url: string): string {
return url.trim().replace(/\/$/, "")
} }
export function DataSourceProvider({ children }: { children: React.ReactNode }) { export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>("mock") const [mode, setModeState] = useState<DataSourceMode>(() =>
const [backendUrl, setBackendUrlState] = useState(DEFAULT_URL) 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 [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked()
useEffect(() => { useEffect(() => {
queueMicrotask(() => { if (configuredBackendUrl().kind !== "same-origin") return
const nextMode = readStoredMode() setBackendUrlState(window.location.origin)
const nextBackendUrl = readStoredBackendUrl()
setModeState(nextMode)
setBackendUrlState(nextBackendUrl)
})
}, []) }, [])
const setMode = useCallback((m: DataSourceMode) => { const setMode = useCallback((m: DataSourceMode) => {
@@ -55,27 +67,30 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
}, []) }, [])
const setBackendUrl = useCallback((url: string) => { const setBackendUrl = useCallback((url: string) => {
setBackendUrlState(url) if (backendUrlLocked) return
localStorage.setItem(LS_BACKEND, url) const normalized = normalizeBackendUrl(url)
}, []) setBackendUrlState(normalized)
localStorage.setItem(LS_BACKEND, normalized)
}, [backendUrlLocked])
const checkBackend = useCallback(async () => { const checkBackend = useCallback(async () => {
const url = (localStorage.getItem(LS_BACKEND) ?? DEFAULT_URL).replace(/\/$/, "") const url = normalizeBackendUrl(backendUrl)
try { try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) }) const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) })
setBackendStatus(res.ok) setBackendStatus(res.ok)
} catch { } catch {
setBackendStatus(false) setBackendStatus(false)
} }
}, []) }, [backendUrl])
// Auto-check when mode switches to live (defer to avoid set-state-in-effect rule on async loader)
useEffect(() => { useEffect(() => {
if (mode === "live") queueMicrotask(() => { void checkBackend() }) if (mode === "live") queueMicrotask(() => { void checkBackend() })
}, [mode, checkBackend]) }, [mode, checkBackend])
return ( return (
<DataSourceContext.Provider value={{ mode, setMode, backendUrl, setBackendUrl, backendStatus, checkBackend }}> <DataSourceContext.Provider
value={{ mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, backendStatus, checkBackend }}
>
{children} {children}
</DataSourceContext.Provider> </DataSourceContext.Provider>
) )
+11
View File
@@ -1,7 +1,18 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const backendInternalUrl = (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(
/\/$/,
"",
);
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
async rewrites() {
return [
{ source: "/health", destination: `${backendInternalUrl}/health` },
{ source: "/api/:path*", destination: `${backendInternalUrl}/api/:path*` },
];
},
}; };
export default nextConfig; export default nextConfig;