Skip to content

Commit 895f145

Browse files
committed
refactor(frontend): replace sed/placeholder hack with runtime config API
- Add /api/config route that reads env vars at runtime - Add ConfigProvider React context for app-wide config access - Auto-detect API/WS URLs from window.location in K3S deployments - Build production image with basePath=/llm-admin (Next.js requirement) - Remove all placeholder strings and sed replacements Docker images: - Production (Dockerfile): basePath=/llm-admin baked in, for K3S - Development (Dockerfile.dev): no basePath, runs at root Environment variables: - NEXT_PUBLIC_API_URL: Required when API is on different origin (dev) - NEXT_PUBLIC_WS_URL: Required when WS is on different origin (dev) - In K3S: URLs auto-detected from window.location (no env vars needed)
1 parent a59e0b8 commit 895f145

File tree

17 files changed

+355
-151
lines changed

17 files changed

+355
-151
lines changed

docker-compose.dev.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,18 @@ services:
6161
replicas: ${CELERY_WORKERS:-1}
6262

6363
frontend:
64+
# Development mode with hot-reload, no basePath (runs at root)
65+
# Access at: http://localhost:8001
6466
build:
6567
context: ./frontend
6668
dockerfile: Dockerfile.dev
6769
args:
6870
USER_ID: ${UID:-1000}
6971
GROUP_ID: ${GID:-1000}
7072
environment:
73+
# API/WS URLs required when frontend and backend are on different ports
7174
- NEXT_PUBLIC_API_URL=http://localhost:8000
7275
- NEXT_PUBLIC_WS_URL=ws://localhost:8000
73-
- NEXT_PUBLIC_APP_NAME=LLM Gateway
74-
- NEXT_PUBLIC_DEFAULT_LOCALE=en
7576
networks:
7677
- llm-network
7778
ports:

docker-compose.local.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,18 @@ services:
6868
replicas: ${CELERY_WORKERS:-1}
6969

7070
frontend:
71+
# Development mode with hot-reload, no basePath (runs at root)
72+
# Access at: http://localhost:8011
7173
build:
7274
context: ./frontend
7375
dockerfile: Dockerfile.dev
7476
args:
7577
USER_ID: ${UID:-1000}
7678
GROUP_ID: ${GID:-1000}
7779
environment:
80+
# API/WS URLs required when frontend and backend are on different ports
7881
- NEXT_PUBLIC_API_URL=http://localhost:8010
7982
- NEXT_PUBLIC_WS_URL=ws://localhost:8010
80-
- NEXT_PUBLIC_APP_NAME=LLM Gateway
81-
- NEXT_PUBLIC_DEFAULT_LOCALE=en
8283
networks:
8384
- llm-network
8485
ports:

docker-compose.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,14 @@ services:
5858
replicas: ${CELERY_WORKERS:-1}
5959

6060
frontend:
61+
# This image is built with basePath=/llm-admin (for K3S/reverse proxy deployments)
62+
# Access at: http://localhost:8001/llm-admin
63+
# For development without basePath, use docker-compose.dev.yml instead
6164
image: lintoai/llm-gateway-frontend:latest-unstable
6265
environment:
66+
# API/WS URLs required when frontend and backend are on different origins
6367
- NEXT_PUBLIC_API_URL=http://localhost:8000
6468
- NEXT_PUBLIC_WS_URL=ws://localhost:8000
65-
- NEXT_PUBLIC_APP_NAME=LLM Gateway
66-
- NEXT_PUBLIC_DEFAULT_LOCALE=en
67-
# Subpath deployment (optional): set BASE_PATH to deploy behind reverse proxy
68-
# - BASE_PATH=/llm-admin
6969
networks:
7070
- llm-network
7171
ports:

frontend/Dockerfile

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,10 @@ COPY . .
1919
# Disable telemetry during build
2020
ENV NEXT_TELEMETRY_DISABLED=1
2121

22-
# Build without basePath (root deployment)
23-
# For subpath deployment, rebuild with NEXT_PUBLIC_BASE_PATH set
24-
ENV NEXT_PUBLIC_BASE_PATH=""
25-
ENV NEXT_PUBLIC_API_URL="__NEXT_API_URL_PLACEHOLDER__"
26-
ENV NEXT_PUBLIC_WS_URL="__NEXT_WS_URL_PLACEHOLDER__"
27-
28-
# Build the application
22+
# Build with /llm-admin basePath (hardcoded for K3S/production deployments)
23+
# This image is meant for deployment behind a reverse proxy at /llm-admin
24+
# For local development without basePath, use docker-compose.dev.yml
25+
ENV BASE_PATH=/llm-admin
2926
RUN npm run build
3027

