feat(llm-playground): add production deployment with auth

- Add Dockerfile for multi-stage Docker build
- Add mana-core-auth integration with login/register pages
- Add auth store using Svelte 5 runes
- Add protected route layout with auth guard
- Add health endpoint for container health checks
- Add runtime URL injection via hooks.server.ts
- Add logout button to header
- Update docker-compose.macmini.yml with llm-playground service
- Update cloudflared-config.yml with playground.mana.how route
- Update mana-llm CORS config for playground domain
- Update generate-env.mjs with auth URL variable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-30 18:15:02 +01:00
parent 8207d38ca5
commit fdba0e3425
19 changed files with 859 additions and 577 deletions

View file

@ -36,6 +36,12 @@ ingress:
- hostname: nutriphi-api.mana.how
service: http://localhost:3023
# LLM Services
- hostname: playground.mana.how
service: http://localhost:5190
- hostname: llm.mana.how
service: http://localhost:3025
# Monitoring & Tools
- hostname: grafana.mana.how
service: http://localhost:3100

View file

@ -735,6 +735,37 @@ services:
retries: 3
start_period: 40s
# ============================================
# LLM Playground (Web)
# ============================================
llm-playground:
build:
context: .
dockerfile: services/llm-playground/Dockerfile
container_name: llm-playground
restart: unless-stopped
ports:
- "5190:5190"
environment:
- NODE_ENV=production
- PORT=5190
- PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
- PUBLIC_MANA_CORE_AUTH_URL_CLIENT=https://auth.mana.how
- PUBLIC_MANA_LLM_URL=http://host.docker.internal:3025
- PUBLIC_MANA_LLM_URL_CLIENT=https://llm.mana.how
depends_on:
mana-core-auth:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5190/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
labels:
- "com.centurylinklabs.watchtower.enable=true"
# ============================================
# Monitoring Stack
# ============================================

867
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -639,6 +639,7 @@ const APP_CONFIGS = [
path: 'services/llm-playground/.env',
vars: {
PUBLIC_MANA_LLM_URL: (env) => env.MANA_LLM_URL || 'http://localhost:3025',
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
},
},

View file

@ -0,0 +1,9 @@
node_modules
.svelte-kit
.git
*.md
.env*
!.env.example
dist
coverage
.DS_Store

View file

@ -0,0 +1,69 @@
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
ARG PUBLIC_MANA_LLM_URL=http://mana-llm:3025
# Set as environment variables for build
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
ENV PUBLIC_MANA_LLM_URL=$PUBLIC_MANA_LLM_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by llm-playground
COPY packages/shared-auth ./packages/shared-auth
# Copy llm-playground service
COPY services/llm-playground ./services/llm-playground
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
# Build the web app
WORKDIR /app/services/llm-playground
RUN pnpm exec svelte-kit sync
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/services/llm-playground
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/services/llm-playground/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/services/llm-playground/build ./build
COPY --from=builder /app/services/llm-playground/package.json ./
# Expose port
EXPOSE 5190
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5190
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5190/health || exit 1
# Run the app
CMD ["node", "build"]

View file

@ -21,6 +21,7 @@
"vite": "^7.1.7"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"marked": "^17.0.0"
}
}

View file

@ -8,6 +8,11 @@ declare global {
// interface PageState {}
// interface Platform {}
}
interface Window {
__PUBLIC_MANA_CORE_AUTH_URL__?: string;
__PUBLIC_MANA_LLM_URL__?: string;
}
}
export {};

View file

@ -0,0 +1,18 @@
import type { Handle } from '@sveltejs/kit';
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_MANA_LLM_URL_CLIENT =
process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_MANA_LLM_URL__ = "${PUBLIC_MANA_LLM_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};

View file

