Compare commits
2 Commits
d2d0b2c53c
...
26dfa08730
| Author | SHA1 | Date | |
|---|---|---|---|
| 26dfa08730 | |||
| a76442b382 |
@@ -25,6 +25,10 @@ TRANSLATION_SERVICE=google
|
|||||||
# ---- Google (gratuit, toujours actif) ----
|
# ---- Google (gratuit, toujours actif) ----
|
||||||
GOOGLE_TRANSLATE_ENABLED=true
|
GOOGLE_TRANSLATE_ENABLED=true
|
||||||
|
|
||||||
|
# ---- Google OAuth (connexion avec Google) ----
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||||
|
|
||||||
# ---- Ollama (local, gratuit) ----
|
# ---- Ollama (local, gratuit) ----
|
||||||
OLLAMA_ENABLED=false
|
OLLAMA_ENABLED=false
|
||||||
OLLAMA_BASE_URL=http://ollama:11434
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
|
|
||||||
# Final health check on backend
|
# Final health check on backend
|
||||||
for i in $(seq 1 10); do
|
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"
|
echo "Backend health check: OK"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
echo "========================================="
|
echo "========================================="
|
||||||
docker compose ps
|
docker compose ps
|
||||||
echo ""
|
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 "========================================="
|
echo "========================================="
|
||||||
|
|
||||||
# Optional: deploy monitoring stack
|
# Optional: deploy monitoring stack
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ services:
|
|||||||
container_name: wordly-backend
|
container_name: wordly-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8001:8000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -104,6 +104,7 @@ services:
|
|||||||
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
||||||
- STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-}
|
- STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-}
|
||||||
- STRIPE_BUSINESS_PRICE_ID=${STRIPE_BUSINESS_PRICE_ID:-}
|
- STRIPE_BUSINESS_PRICE_ID=${STRIPE_BUSINESS_PRICE_ID:-}
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
- outputs_data:/app/outputs
|
- outputs_data:/app/outputs
|
||||||
@@ -146,7 +147,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://wordly.art}
|
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://wordly.art}
|
||||||
networks:
|
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||||
- wordly-network
|
- wordly-network
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Languages } from 'lucide-react';
|
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Languages } from 'lucide-react';
|
||||||
|
import { GoogleLogin } from '@react-oauth/google';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useNotification } from '@/components/ui/notification';
|
import { useNotification } from '@/components/ui/notification';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { apiClient } from '@/lib/apiClient';
|
||||||
import { useLogin } from './useLogin';
|
import { useLogin } from './useLogin';
|
||||||
|
import type { GoogleAuthResponse } from './types';
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [googleLoading, setGoogleLoading] = useState(false);
|
||||||
|
|
||||||
const loginMutation = useLogin();
|
const loginMutation = useLogin();
|
||||||
const { notify } = useNotification();
|
const { notify } = useNotification();
|
||||||
const { t } = useI18n();
|
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(() => {
|
useEffect(() => {
|
||||||
if (loginMutation.isError && loginMutation.error) {
|
if (loginMutation.isError && loginMutation.error) {
|
||||||
@@ -35,6 +45,37 @@ export function LoginForm() {
|
|||||||
loginMutation.mutate({ email, password });
|
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 (
|
return (
|
||||||
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
|
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
|
||||||
<CardHeader className="text-center pb-6">
|
<CardHeader className="text-center pb-6">
|
||||||
@@ -56,6 +97,39 @@ export function LoginForm() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">{t('login.email')}</Label>
|
<Label htmlFor="email">{t('login.email')}</Label>
|
||||||
|
|||||||
@@ -14,3 +14,9 @@ export interface LoginResponse {
|
|||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GoogleAuthResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
@@ -16,12 +17,16 @@ import {
|
|||||||
Languages,
|
Languages,
|
||||||
User,
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { GoogleLogin } from '@react-oauth/google';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { useRegister } from './useRegister';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { GoogleAuthResponse } from '../login/types';
|
||||||
|
|
||||||
function validateEmail(email: string) {
|
function validateEmail(email: string) {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
@@ -70,10 +75,17 @@ export function RegisterForm() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = 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 [touched, setTouched] = useState({ name: false, email: false, password: false, confirmPassword: false });
|
||||||
|
|
||||||
const registerMutation = useRegister();
|
const registerMutation = useRegister();
|
||||||
|
const { notify } = useNotification();
|
||||||
const { t } = useI18n();
|
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
|
const nameError = touched.name && name.length > 0 && name.length < 2
|
||||||
? t('register.name.error')
|
? 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 (
|
return (
|
||||||
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
|
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
|
||||||
<CardHeader className="text-center pb-6">
|
<CardHeader className="text-center pb-6">
|
||||||
@@ -136,6 +179,39 @@ export function RegisterForm() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-5">
|
<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 && (
|
{registerMutation.isError && (
|
||||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
|
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ThemeProvider } from "@/providers/ThemeProvider";
|
|||||||
import { NotificationProvider } from "@/components/ui/notification";
|
import { NotificationProvider } from "@/components/ui/notification";
|
||||||
import { I18nProvider } from "@/lib/i18n";
|
import { I18nProvider } from "@/lib/i18n";
|
||||||
import { Agentation } from "agentation";
|
import { Agentation } from "agentation";
|
||||||
|
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -29,9 +30,11 @@ export default function RootLayout({
|
|||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<NotificationProvider>
|
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""}>
|
||||||
{children}
|
<NotificationProvider>
|
||||||
</NotificationProvider>
|
{children}
|
||||||
|
</NotificationProvider>
|
||||||
|
</GoogleOAuthProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
{process.env.NODE_ENV === "development" && <Agentation />}
|
{process.env.NODE_ENV === "development" && <Agentation />}
|
||||||
|
|||||||
@@ -212,7 +212,8 @@
|
|||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Welcome back",
|
"title": "Welcome back",
|
||||||
"subtitle": "Sign in to keep translating",
|
"welcomeBack": "Welcome back",
|
||||||
|
"signInToContinue": "Sign in to keep translating",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"emailPlaceholder": "you@example.com",
|
"emailPlaceholder": "you@example.com",
|
||||||
@@ -221,7 +222,8 @@
|
|||||||
"signIn": "Sign in",
|
"signIn": "Sign in",
|
||||||
"errorTitle": "Sign-in error",
|
"errorTitle": "Sign-in error",
|
||||||
"noAccount": "Don't have an account?",
|
"noAccount": "Don't have an account?",
|
||||||
"signUpFree": "Sign up for free"
|
"signUpFree": "Sign up for free",
|
||||||
|
"orContinueWith": "or continue with email"
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Create an account",
|
"title": "Create an account",
|
||||||
|
|||||||
@@ -212,7 +212,8 @@
|
|||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Bon retour",
|
"title": "Bon retour",
|
||||||
"subtitle": "Connectez-vous pour continuer à traduire",
|
"welcomeBack": "Bon retour",
|
||||||
|
"signInToContinue": "Connectez-vous pour continuer à traduire",
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"password": "Mot de passe",
|
"password": "Mot de passe",
|
||||||
"emailPlaceholder": "vous@exemple.com",
|
"emailPlaceholder": "vous@exemple.com",
|
||||||
@@ -221,7 +222,8 @@
|
|||||||
"signIn": "Se connecter",
|
"signIn": "Se connecter",
|
||||||
"errorTitle": "Erreur de connexion",
|
"errorTitle": "Erreur de connexion",
|
||||||
"noAccount": "Pas encore de compte ?",
|
"noAccount": "Pas encore de compte ?",
|
||||||
"signUpFree": "Créer un compte gratuitement"
|
"signUpFree": "Créer un compte gratuitement",
|
||||||
|
"orContinueWith": "ou continuer avec email"
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Créer un compte",
|
"title": "Créer un compte",
|
||||||
|
|||||||
8
main.py
8
main.py
@@ -56,6 +56,7 @@ from middleware.security import (
|
|||||||
RequestLoggingMiddleware,
|
RequestLoggingMiddleware,
|
||||||
)
|
)
|
||||||
from middleware.error_handler import ErrorHandlingMiddleware, format_error_response
|
from middleware.error_handler import ErrorHandlingMiddleware, format_error_response
|
||||||
|
from middleware.metrics import PrometheusMiddleware, get_metrics
|
||||||
from middleware.cleanup import (
|
from middleware.cleanup import (
|
||||||
MemoryMonitor,
|
MemoryMonitor,
|
||||||
HealthChecker,
|
HealthChecker,
|
||||||
@@ -359,6 +360,7 @@ app = FastAPI(
|
|||||||
app.openapi = custom_openapi
|
app.openapi = custom_openapi
|
||||||
|
|
||||||
app.add_middleware(ErrorHandlingMiddleware)
|
app.add_middleware(ErrorHandlingMiddleware)
|
||||||
|
app.add_middleware(PrometheusMiddleware)
|
||||||
app.add_middleware(RequestLoggingMiddleware, log_body=False)
|
app.add_middleware(RequestLoggingMiddleware, log_body=False)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
SecurityHeadersMiddleware,
|
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"])
|
@app.get("/health", tags=["Health"])
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint with detailed system status (Kubernetes liveness probe)"""
|
"""Health check endpoint with detailed system status (Kubernetes liveness probe)"""
|
||||||
|
|||||||
85
middleware/metrics.py
Normal file
85
middleware/metrics.py
Normal 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)
|
||||||
@@ -46,3 +46,5 @@ aiosmtplib>=3.0.0
|
|||||||
|
|
||||||
pytest>=7.0.0
|
pytest>=7.0.0
|
||||||
pytest-asyncio>=0.21.0
|
pytest-asyncio>=0.21.0
|
||||||
|
|
||||||
|
prometheus-client==0.20.0
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from schemas.translation import (
|
|||||||
)
|
)
|
||||||
from schemas.errors import ErrorResponse
|
from schemas.errors import ErrorResponse
|
||||||
from utils.file_handler import FileHandler
|
from utils.file_handler import FileHandler
|
||||||
|
from middleware.metrics import record_translation, record_file_size
|
||||||
from services.progress_tracker import ProgressTracker
|
from services.progress_tracker import ProgressTracker
|
||||||
from services.storage_tracker import storage_tracker
|
from services.storage_tracker import storage_tracker
|
||||||
from core.redis import set_job_status_async, get_job_status_async
|
from core.redis import set_job_status_async, get_job_status_async
|
||||||
@@ -696,6 +697,10 @@ async def translate_document_v1(
|
|||||||
|
|
||||||
_cleanup_old_jobs()
|
_cleanup_old_jobs()
|
||||||
|
|
||||||
|
# Record file size metric
|
||||||
|
if file_extension and file_size:
|
||||||
|
record_file_size(file_extension, file_size)
|
||||||
|
|
||||||
_translation_jobs[job_id] = {
|
_translation_jobs[job_id] = {
|
||||||
"id": job_id,
|
"id": job_id,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
@@ -1124,10 +1129,15 @@ async def _run_translation_job(
|
|||||||
logger.warning(f"Job {job_id}: watermark failed: {wm_err}")
|
logger.warning(f"Job {job_id}: watermark failed: {wm_err}")
|
||||||
|
|
||||||
tracker.set_completed(str(output_path))
|
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")
|
logger.info(f"Job {job_id}: Completed successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tracker.set_error(str(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}")
|
logger.error(f"Job {job_id}: Failed - {e}")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in New Issue
Block a user