3128
# Stage 3: Runner
@@ -44,11 +41,11 @@ COPY --from=builder /app/public ./public
4441
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
4542
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
4643

47-
# Copy entrypoint script (from builder to ensure it's available regardless of build context)
44+
# Copy entrypoint script
4845
COPY --from=builder --chown=nextjs:nodejs /app/docker-entrypoint.sh /app/
4946
RUN chmod +x /app/docker-entrypoint.sh
5047

51-
# Ensure nextjs user can write to /app for sed -i operations at runtime
48+
# Set ownership for nextjs user
5249
RUN chown -R nextjs:nodejs /app
5350

5451
USER nextjs
@@ -58,9 +55,10 @@ EXPOSE 3000
5855
ENV PORT=3000
5956
ENV HOSTNAME="0.0.0.0"
6057

61-
# Runtime basePath configuration (optional)
62-
# Set BASE_PATH=/your-subpath to deploy under a subpath
63-
ENV BASE_PATH=""
58+
# Runtime configuration (set via docker-compose or K8S ConfigMap):
59+
# - NEXT_PUBLIC_API_URL: API endpoint (required for cross-origin, e.g., http://localhost:8000)
60+
# - NEXT_PUBLIC_WS_URL: WebSocket endpoint (required for cross-origin, e.g., ws://localhost:8000)
61+
# If not set, URLs are auto-detected from window.location (for same-origin K3S deployments)
6462

6563
ENTRYPOINT ["/app/docker-entrypoint.sh"]
6664
CMD ["node", "server.js"]