@ -6,11 +6,21 @@ import type {
StreamChunk,
} from '$lib/types';
import { env } from '$env/dynamic/public';
import { browser } from '$app/environment';
const API_BASE = env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025';
function getApiBase(): string {
if (browser) {
return (
(window as unknown as { __PUBLIC_MANA_LLM_URL__?: string }).__PUBLIC_MANA_LLM_URL__ ||
env.PUBLIC_MANA_LLM_URL ||
'http://localhost:3025'
);
}
return env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025';
}
export async function getHealth(): Promise<HealthResponse> {
const response = await fetch(`${API_BASE}/health`);
const response = await fetch(`${getApiBase()}/health`);
if (!response.ok) {
throw new Error(`Health check failed: ${response.statusText}`);
}
@ -18,7 +28,7 @@ export async function getHealth(): Promise<HealthResponse> {
}
export async function getModels(): Promise<ModelsResponse> {
const response = await fetch(`${API_BASE}/v1/models`);
const response = await fetch(`${getApiBase()}/v1/models`);
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.statusText}`);
}
@ -28,7 +38,7 @@ export async function getModels(): Promise<ModelsResponse> {
export async function sendCompletion(
request: ChatCompletionRequest
): Promise<ChatCompletionResponse> {
const response = await fetch(`${API_BASE}/v1/chat/completions`, {
const response = await fetch(`${getApiBase()}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...request, stream: false }),
@ -42,7 +52,7 @@ export async function sendCompletion(
export async function* streamCompletion(
request: ChatCompletionRequest
): AsyncGenerator<string, void, unknown> {
const response = await fetch(`${API_BASE}/v1/chat/completions`, {
const response = await fetch(`${getApiBase()}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...request, stream: true }),

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { getHealth } from '$lib/api/llm';
import { authStore } from '$lib/stores/auth.svelte';
import { onMount } from 'svelte';
let healthStatus = $state<'loading' | 'healthy' | 'error'>('loading');
@ -75,5 +76,13 @@
/>
</svg>
</button>
<button
onclick={() => authStore.signOut()}
class="rounded px-3 py-1.5 text-sm transition-colors hover:bg-white/10"
style="color: var(--color-text-muted);"
title="Abmelden"
>
Abmelden
</button>
</div>
</header>

View file

@ -0,0 +1,105 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { initializeWebAuth, type AuthService, type UserData } from '@manacore/shared-auth';
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
let _authService: AuthService | null = null;
function getAuthUrl(): string {
if (!browser) return '';
return (
(window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__ ||
import.meta.env.PUBLIC_MANA_CORE_AUTH_URL ||
'http://localhost:3001'
);
}
function getLlmUrl(): string {
if (!browser) return '';
return (
(window as unknown as { __PUBLIC_MANA_LLM_URL__?: string }).__PUBLIC_MANA_LLM_URL__ ||
import.meta.env.PUBLIC_MANA_LLM_URL ||
'http://localhost:3025'
);
}
function getAuthService(): AuthService | null {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getLlmUrl(),
});
_authService = auth.authService;
}
return _authService;
}
export const authStore = {
get user() {
return user;
},
get loading() {
return loading;
},
get initialized() {
return initialized;
},
get isAuthenticated() {
return !!user;
},
async initialize() {
if (initialized || !browser) return;
loading = true;
try {
const authService = getAuthService();
if (authService) {
const currentUser = await authService.getUserFromToken();
user = currentUser;
}
} catch (error) {
console.error('Auth initialization failed:', error);
user = null;
} finally {
loading = false;
initialized = true;
}
},
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) throw new Error('Auth not initialized');
const result = await authService.signIn(email, password);
if (result.success) {
const currentUser = await authService.getUserFromToken();
user = currentUser;
}
return result;
},
async signUp(email: string, password: string, name?: string) {
const authService = getAuthService();
if (!authService) throw new Error('Auth not initialized');
return authService.signUp(email, password, name);
},
async signOut() {
const authService = getAuthService();
if (authService) {
await authService.signOut();
}
user = null;
goto('/login');
},
async getValidToken(): Promise<string | null> {
const authService = getAuthService();
if (!authService) return null;
return authService.getAppToken();
},
};

View file

@ -0,0 +1,11 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div
class="min-h-screen flex items-center justify-center"
style="background-color: var(--color-bg);"
>
{@render children()}
</div>

View file

@ -0,0 +1,109 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { onMount } from 'svelte';
let email = $state('');
let password = $state('');
let error = $state('');
let isLoading = $state(false);
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
onMount(async () => {
await authStore.initialize();
if (authStore.isAuthenticated) {
goto(redirectTo);
}
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
isLoading = true;
try {
const result = await authStore.signIn(email, password);
if (result.success) {
goto(redirectTo);
} else {
error = result.error || 'Login fehlgeschlagen';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Login fehlgeschlagen';
} finally {
isLoading = false;
}
}
</script>
<div
class="w-full max-w-md p-8 rounded-xl border"
style="background-color: var(--color-surface); border-color: var(--color-border);"
>
<h1 class="text-2xl font-bold mb-6" style="color: var(--color-text);">LLM Playground</h1>
<p class="mb-6" style="color: var(--color-text-muted);">
Melde dich an, um den Playground zu nutzen.
</p>
{#if error}
<div
class="mb-4 p-3 rounded-lg text-sm"
style="background-color: var(--color-error-bg, rgba(239, 68, 68, 0.1)); border: 1px solid var(--color-error, #ef4444); color: var(--color-error, #ef4444);"
>
{error}
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label
for="email"
class="block text-sm font-medium mb-1"
style="color: var(--color-text-muted);">E-Mail</label
>
<input
id="email"
type="email"
bind:value={email}
required
class="w-full px-4 py-2 rounded-lg focus:outline-none focus:ring-2"
style="background-color: var(--color-bg); border: 1px solid var(--color-border); color: var(--color-text);"
/>
</div>
<div>
<label
for="password"
class="block text-sm font-medium mb-1"
style="color: var(--color-text-muted);">Passwort</label
>
<input
id="password"
type="password"
bind:value={password}
required
class="w-full px-4 py-2 rounded-lg focus:outline-none focus:ring-2"
style="background-color: var(--color-bg); border: 1px solid var(--color-border); color: var(--color-text);"
/>
</div>
<button
type="submit"
disabled={isLoading}
class="w-full py-2 px-4 font-medium rounded-lg transition-colors disabled:opacity-50"
style="background-color: var(--color-primary); color: white;"
>
{isLoading ? 'Wird angemeldet...' : 'Anmelden'}
</button>
</form>
<p class="mt-6 text-center text-sm" style="color: var(--color-text-muted);">
Noch kein Konto? <a
href="/register"
class="hover:underline"
style="color: var(--color-primary);">Registrieren</a
>
</p>
</div>

View file

@ -0,0 +1,129 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
let email = $state('');
let password = $state('');
let name = $state('');
let error = $state('');
let success = $state(false);
let isLoading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
isLoading = true;
try {
const result = await authStore.signUp(email, password, name || undefined);
if (result.success) {
success = true;
} else {
error = result.error || 'Registrierung fehlgeschlagen';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Registrierung fehlgeschlagen';
} finally {
isLoading = false;
}
}
</script>
<div
class="w-full max-w-md p-8 rounded-xl border"
style="background-color: var(--color-surface); border-color: var(--color-border);"
>
<h1 class="text-2xl font-bold mb-6" style="color: var(--color-text);">Registrieren</h1>
{#if success}
<div
class="p-4 rounded-lg"
style="background-color: var(--color-success-bg, rgba(34, 197, 94, 0.1)); border: 1px solid var(--color-success, #22c55e);"
>
<p style="color: var(--color-success, #22c55e);">
Registrierung erfolgreich! Du kannst dich jetzt anmelden.
</p>
<a
href="/login"
class="hover:underline mt-2 inline-block"
style="color: var(--color-primary);">Zum Login</a
>
</div>
{:else}
{#if error}
<div
class="mb-4 p-3 rounded-lg text-sm"
style="background-color: var(--color-error-bg, rgba(239, 68, 68, 0.1)); border: 1px solid var(--color-error, #ef4444); color: var(--color-error, #ef4444);"
>
{error}
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label
for="name"
class="block text-sm font-medium mb-1"
style="color: var(--color-text-muted);">Name (optional)</label
>
<input
id="name"
type="text"
bind:value={name}
class="w-full px-4 py-2 rounded-lg focus:outline-none focus:ring-2"
style="background-color: var(--color-bg); border: 1px solid var(--color-border); color: var(--color-text);"
/>
</div>
<div>
<label
for="email"
class="block text-sm font-medium mb-1"
style="color: var(--color-text-muted);">E-Mail</label
>
<input
id="email"
type="email"
bind:value={email}
required
class="w-full px-4 py-2 rounded-lg focus:outline-none focus:ring-2"
style="background-color: var(--color-bg); border: 1px solid var(--color-border); color: var(--color-text);"
/>
</div>
<div>
<label
for="password"
class="block text-sm font-medium mb-1"
style="color: var(--color-text-muted);">Passwort</label
>
<input
id="password"
type="password"
bind:value={password}
required
minlength="8"
class="w-full px-4 py-2 rounded-lg focus:outline-none focus:ring-2"
style="background-color: var(--color-bg); border: 1px solid var(--color-border); color: var(--color-text);"
/>
</div>
<button
type="submit"
disabled={isLoading}
class="w-full py-2 px-4 font-medium rounded-lg transition-colors disabled:opacity-50"
style="background-color: var(--color-primary); color: white;"
>
{isLoading ? 'Wird registriert...' : 'Registrieren'}
</button>
</form>
<p class="mt-6 text-center text-sm" style="color: var(--color-text-muted);">
Bereits ein Konto? <a
href="/login"
class="hover:underline"
style="color: var(--color-primary);">Anmelden</a
>
</p>
{/if}
</div>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
let isChecking = $state(true);
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
const currentPath = $page.url.pathname;
goto(`/login?redirectTo=${encodeURIComponent(currentPath)}`);
return;
}
isChecking = false;
});
</script>
{#if isChecking}
<div
class="min-h-screen flex items-center justify-center"
style="background-color: var(--color-bg);"
>
<div
class="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2"
style="border-color: var(--color-primary);"
></div>
</div>
{:else}
{@render children()}
{/if}

View file

@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({
status: 'healthy',
service: 'llm-playground',
timestamp: new Date().toISOString(),
});
};

View file

@ -33,7 +33,7 @@ class Settings(BaseSettings):
cache_ttl: int = 3600
# CORS
cors_origins: str = "http://localhost:5173,http://localhost:5190,https://mana.how"
cors_origins: str = "http://localhost:5173,http://localhost:5190,https://mana.how,https://playground.mana.how"
@property
def cors_origins_list(self) -> list[str]: