chore: enhance frontend configuration for backend integration
- 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:
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user