Compare commits

...

2 Commits

Author SHA1 Message Date
26dfa08730 feat: add Prometheus metrics + fix CI/CD health check port
Some checks failed
Deploy to Homelab / Deploy Wordly to 192.168.1.151 (push) Has been cancelled
Deploy to Homelab / Deploy Monitoring (if configured) (push) Has been cancelled
- Add prometheus-client dependency
- Create middleware/metrics.py with PrometheusMiddleware
- Expose /metrics endpoint in Prometheus text format
- Track http_requests_total, translation_total, translation_duration_seconds,
  file_size_bytes
- Instrument translate routes with record_translation() and record_file_size()
- Fix deploy.yml health check: localhost:8000 -> localhost:8001 (Portainer conflict)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:33:10 +02:00
a76442b382 feat: add Google Sign-In to login and register pages
- Add GoogleOAuthProvider wrapper in layout.tsx
- Add Google login button to LoginForm with "or continue with email" separator
- Add Google signup button to RegisterForm
- Add Google auth types and API client integration
- Add GOOGLE_CLIENT_ID and NEXT_PUBLIC_GOOGLE_CLIENT_ID to env config
- Add translations (EN/FR) for Google OAuth UI
- Backend already has /api/v1/auth/google endpoint, no changes needed

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 19:47:08 +02:00
13 changed files with 286 additions and 13 deletions

View File

@@ -25,6 +25,10 @@ TRANSLATION_SERVICE=google
# ---- Google (gratuit, toujours actif) ----
GOOGLE_TRANSLATE_ENABLED=true
# ---- Google OAuth (connexion avec Google) ----
GOOGLE_CLIENT_ID=
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# ---- Ollama (local, gratuit) ----
OLLAMA_ENABLED=false
OLLAMA_BASE_URL=http://ollama:11434

View File

@@ -52,7 +52,7 @@ jobs:
# Final health check on backend
for i in $(seq 1 10); do
if curl -sf http://localhost:8000/health > /dev/null 2>&1; then
if curl -sf http://localhost:8001/health > /dev/null 2>&1; then
echo "Backend health check: OK"
break
fi
@@ -77,7 +77,7 @@ jobs:
echo "========================================="
docker compose ps
echo ""
echo "Health: $(curl -sf http://localhost:8000/health 2>/dev/null || echo 'FAILED')"
echo "Health: $(curl -sf http://localhost:8001/health 2>/dev/null || echo 'FAILED')"
echo "========================================="
# Optional: deploy monitoring stack

View File