frontend/app/[locale]/layout.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getMessages } from 'next-intl/server';
33
import { notFound } from 'next/navigation';
44
import { routing } from '@/i18n/routing';
55
import { QueryProvider } from '@/components/providers/QueryProvider';
6+
import { ConfigProvider } from '@/components/providers/ConfigProvider';
67
import { Header } from '@/components/layout/Header';
78
import { Sidebar } from '@/components/layout/Sidebar';
89
import { Footer } from '@/components/layout/Footer';
@@ -34,15 +35,17 @@ export default async function LocaleLayout({
3435
<body className="antialiased" suppressHydrationWarning>
3536
<QueryProvider>
3637
<NextIntlClientProvider messages={messages}>
37-
<div className="flex h-screen flex-col">
38-
<Header />
39-
<div className="flex flex-1 overflow-hidden">
40-
<Sidebar locale={locale} />
41-
<main className="flex-1 overflow-auto p-6">{children}</main>
38+
<ConfigProvider>
39+
<div className="flex h-screen flex-col">
40+
<Header />
41+
<div className="flex flex-1 overflow-hidden">
42+
<Sidebar locale={locale} />
43+
<main className="flex-1 overflow-auto p-6">{children}</main>
44+
</div>
45+
<Footer />
4246
</div>
43-
<Footer />
44-
</div>
45-
<Toaster />
47+
<Toaster />
48+
</ConfigProvider>
4649
</NextIntlClientProvider>
4750
</QueryProvider>
4851
</body>

frontend/app/api/config/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { NextResponse } from 'next/server';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export async function GET() {
6+
return NextResponse.json({
7+
apiUrl: process.env.NEXT_PUBLIC_API_URL || '',
8+
wsUrl: process.env.NEXT_PUBLIC_WS_URL || '',
9+
basePath: process.env.BASE_PATH || '',
10+
appName: process.env.NEXT_PUBLIC_APP_NAME || 'LLM Gateway',
11+
});
12+
}

frontend/components/layout/Footer.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@ import { useTranslations } from 'next-intl';
44
import { useEffect, useState } from 'react';
55
import { Badge } from '@/components/ui/badge';
66
import { Separator } from '@/components/ui/separator';
7-
import { api } from '@/lib/api';
7+
import { useConfig } from '@/components/providers/ConfigProvider';
8+
import { getApi } from '@/lib/api';
89

910
export function Footer() {
1011
const t = useTranslations('app');
12+
const config = useConfig();
1113
const [isConnected, setIsConnected] = useState(true);
1214

1315
useEffect(() => {
16+
// Wait for config to load before checking connection
17+
if (config.isLoading) return;
18+
1419
const checkConnection = async () => {
1520
try {
21+
const api = getApi();
1622
await api.get('/healthcheck');
1723
setIsConnected(true);
1824
} catch (error) {
@@ -24,7 +30,7 @@ export function Footer() {
2430
const interval = setInterval(checkConnection, 30000); // Check every 30s
2531

2632
return () => clearInterval(interval);
27-
}, []);
33+
}, [config.isLoading]);
2834

2935
return (
3036
<footer className="border-t bg-background">
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use client';
2+
3+
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
4+
import type { RuntimeConfig, ConfigContextValue } from '@/lib/config';
5+
import { setConfigCache } from '@/lib/config';
6+
7+
const defaultConfig: ConfigContextValue = {
8+
apiUrl: '',
9+
wsUrl: '',
10+
basePath: '',
11+
appName: 'LLM Gateway',
12+
isLoading: true,
13+
};
14+
15+
const ConfigContext = createContext<ConfigContextValue>(defaultConfig);
16+
17+
export function ConfigProvider({ children }: { children: ReactNode }) {
18+
const [config, setConfig] = useState<ConfigContextValue>(defaultConfig);
19+
20+
useEffect(() => {
21+
async function loadConfig() {
22+
try {
23+
const response = await fetch('/api/config');
24+
if (!response.ok) {
25+
throw new Error(`Config fetch failed: ${response.status}`);
26+
}
27+
const data: RuntimeConfig = await response.json();
28+
29+
// Update the module-level cache for synchronous access
30+
setConfigCache(data);
31+
32+
setConfig({
33+
...data,
34+
isLoading: false,
35+
});
36+
} catch (error) {
37+
console.error('Failed to load runtime config:', error);
38+
// Set loading to false even on error to allow app to render
39+
setConfig((prev) => ({ ...prev, isLoading: false }));
40+
}
41+
}
42+
43+
loadConfig();
44+
}, []);
45+
46+
return (
47+
<ConfigContext.Provider value={config}>
48+
{children}
49+
</ConfigContext.Provider>
50+
);
51+
}
52+
53+
/**
54+
* Hook to access runtime configuration.
55+
* Must be used within a ConfigProvider.
56+
*/
57+
export function useConfig(): ConfigContextValue {
58+
const context = useContext(ConfigContext);
59+
if (context === undefined) {
60+
throw new Error('useConfig must be used within a ConfigProvider');
61+
}
62+
return context;
63+
}

frontend/docker-entrypoint.sh

Lines changed: 4 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,9 @@
11
#!/bin/sh
22
set -e
33

4-
BASEPATH_PLACEHOLDER="/__NEXT_BASEPATH_PLACEHOLDER__"
5-
BASEPATH_PLACEHOLDER_ESCAPED="\\\\\\\\/__NEXT_BASEPATH_PLACEHOLDER__"
6-
API_URL_PLACEHOLDER="__NEXT_API_URL_PLACEHOLDER__"
7-
WS_URL_PLACEHOLDER="__NEXT_WS_URL_PLACEHOLDER__"
4+
echo "Starting LLM Gateway Frontend"
5+
echo " basePath: /llm-admin (built-in)"
6+
echo " API_URL: ${NEXT_PUBLIC_API_URL:-auto-detect from window.location}"
7+
echo " WS_URL: ${NEXT_PUBLIC_WS_URL:-auto-detect from window.location}"
88

9-
if [ -n "$BASE_PATH" ]; then
10-
echo "Configuring basePath: $BASE_PATH"
11-
ESCAPED_BASE_PATH=$(echo "$BASE_PATH" | sed 's|/|\\/|g')
12-
sed -i "s|${BASEPATH_PLACEHOLDER}|${BASE_PATH}|g" /app/server.js 2>/dev/null || true
13-
sed -i "s|${BASEPATH_PLACEHOLDER}|${BASE_PATH}|g" /app/.next/routes-manifest.json 2>/dev/null || true
14-
# Escaped version first (for regex patterns in JSON)
15-
find /app/.next/static -type f -name "*.json" -exec sed -i "s|${BASEPATH_PLACEHOLDER_ESCAPED}|${ESCAPED_BASE_PATH}|g" {} + 2>/dev/null || true
16-
find /app/.next/static -type f \( -name "*.js" -o -name "*.json" \) -exec sed -i "s|${BASEPATH_PLACEHOLDER}|${BASE_PATH}|g" {} + 2>/dev/null || true
17-
find /app/.next/server -type f \( -name "*.html" -o -name "*.rsc" -o -name "*.meta" -o -name "*.body" \) -exec sed -i "s|${BASEPATH_PLACEHOLDER}|${BASE_PATH}|g" {} + 2>/dev/null || true
18-
find /app/.next/server -type f -name "*client-reference-manifest.js" -exec sed -i "s|${BASEPATH_PLACEHOLDER}|${BASE_PATH}|g" {} + 2>/dev/null || true
19-
# Also handle prerender manifests and action manifests
20-
find /app/.next -type f -name "*.json" -exec sed -i "s|${BASEPATH_PLACEHOLDER}|${BASE_PATH}|g" {} + 2>/dev/null || true
21-
else
22-
echo "No BASE_PATH set, running at root path"
23-
sed -i "s|${BASEPATH_PLACEHOLDER}||g" /app/server.js 2>/dev/null || true
24-
sed -i "s|${BASEPATH_PLACEHOLDER}||g" /app/.next/routes-manifest.json 2>/dev/null || true
25-
# Escaped version first (for regex patterns in JSON)
26-
find /app/.next/static -type f -name "*.json" -exec sed -i "s|${BASEPATH_PLACEHOLDER_ESCAPED}||g" {} + 2>/dev/null || true
27-
find /app/.next/static -type f \( -name "*.js" -o -name "*.json" \) -exec sed -i "s|${BASEPATH_PLACEHOLDER}||g" {} + 2>/dev/null || true
28-
find /app/.next/server -type f \( -name "*.html" -o -name "*.rsc" -o -name "*.meta" -o -name "*.body" \) -exec sed -i "s|${BASEPATH_PLACEHOLDER}||g" {} + 2>/dev/null || true
29-
find /app/.next/server -type f -name "*client-reference-manifest.js" -exec sed -i "s|${BASEPATH_PLACEHOLDER}||g" {} + 2>/dev/null || true
30-
# Also handle prerender manifests and action manifests
31-
find /app/.next -type f -name "*.json" -exec sed -i "s|${BASEPATH_PLACEHOLDER}||g" {} + 2>/dev/null || true
32-
fi
33-
34-
API_URL="${NEXT_PUBLIC_API_URL:-http://localhost:8000}"
35-
echo "Configuring API URL: $API_URL"
36-
find /app/.next/static -type f -name "*.js" -exec sed -i "s|${API_URL_PLACEHOLDER}|${API_URL}|g" {} + 2>/dev/null || true
37-
38-
WS_URL="${NEXT_PUBLIC_WS_URL:-ws://localhost:8000}"
39-
echo "Configuring WebSocket URL: $WS_URL"
40-
find /app/.next/static -type f -name "*.js" -exec sed -i "s|${WS_URL_PLACEHOLDER}|${WS_URL}|g" {} + 2>/dev/null || true
41-
42-
echo "Configuration complete"
439
exec "$@"

frontend/hooks/use-job-websocket.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useState, useRef, useCallback } from 'react';
2-
import { WS_BASE_URL } from '@/lib/constants';
2+
import { useConfig } from '@/components/providers/ConfigProvider';
3+
import { buildWsUrl } from '@/lib/config';
34
import type {
45
JobStatus,
56
JobProgress,
@@ -34,6 +35,7 @@ export function useJobWebSocket(
3435
options?: UseJobWebSocketOptions
3536
): UseJobWebSocketReturn {
3637
const { onStatusChange, enabled = true } = options || {};
38+
const config = useConfig();
3739

3840
const [status, setStatus] = useState<JobStatus | null>(null);
3941
const [progress, setProgress] = useState<JobProgress | null>(null);
@@ -96,10 +98,11 @@ export function useJobWebSocket(
9698
);
9799

98100
const connect = useCallback(() => {
99-
if (!jobId || !enabled) return;
101+
if (!jobId || !enabled || config.isLoading) return;
100102

101103
try {
102-
const wsUrl = `${WS_BASE_URL}/ws/jobs/${jobId}`;
104+
const wsBaseUrl = buildWsUrl(config);
105+
const wsUrl = `${wsBaseUrl}/ws/jobs/${jobId}`;
103106
const ws = new WebSocket(wsUrl);
104107
wsRef.current = ws;
105108

@@ -136,11 +139,11 @@ export function useJobWebSocket(
136139
} catch {
137140
// WebSocket connection failed
138141
}
139-
}, [jobId, enabled, handleMessage]);
142+
}, [jobId, enabled, config, handleMessage]);
140143

141-
// Connect on mount or when jobId changes
144+
// Connect on mount or when jobId changes (wait for config to load)
142145
useEffect(() => {
143-
if (!jobId || !enabled) return;
146+
if (!jobId || !enabled || config.isLoading) return;
144147

145148
// Reset state for new job
146149
setStatus(null);
@@ -164,7 +167,7 @@ export function useJobWebSocket(
164167
wsRef.current = null;
165168
}
166169
};
167-
}, [jobId, enabled, connect]);
170+
}, [jobId, enabled, config.isLoading, connect]);
168171

169172
return {
170173
status,

0 commit comments

Comments
 (0)