@@ -71,7 +71,7 @@ services:
container_name: wordly-backend
restart: unless-stopped
ports:
- "8000:8000"
- "8001:8000"
env_file:
- .env
environment:
@@ -104,6 +104,7 @@ services:
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-}
- STRIPE_BUSINESS_PRICE_ID=${STRIPE_BUSINESS_PRICE_ID:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
volumes:
- uploads_data:/app/uploads
- outputs_data:/app/outputs
@@ -146,7 +147,7 @@ services:
- .env
environment:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://wordly.art}
networks:
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
- wordly-network
depends_on:
backend:

View File

@@ -1,24 +1,34 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Languages } from 'lucide-react';
import { GoogleLogin } from '@react-oauth/google';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
import { apiClient } from '@/lib/apiClient';
import { useLogin } from './useLogin';
import type { GoogleAuthResponse } from './types';
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const loginMutation = useLogin();
const { notify } = useNotification();
const { t } = useI18n();
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get('redirect') || '/dashboard';
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
useEffect(() => {
if (loginMutation.isError && loginMutation.error) {
@@ -35,6 +45,37 @@ export function LoginForm() {
loginMutation.mutate({ email, password });
};
const handleGoogleSuccess = useCallback(async (credentialResponse: { credential?: string }) => {
if (!credentialResponse.credential) return;
setGoogleLoading(true);
try {
const response = await apiClient.post<{ data: GoogleAuthResponse }>(
'/api/v1/auth/google',
{ credential: credentialResponse.credential },
);
const { access_token, refresh_token } = response.data;
localStorage.setItem('token', access_token);
localStorage.setItem('refresh_token', refresh_token);
router.push(redirect);
} catch {
notify({
title: t('login.google.errorGeneric'),
description: t('login.google.errorFailed'),
variant: 'destructive',
});
} finally {
setGoogleLoading(false);
}
}, [redirect, router, notify, t]);
const handleGoogleError = useCallback(() => {
notify({
title: t('login.google.errorGeneric'),
description: t('login.google.errorFailed'),
variant: 'destructive',
});
}, [notify, t]);
return (
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
<CardHeader className="text-center pb-6">
@@ -56,6 +97,39 @@ export function LoginForm() {
</CardHeader>
<CardContent className="space-y-6">
{googleClientId && (
<>
<div className="flex justify-center">
{googleLoading ? (
<Button variant="outline" className="w-full" disabled>
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t('login.google.connecting')}
</Button>
) : (
<GoogleLogin
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
text="continue_with"
shape="rectangular"
size="large"
width={380}
/>
)}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">
{t('login.orContinueWith')}
</span>
</div>
</div>
</>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{t('login.email')}</Label>

View File

@@ -14,3 +14,9 @@ export interface LoginResponse {
refresh_token: string;
token_type: string;
}
export interface GoogleAuthResponse {
access_token: string;
refresh_token: string;
token_type: string;
}

View File

@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { useState, useCallback } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useI18n } from '@/lib/i18n';
import {
Eye,
@@ -16,12 +17,16 @@ import {
Languages,
User,
} from 'lucide-react';
import { GoogleLogin } from '@react-oauth/google';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useNotification } from '@/components/ui/notification';
import { apiClient } from '@/lib/apiClient';
import { useRegister } from './useRegister';
import { cn } from '@/lib/utils';
import type { GoogleAuthResponse } from '../login/types';
function validateEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
@@ -70,10 +75,17 @@ export function RegisterForm() {
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [touched, setTouched] = useState({ name: false, email: false, password: false, confirmPassword: false });
const registerMutation = useRegister();
const { notify } = useNotification();
const { t } = useI18n();
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get('redirect') || '/dashboard';
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '';
const nameError = touched.name && name.length > 0 && name.length < 2
? t('register.name.error')
@@ -119,6 +131,37 @@ export function RegisterForm() {
);
};
const handleGoogleSuccess = useCallback(async (credentialResponse: { credential?: string }) => {
if (!credentialResponse.credential) return;
setGoogleLoading(true);
try {
const response = await apiClient.post<{ data: GoogleAuthResponse }>(
'/api/v1/auth/google',
{ credential: credentialResponse.credential },
);
const { access_token, refresh_token } = response.data;
localStorage.setItem('token', access_token);
localStorage.setItem('refresh_token', refresh_token);
router.push(redirect);
} catch {
notify({
title: t('login.google.errorGeneric'),
description: t('login.google.errorFailed'),
variant: 'destructive',
});
} finally {
setGoogleLoading(false);
}
}, [redirect, router, notify, t]);
const handleGoogleError = useCallback(() => {
notify({
title: t('login.google.errorGeneric'),
description: t('login.google.errorFailed'),
variant: 'destructive',
});
}, [notify, t]);
return (
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
<CardHeader className="text-center pb-6">
@@ -136,6 +179,39 @@ export function RegisterForm() {
</CardHeader>
<CardContent className="space-y-5">
{googleClientId && (
<>
<div className="flex justify-center">
{googleLoading ? (
<Button variant="outline" className="w-full" disabled>
<Loader2 className="me-2 h-4 w-4 animate-spin" />
{t('login.google.connecting')}
</Button>
) : (
<GoogleLogin
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
text="signup_with"
shape="rectangular"
size="large"
width={380}
/>
)}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">
{t('login.orContinueWith')}
</span>
</div>
</div>
</>
)}
{registerMutation.isError && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
<div className="flex items-start gap-3">

View File

@@ -6,6 +6,7 @@ import { ThemeProvider } from "@/providers/ThemeProvider";
import { NotificationProvider } from "@/components/ui/notification";
import { I18nProvider } from "@/lib/i18n";
import { Agentation } from "agentation";
import { GoogleOAuthProvider } from "@react-oauth/google";
export const dynamic = 'force-dynamic';
@@ -29,9 +30,11 @@ export default function RootLayout({
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
<I18nProvider>
<QueryProvider>
<NotificationProvider>
{children}
</NotificationProvider>
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""}>
<NotificationProvider>
{children}
</NotificationProvider>
</GoogleOAuthProvider>
</QueryProvider>
</I18nProvider>
{process.env.NODE_ENV === "development" && <Agentation />}

View File

@@ -212,7 +212,8 @@
},
"login": {
"title": "Welcome back",
"subtitle": "Sign in to keep translating",
"welcomeBack": "Welcome back",
"signInToContinue": "Sign in to keep translating",
"email": "Email",
"password": "Password",
"emailPlaceholder": "you@example.com",
@@ -221,7 +222,8 @@
"signIn": "Sign in",
"errorTitle": "Sign-in error",
"noAccount": "Don't have an account?",
"signUpFree": "Sign up for free"
"signUpFree": "Sign up for free",
"orContinueWith": "or continue with email"
},
"register": {
"title": "Create an account",

View File

@@ -212,7 +212,8 @@
},
"login": {
"title": "Bon retour",
"subtitle": "Connectez-vous pour continuer à traduire",
"welcomeBack": "Bon retour",
"signInToContinue": "Connectez-vous pour continuer à traduire",
"email": "E-mail",
"password": "Mot de passe",
"emailPlaceholder": "vous@exemple.com",
@@ -221,7 +222,8 @@
"signIn": "Se connecter",
"errorTitle": "Erreur de connexion",
"noAccount": "Pas encore de compte ?",
"signUpFree": "Créer un compte gratuitement"
"signUpFree": "Créer un compte gratuitement",
"orContinueWith": "ou continuer avec email"
},
"register": {
"title": "Créer un compte",

View File

@@ -56,6 +56,7 @@ from middleware.security import (
RequestLoggingMiddleware,
)
from middleware.error_handler import ErrorHandlingMiddleware, format_error_response
from middleware.metrics import PrometheusMiddleware, get_metrics
from middleware.cleanup import (
MemoryMonitor,
HealthChecker,
@@ -359,6 +360,7 @@ app = FastAPI(
app.openapi = custom_openapi
app.add_middleware(ErrorHandlingMiddleware)
app.add_middleware(PrometheusMiddleware)
app.add_middleware(RequestLoggingMiddleware, log_body=False)
app.add_middleware(
SecurityHeadersMiddleware,
@@ -574,6 +576,12 @@ async def root():
}
@app.get("/metrics", tags=["Health"])
async def metrics_endpoint():
"""Prometheus metrics endpoint"""
return get_metrics()
@app.get("/health", tags=["Health"])
async def health_check():
"""Health check endpoint with detailed system status (Kubernetes liveness probe)"""

85
middleware/metrics.py Normal file
View File

@@ -0,0 +1,85 @@
"""
Prometheus metrics middleware for FastAPI.
Exposes /metrics endpoint in Prometheus text format.
Tracks HTTP requests, translations, and file uploads.
"""
import time
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LSP
logger = logging.getLogger(__name__)
# ---- Metrics definitions ----
http_requests_total = Counter(
"http_requests_total",
"Total HTTP requests",
["method", "path", "status"],
)
translation_total = Counter(
"translation_total",
"Total translations processed",
["provider", "file_type", "status"],
)
translation_duration_seconds = Histogram(
"translation_duration_seconds",
"Translation processing duration in seconds",
["provider", "file_type"],
buckets=(0.5, 1, 2, 5, 10, 30, 60, 120, 300),
)
file_size_bytes = Histogram(
"file_size_bytes",
"Uploaded file size in bytes",
["file_type"],
buckets=(100_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000),
)
# Paths to skip from metrics (noisy health checks)
_SKIP_PATHS = {"/health", "/ready", "/metrics", "/favicon.ico"}
def record_translation(provider: str, file_type: str, duration: float, status: str = "success"):
translation_total.labels(provider=provider, file_type=file_type, status=status).inc()
translation_duration_seconds.labels(provider=provider, file_type=file_type).observe(duration)
def record_file_size(file_type: str, size_bytes: int):
file_size_bytes.labels(file_type=file_type).observe(size_bytes)
class PrometheusMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.url.path in _SKIP_PATHS:
return await call_next(request)
start = time.time()
response: Response = await call_next(request)
duration = time.time() - start
path = request.url.path
# Group dynamic paths to avoid label explosion
if path.startswith("/api/v1/translations/"):
path = "/api/v1/translations/{id}"
elif path.startswith("/api/v1/download/"):
path = "/api/v1/download/{id}"
http_requests_total.labels(
method=request.method,
path=path,
status=str(response.status_code),
).inc()
return response
def get_metrics() -> Response:
body = generate_latest()
return Response(content=body, media_type=CONTENT_TYPE_LSP)

View File

@@ -46,3 +46,5 @@ aiosmtplib>=3.0.0
pytest>=7.0.0
pytest-asyncio>=0.21.0
prometheus-client==0.20.0

View File

@@ -60,6 +60,7 @@ from schemas.translation import (
)
from schemas.errors import ErrorResponse
from utils.file_handler import FileHandler
from middleware.metrics import record_translation, record_file_size
from services.progress_tracker import ProgressTracker
from services.storage_tracker import storage_tracker
from core.redis import set_job_status_async, get_job_status_async
@@ -696,6 +697,10 @@ async def translate_document_v1(
_cleanup_old_jobs()
# Record file size metric
if file_extension and file_size:
record_file_size(file_extension, file_size)
_translation_jobs[job_id] = {
"id": job_id,
"status": "queued",
@@ -1124,10 +1129,15 @@ async def _run_translation_job(
logger.warning(f"Job {job_id}: watermark failed: {wm_err}")
tracker.set_completed(str(output_path))
# Record translation metric
duration = time.time() - time.mktime(datetime.fromisoformat(job["created_at"].replace("Z", "+00:00")).timetuple())
record_translation(provider=provider, file_type=file_extension or "unknown", duration=duration, status="success")
logger.info(f"Job {job_id}: Completed successfully")
except Exception as e:
tracker.set_error(str(e))
# Record translation failure metric
record_translation(provider=provider, file_type=file_extension or "unknown", duration=0, status="error")
logger.error(f"Job {job_id}: Failed - {e}")
finally: