From ce8e150a61018bfd247cccf7d626bcdbc6b48f55 Mon Sep 17 00:00:00 2001 From: sepehr Date: Sun, 10 May 2026 11:43:28 +0200 Subject: [PATCH] feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup - Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 --- .gitattributes | 10 + DEPLOYMENT_HOMELAB.md | 565 ++++++++++++ MARKETING_PLAN.md | 212 +++++ .../versions/006_fix_tier_check_constraint.py | 31 + config.py | 2 +- database/models.py | 2 +- docker-compose.local.yml | 2 +- docker-compose.monitoring.yml | 146 +++ docker-compose.yml | 177 +--- docker/backend/Dockerfile | 13 +- docker/backend/entrypoint.sh | 45 +- .../dashboards/wordly-infrastructure.json | 168 ++++ .../grafana/dashboards/wordly-overview.json | 206 +++++ .../provisioning/dashboards/dashboards.yml | 13 + .../provisioning/datasources/datasources.yml | 12 + docker/nginx/conf.d/default.conf | 107 ++- docker/prometheus/alerts.yml | 101 +++ docker/prometheus/prometheus.yml | 31 +- frontend/package-lock.json | 37 +- frontend/src/app/(app)/layout.tsx | 18 - frontend/src/app/(app)/ollama-setup/page.tsx | 364 -------- .../src/app/(app)/settings/context/page.tsx | 354 -------- frontend/src/app/(app)/settings/page.tsx | 394 -------- .../app/(app)/settings/subscription/page.tsx | 694 -------------- frontend/src/app/admin/AdminHeader.tsx | 12 +- frontend/src/app/admin/AdminSidebar.tsx | 6 +- frontend/src/app/admin/SystemHealthCards.tsx | 16 +- frontend/src/app/admin/TopUsersTable.tsx | 4 +- frontend/src/app/admin/constants.ts | 14 +- frontend/src/app/admin/layout.tsx | 5 +- frontend/src/app/admin/login/page.tsx | 19 +- frontend/src/app/admin/page.tsx | 18 +- frontend/src/app/admin/settings/page.tsx | 32 +- frontend/src/app/admin/system/page.tsx | 55 +- .../src/app/admin/system/useSystemPage.ts | 28 + frontend/src/app/admin/users/UserTable.tsx | 12 +- frontend/src/app/admin/users/page.tsx | 36 +- .../src/app/auth/forgot-password/page.tsx | 31 +- frontend/src/app/auth/login/LoginForm.tsx | 32 +- frontend/src/app/auth/login/page.tsx | 10 +- frontend/src/app/auth/login/useLogin.ts | 2 +- .../src/app/auth/register/RegisterForm.tsx | 60 +- frontend/src/app/auth/register/page.tsx | 17 +- frontend/src/app/auth/register/useRegister.ts | 2 +- frontend/src/app/auth/reset-password/page.tsx | 47 +- .../src/app/dashboard/DashboardHeader.tsx | 8 - .../src/app/dashboard/DashboardSidebar.tsx | 15 +- .../app/dashboard/api-keys/ApiKeyTable.tsx | 46 +- .../dashboard/api-keys/GenerateKeyDialog.tsx | 66 +- .../dashboard/api-keys/ProUpgradePrompt.tsx | 22 +- .../dashboard/api-keys/RevokeKeyDialog.tsx | 28 +- frontend/src/app/dashboard/api-keys/page.tsx | 49 +- .../src/app/dashboard/api-keys/useApiKeys.ts | 9 +- frontend/src/app/dashboard/constants.ts | 8 +- frontend/src/app/dashboard/context/page.tsx | 195 ++++ .../glossaries/CreateGlossaryDialog.tsx | 4 +- .../app/dashboard/glossaries/GlossaryCard.tsx | 4 +- .../app/dashboard/glossaries/useGlossaries.ts | 15 +- frontend/src/app/dashboard/profile/page.tsx | 467 +++++----- .../settings => dashboard}/services/page.tsx | 58 +- frontend/src/app/dashboard/settings/page.tsx | 95 ++ .../app/dashboard/translate/FileDropZone.tsx | 18 +- .../app/dashboard/translate/FilePreview.tsx | 2 +- .../dashboard/translate/LanguageSelector.tsx | 250 +++-- .../translate/TranslationComplete.tsx | 106 ++- .../translate/TranslationModeToggle.tsx | 4 +- .../translate/TranslationProgress.tsx | 377 ++++++-- frontend/src/app/dashboard/translate/page.tsx | 787 +++++++++++----- frontend/src/app/dashboard/translate/types.ts | 1 + .../app/dashboard/translate/useFileUpload.ts | 4 +- .../translate/useTranslationConfig.ts | 26 +- .../translate/useTranslationSubmit.ts | 8 + frontend/src/app/dashboard/useUser.ts | 2 +- frontend/src/app/layout.tsx | 2 +- frontend/src/app/pricing/page.tsx | 318 +++---- frontend/src/components/file-uploader.tsx | 6 +- .../components/landing/features-section.tsx | 86 -- .../src/components/landing/hero-section.tsx | 46 - .../landing/hero-word-comparison.tsx | 6 +- .../src/components/landing/landing-page.tsx | 682 +++++++++----- frontend/src/components/sidebar.tsx | 281 ------ frontend/src/components/ui/badge.tsx | 6 +- frontend/src/components/ui/button.tsx | 2 +- frontend/src/components/ui/dialog.tsx | 2 +- frontend/src/components/ui/dropdown-menu.tsx | 10 +- frontend/src/components/ui/input.tsx | 10 +- .../src/components/ui/language-switcher.tsx | 16 +- frontend/src/components/ui/model-combobox.tsx | 2 +- frontend/src/components/ui/notification.tsx | 2 +- frontend/src/components/ui/select.tsx | 2 +- frontend/src/components/ui/table.tsx | 4 +- frontend/src/components/ui/toast.tsx | 2 +- middleware/rate_limiting.py | 38 +- middleware/tier_quota.py | 105 +-- middleware/validation.py | 19 +- models/subscription.py | 111 ++- requirements.txt | 4 + routes/admin_routes.py | 28 + routes/api_key_routes.py | 101 +-- routes/auth_routes.py | 110 +-- routes/glossary_routes.py | 36 +- routes/legacy_routes.py | 13 +- routes/prompt_routes.py | 20 +- routes/translate_routes.py | 142 ++- scripts/backup-to-nas.sh | 287 ++++++ translators/excel_translator.py | 166 ++++ translators/pdf_translator.py | 852 ++++++++++++++++++ translators/pptx_translator.py | 133 +++ translators/watermark.py | 168 ++++ translators/word_translator.py | 582 +++++++++++- 110 files changed, 6935 insertions(+), 4301 deletions(-) create mode 100644 .gitattributes create mode 100644 DEPLOYMENT_HOMELAB.md create mode 100644 MARKETING_PLAN.md create mode 100644 alembic/versions/006_fix_tier_check_constraint.py create mode 100644 docker-compose.monitoring.yml create mode 100644 docker/grafana/dashboards/wordly-infrastructure.json create mode 100644 docker/grafana/dashboards/wordly-overview.json create mode 100644 docker/grafana/provisioning/dashboards/dashboards.yml create mode 100644 docker/grafana/provisioning/datasources/datasources.yml create mode 100644 docker/prometheus/alerts.yml delete mode 100644 frontend/src/app/(app)/layout.tsx delete mode 100644 frontend/src/app/(app)/ollama-setup/page.tsx delete mode 100644 frontend/src/app/(app)/settings/context/page.tsx delete mode 100644 frontend/src/app/(app)/settings/page.tsx delete mode 100644 frontend/src/app/(app)/settings/subscription/page.tsx create mode 100644 frontend/src/app/dashboard/context/page.tsx rename frontend/src/app/{(app)/settings => dashboard}/services/page.tsx (71%) create mode 100644 frontend/src/app/dashboard/settings/page.tsx delete mode 100644 frontend/src/components/landing/features-section.tsx delete mode 100644 frontend/src/components/landing/hero-section.tsx delete mode 100644 frontend/src/components/sidebar.tsx create mode 100644 scripts/backup-to-nas.sh create mode 100644 translators/pdf_translator.py create mode 100644 translators/watermark.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0698cf5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Shell scripts must always use LF line endings +*.sh text eol=lf + +# Windows scripts +*.ps1 text eol=crlm +*.bat text eol=crlm +*.cmd text eol=crlm + +# Auto-detect for all other text files +* text=auto diff --git a/DEPLOYMENT_HOMELAB.md b/DEPLOYMENT_HOMELAB.md new file mode 100644 index 0000000..57f1050 --- /dev/null +++ b/DEPLOYMENT_HOMELAB.md @@ -0,0 +1,565 @@ +# Guide de Deploiement - Wordly.art (Homelab) +> Nginx Proxy Manager + IONOS DNS + Docker + NAS Backup + Monitoring + +--- + +## Architecture + +``` +Internet + | + | DNS IONOS: wordly.art -> ton IP fixe + | +[Routeur/Box] (port forwarding 80+443 -> machine NPM) + | + +-- Machine 1: Nginx Proxy Manager (NPM) + | - SSL Let's Encrypt automatique + | - Reverse proxy vers les services + | + +-- Machine 2 ou 3: Docker (Wordly app) + | + +-- wordly-backend (FastAPI :8000) + +-- wordly-frontend (Next.js :3000) + +-- wordly-postgres (PostgreSQL :5432) + +-- wordly-redis (Redis :6379) + +-- wordly-prometheus (interne :9090) + +-- wordly-grafana (:3001) + +-- wordly-node-exporter + +-- wordly-cadvisor + | + +-- Backup quotidien -> NAS (SMB/NFS) +``` + +### Routing NPM + +| Sous-domaine | Cible Docker | Port | +|--------------|-------------|------| +| `wordly.art` | `wordly-frontend` | 3000 | +| `wordly.art/api/*` | `wordly-backend` | 8000 | +| `wordly.art/translate` | `wordly-backend` | 8000 | +| `monitoring.wordly.art` | `wordly-grafana` | 3000 | + +--- + +## Etape 1 : Configuration DNS chez IONOS + +### 1.1 Connexion a IONOS +1. Se connecter sur **ionos.fr** / **ionos.com** +2. Aller dans **Domaines & SSL** +3. Cliquer sur **wordly.art** + +### 1.2 Creer les enregistrements DNS + +Cliquer sur **DNS** > **Gerer les enregistrements** et ajouter : + +| Type | Nom | Valeur | TTL | +|------|-----|--------|-----| +| **A** | `@` | `TON_IP_FIXE` | 3600 | +| **A** | `www` | `TON_IP_FIXE` | 3600 | +| **A** | `monitoring` | `TON_IP_FIXE` | 3600 | + +> Remplace `TON_IP_FIXE` par ton IP fixe publique. +> Pour la trouver : https://whatismyip.com + +### 1.3 Verifier la propagation DNS +```bash +# Attendre 5-30 minutes puis verifier +nslookup wordly.art +nslookup monitoring.wordly.art +``` + +--- + +## Etape 2 : Nginx Proxy Manager (NPM) + +### 2.1 Verifier que NPM tourne +NPM est deja installe sur une de tes machines. Verifier : + +```bash +# Sur la machine qui heberge NPM +docker ps | grep npm +# ou +docker ps | grep nginx-proxy-manager +``` + +L'interface admin NPM est accessible sur **http://IP_NPM:81** + +### 2.2 Connecter NPM au reseau Docker de Wordly + +Pour que NPM puisse atteindre les containers Wordly par leur nom, il doit etre sur le meme reseau Docker. + +```bash +# Sur la machine qui heberge NPM, trouver le nom du container NPM +docker ps | grep -i npm + +# Trouver le reseau du container NPM +docker inspect | grep -A5 Networks + +# Sur la machine Wordly, on va connecter NPM au reseau wordly-network +# (uniquement si NPM et Wordly sont sur la MEME machine Docker) +docker network connect wordly-network +``` + +**Si NPM et Wordly sont sur des machines differentes** (recommande avec 3 machines) : +- Pas besoin de reseau Docker partage +- NPM utilisera l'IP de la machine Wordly au lieu du nom de container +- Voir section 2.4 pour la configuration + +### 2.3 Creer les Proxy Hosts dans NPM + +Se connecter a **http://IP_NPM:81** puis : + +#### Proxy Host 1 : wordly.art (Frontend + Backend) + +Aller dans **Proxy Hosts** > **Add Proxy Host** : + +**Onglet Details :** +- **Domain Names** : `wordly.art` +- **Scheme** : `http` +- **Forward Hostname/IP** : `wordly-frontend` *(si meme machine)* OU `IP_MACHINE_WORDLY` *(si machines differentes)* +- **Forward Port** : `3000` +- Cocher **Block Common Exploits** +- Cocher **Websockets Support** + +**Onglet SSL :** +- **SSL Certificate** : `Request a new SSL Certificate` +- Cocher **Force SSL** +- Cocher **HTTP/2 Support** +- Cocher **HSTS Enabled** +- **Email** : `admin@wordly.art` +- Cocher **I Agree to the...** + +Cliquer **Save**. NPM va automatiquement obtenir le certificat Let's Encrypt. + +#### Proxy Host 2 : www.wordly.art -> redirect + +**Onglet Details :** +- **Domain Names** : `www.wordly.art` +- **Scheme** : `http` +- **Forward Hostname/IP** : `wordly-frontend` +- **Forward Port** : `3000` + +**Onglet SSL :** +- Meme certificat que wordly.art (selectionner le certificat deja cree) + +**Onglet Advanced :** +Ajouter dans le champ Custom Nginx Configuration : + +```nginx +# Rediriger toutes les requetes API et translate vers le backend +location /api/ { + proxy_pass http://wordly-backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + add_header Access-Control-Allow-Origin https://wordly.art always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-API-Key" always; + add_header Access-Control-Allow-Credentials "true" always; + + if ($request_method = 'OPTIONS') { + return 204; + } +} + +location /translate { + proxy_pass http://wordly-backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + + client_max_body_size 100M; +} + +location /health { + proxy_pass http://wordly-backend:8000; + proxy_http_version 1.1; + proxy_set_header Connection ""; +} + +location /docs { + proxy_pass http://wordly-backend:8000; + proxy_set_header Host $host; +} + +location /redoc { + proxy_pass http://wordly-backend:8000; + proxy_set_header Host $host; +} + +location /openapi.json { + proxy_pass http://wordly-backend:8000; + proxy_set_header Host $host; +} + +location /admin { + proxy_pass http://wordly-frontend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /_next/static/ { + proxy_pass http://wordly-frontend:3000; + add_header Cache-Control "public, max-age=31536000, immutable"; +} +``` + +> **IMPORTANT** : Si NPM et Wordly sont sur des machines differentes, remplacer +> `wordly-backend:8000` par `IP_MACHINE_WORDLY:8000` et +> `wordly-frontend:3000` par `IP_MACHINE_WORDLY:3000` + +#### Proxy Host 3 : monitoring.wordly.art (Grafana) + +**Onglet Details :** +- **Domain Names** : `monitoring.wordly.art` +- **Scheme** : `http` +- **Forward Hostname/IP** : `wordly-grafana` *(si meme machine)* OU `IP_MACHINE_WORDLY` *(si machines differentes)* +- **Forward Port** : `3000` +- Cocher **Block Common Exploits** + +**Onglet SSL :** +- **SSL Certificate** : `Request a new SSL Certificate` +- Cocher **Force SSL** +- Cocher **HSTS Enabled** +- **Email** : `admin@wordly.art` + +### 2.4 Cas : NPM et Wordly sur machines differentes + +Si NPM tourne sur Machine A et Wordly sur Machine B : + +1. **Ouvrir les ports** sur la Machine B pour que NPM puisse atteindre les services : + ```bash + # Sur Machine B, ouvrir dans le pare-feu local + sudo ufw allow from IP_MACHINE_A to any port 3000 # Frontend + sudo ufw allow from IP_MACHINE_A to any port 8000 # Backend + sudo ufw allow from IP_MACHINE_A to any port 3001 # Grafana + ``` + +2. **Exposer les ports** dans docker-compose.yml en ajoutant la section `ports` : + ```yaml + backend: + # ... config existante ... + ports: + - "IP_MACHINE_B:8000:8000" # Bind sur IP locale seulement + + frontend: + # ... config existante ... + ports: + - "IP_MACHINE_B:3000:3000" + + grafana: + # ... config existante ... + ports: + - "IP_MACHINE_B:3000:3000" + ``` + +3. Dans NPM, utiliser `IP_MACHINE_B` comme Forward Hostname + +--- + +## Etape 3 : Deploiement de l'application + +### 3.1 Preparer le serveur + +```bash +# Installer Docker (si pas deja fait) +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +newgrp docker + +# Verifier +docker --version +docker compose version +``` + +### 3.2 Transferer le code + +```bash +# Cloner le repo +git clone /opt/wordly +cd /opt/wordly +git checkout production-deployment +``` + +### 3.3 Configurer les secrets + +```bash +cd /opt/wordly + +# Copier le fichier env +cp .env.production .env + +# Generer le hash bcrypt du mot de passe admin +docker run --rm python:3.12-slim bash -c " + pip install 'passlib[bcrypt]' bcrypt > /dev/null 2>&1 && + python -c \"from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('TON_MOT_DE_PASSE_ADMIN'))\" +" +``` + +Copier le hash affiche et le coller dans `.env` pour `ADMIN_PASSWORD_HASH`. + +```bash +# Editer le fichier .env et verifier tous les champs +nano .env +``` + +Verifications cles dans `.env` : +- `DOMAIN=wordly.art` +- `NEXT_PUBLIC_API_URL=https://wordly.art` +- `JWT_SECRET_KEY=` (deja rempli) +- `ADMIN_TOKEN_SECRET=` (deja rempli) +- `ADMIN_PASSWORD_HASH=` (coller le hash genere) +- `CORS_ORIGINS=https://wordly.art` +- `POSTGRES_PASSWORD=` (deja rempli) + +### 3.4 Lancer l'application + +```bash +# Build et demarrage +docker compose up -d --build + +# Suivre les logs pendant le premier demarrage +docker compose logs -f +``` + +### 3.5 Verifier + +```bash +# Sur le serveur, tester directement +curl http://localhost:8000/health + +# Verifier tous les containers +docker compose ps +``` + +Resultat attendu : +``` +NAME STATUS PORTS +wordly-postgres Up (healthy) +wordly-redis Up (healthy) +wordly-backend Up (healthy) +wordly-frontend Up (healthy) +``` + +--- + +## Etape 4 : Verification complete + +### 4.1 Tester depuis l'exterieur + +```bash +# Depuis un autre ordinateur ou telephone +curl -I https://wordly.art + +# Doit retourner: +# HTTP/2 200 +# server: nginx +# strict-transport-security: ... +``` + +### 4.2 Tester dans le navigateur + +1. Ouvrir **https://wordly.art** -> doit afficher le frontend +2. Ouvrir **https://wordly.art/health** -> doit retourner `{"status": "ok"}` +3. Ouvrir **https://wordly.art/admin** -> doit afficher le login admin +4. Ouvrir **https://monitoring.wordly.art** -> doit afficher Grafana + +### 4.3 Tester le SSL + +Le cadenas vert doit etre present sur wordly.art. NPM a automatiquement obtenu le certificat Let's Encrypt. + +--- + +## Etape 5 : Backup automatique vers NAS + +### 5.1 Monter le NAS + +#### Option A : SMB/CIFS (Synology / QNAP) +```bash +sudo apt install cifs-utils +sudo mkdir -p /mnt/nas-backups/wordly + +# Fichier credentials +sudo tee /etc/nas-credentials <> /var/log/wordly-backup.log 2>&1 +``` + +--- + +## Etape 6 : Monitoring (Prometheus + Grafana) + +### 6.1 Lancer le stack monitoring +```bash +cd /opt/wordly + +# Creer le reseau externe si necessaire +docker network create wordly-network 2>/dev/null || true + +# Lancer application + monitoring +docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d +``` + +### 6.2 Acceder a Grafana + +- **URL** : `https://monitoring.wordly.art` (via NPM) +- **Login** : `admin` +- **Mot de passe** : `WordlyGrafana2026!` +- Changer le mot de passe a la premiere connexion + +### 6.3 Dashboards pre-configures + +| Dashboard | Contenu | +|-----------|---------| +| **Wordly - Application** | Traductions, latence, providers, taux d'erreur | +| **Wordly - Infrastructure** | CPU, RAM, disque, reseau, status containers | + +Ils apparaissent automatiquement dans le dossier **Wordly** de Grafana. + +### 6.4 Configurer les alertes (optionnel) + +Dans Grafana : **Alerting** > **Contact points** > **Add contact point** : +- Type : **Email** ou **Discord webhook** +- Ajouter ton email ou l'URL du webhook + +Les regles d'alerte suivantes sont pre-configures : +- Backend down +- Taux d'erreur > 10% +- RAM > 90% +- Disque < 15% + +--- + +## Etape 7 : Operations courantes + +### Logs +```bash +docker compose logs -f --tail=100 # Tous les services +docker compose logs -f backend # Backend seul +docker compose logs -f frontend # Frontend seul +docker compose logs -f postgres # Base de donnees +``` + +### Redemarrer +```bash +docker compose restart backend +docker compose restart frontend +``` + +### Mettre a jour +```bash +cd /opt/wordly +git pull origin production-deployment +docker compose up -d --build +``` + +### Restaurer un backup +```bash +/opt/wordly/scripts/backup-to-nas.sh --restore +# Liste les backups disponibles + +/opt/wordly/scripts/backup-to-nas.sh --restore wordly_db_20260510_030000.sql.gz +``` + +### Verifier l'espace +```bash +df -h # Disque systeme +docker system df # Espace Docker +``` + +### Renouveler le SSL +NPM renouvelle les certificats Let's Encrypt automatiquement. Pas d'action requise. +Verifier dans NPM > SSL Certificates que le statut est OK. + +--- + +## Checklist de deploiement + +### DNS IONOS +- [ ] Enregistrement A : `@` -> IP fixe +- [ ] Enregistrement A : `www` -> IP fixe +- [ ] Enregistrement A : `monitoring` -> IP fixe +- [ ] Propagation DNS verifiee (nslookup) + +### Nginx Proxy Manager +- [ ] Proxy Host cree pour `wordly.art` -> frontend:3000 +- [ ] SSL Let's Encrypt obtenu pour `wordly.art` +- [ ] Custom config nginx ajoutee (routing API/backend) +- [ ] Proxy Host cree pour `monitoring.wordly.art` -> grafana:3000 +- [ ] SSL Let's Encrypt obtenu pour `monitoring.wordly.art` + +### Serveur Docker +- [ ] Docker installe +- [ ] Code clone dans /opt/wordly +- [ ] Fichier .env rempli avec tous les secrets +- [ ] Hash bcrypt genere et colle +- [ ] `docker compose up -d --build` reussi +- [ ] Tous les containers healthy + +### NAS +- [ ] Partage de backup cree sur le NAS +- [ ] NAS monte sur /mnt/nas-backups/wordly +- [ ] Backup manuel test OK +- [ ] Cron programme a 3h + +### Verification finale +- [ ] https://wordly.art accessible depuis Internet +- [ ] HTTPS OK (cadenas vert) +- [ ] Upload fichier OK +- [ ] Traduction test OK +- [ ] https://monitoring.wordly.art accessible +- [ ] Dashboards Grafana affichent des donnees +- [ ] Page admin accessible (/admin) diff --git a/MARKETING_PLAN.md b/MARKETING_PLAN.md new file mode 100644 index 0000000..63be3cd --- /dev/null +++ b/MARKETING_PLAN.md @@ -0,0 +1,212 @@ +# Plan Marketing - Office Translator (SaaS de Traduction de Documents) + +> Document de référence pour l'agent marketing. Dernière mise à jour : 2026-05-10 + +--- + +## 1. Positionnement & Proposition de Valeur + +### Le Produit +**Office Translator** est un service SaaS de traduction de documents professionnels (Word, Excel, PowerPoint) qui **préserve parfaitement la mise en forme, les tableaux, les images et les styles** du document original. + +### Ce qui nous différencie +- **Préservation du format** : Contrairement à Google Translate ou DeepL qui détruisent les layouts complexes, notre moteur maintient la structure exacte +- **Multi-providers** : L'utilisateur choisit son moteur (Google, DeepL, OpenAI, Ollama/local) selon son budget et ses besoins +- **Glossaires techniques** : Terminologie personnalisée (HVAC, IT, Juridique, Médical) +- **Self-hostable** : Peut être déployé en privé pour les entreprises soucieuses de confidentialité + +### Marché cible +| Segment | Taille estimée | Priorité | +|---------|---------------|----------| +| PME internationales ( traductions régulières) | Grand | P1 | +| Agences de traduction (productivité) | Moyen | P1 | +| Départements RH multilingues | Moyen | P2 | +| Freelancers / consultants | Grand | P2 | +| Étudiants & academics | Grand | P3 (freemium) | + +--- + +## 2. Supports Visuels Requis + +### A. Captures d'écran (OBLIGATOIRES - priorité maximale) + +| # | Capture | Usage | Instructions | +|---|---------|-------|-------------| +| 1 | **Page d'accueil / Hero** | Landing page, réseaux sociaux | Montrer l'interface épurée avec le drop-zone de fichier | +| 2 | **Upload en cours** | Démonstration du workflow | Fichier Excel chargé avec sélection langue source/cible | +| 3 | **Résultat côte à côte** | Preuve de qualité | Document original vs traduit, montrer que le format est intact | +| 4 | **Sélection du provider** | Fonctionnalité clé | Dropdown Google/DeepL/OpenAI/Ollama avec prix affichés | +| 5 | **Glossaire technique** | Différenciation | Interface de gestion des glossaires personnalisés | +| 6 | **Dashboard admin** | Crédibilité entreprise | Vue monitoring avec statistiques d'utilisation | +| 7 | **Page pricing/forfaits** | Conversion | Les 3 tiers (Starter/Pro/Business) clairement affichés | +| 8 | **Profil utilisateur** | Confiance | Page de profil avec historique et quota | + +**Format** : PNG, 1280x720 minimum, fond clair et sombre + +### B. Vidéo de Démonstration (OBLIGATOIRE) + +#### Vidéo courte (60-90 secondes) - "How it works" +- **Objectif** : Landing page + réseaux sociaux +- **Script suggéré** : + 1. (0-5s) Logo + tagline animé + 2. (5-15s) Problème : "Traduire un Excel de 50 pages sans casser le format ? Mission impossible." + 3. (15-40s) Démo accélérée : Upload → Sélection langue → Provider → Traduction → Download + 4. (40-55s) Split-screen : document original vs traduit, zoom sur tableaux/images intacts + 5. (55-65s) CTA : "Essayez gratuitement" + URL +- **Format** : MP4 1080p, sous-titres FR + EN + +#### Vidéo tutoriel (3-5 minutes) - "Guide complet" +- **Objectif** : YouTube, onboarding utilisateurs +- **Contenu** : Création de compte, upload, glossaires, providers, download +- **Format** : Screencast avec voiceover + +### C. Autres assets visuels + +| Asset | Spécifications | +|-------|---------------| +| **Logo SVG** | Version claire + sombre, icône seule + avec texte | +| **OG Image** | 1200x630px pour partage réseaux sociaux | +| **Favicon** | 32x32, 16x16, ICO + PNG | +| **Bannière GitHub** | 1280x640px pour le repo README | +| **GIF animé** | 15s loop du workflow upload→traduction→download | +| **Infographie** | "Pourquoi Office Translator" - comparaison avant/après | + +--- + +## 3. Canaux de Lancement + +### Phase 1 : Pré-lancement (Semaines 1-2) + +| Canal | Action | Priorité | +|-------|--------|----------| +| **Landing page** | Mettre en place avec captures d'écran + vidéo + formulaire email | CRITIQUE | +| **Product Hunt** | Préparer le launch (assets, description, maker comment) | HAUTE | +| **Reddit** | Posts dans r/SideProject, r/selfhosted, r/translator | HAUTE | +| **Hacker News** | Préparer un "Show HN" technique | HAUTE | +| **Twitter/X** | Thread de lancement avec démo GIF | MOYENNE | +| **LinkedIn** | Post professionnel ciblant PME et agences | MOYENNE | + +### Phase 2 : Lancement (Semaine 3) + +| Canal | Action | +|-------|--------| +| **Product Hunt** | Lancement le mardi ou mercredi (meilleur trafic) | +| **Reddit** | Cross-post dans 5-6 subreddits pertinents | +| **Hacker News** | Soumettre le Show HN le matin (heure EST) | +| **Twitter/X** | Thread avec GIF + link Product Hunt | +| **IndieHackers** | Post détaillé sur le build process | +| **Dev.to** | Article technique sur l'architecture | +| **Twitter/X (communautés)** | Cibler #BuildInPublic, #SaaS, #i18n | + +### Phase 3 : Croissance (Semaines 4-8) + +| Canal | Action | +|-------|--------| +| **SEO** | Articles de blog : "Comment traduire un Excel sans perdre le format", etc. | +| **YouTube** | Tutoriels et reviews | +| **Partenariats** | Agences de traduction, consultants internationaux | +| **Google Ads** | Mots-clés "translate excel document", "translate powerpoint" | +| **Communautés** | Discord/Slack de développeurs et traducteurs | +| **AppSumo** | Liste Lifetime Deal pour traction initiale | + +--- + +## 4. Stratégie de Contenu + +### Articles de Blog (SEO) - Minimum 5 au lancement + +1. **"Comment traduire un fichier Excel sans perdre la mise en forme"** +2. **"Les 5 meilleurs outils de traduction de documents comparés (2026)"** +3. **"Traduction professionnelle : guide complet pour les PME"** +4. **"Pourquoi DeepL et Google Translate détruisent vos documents Excel"** +5. **"Auto-héberger son outil de traduction : guide complet"** + +### Contenu Réseaux Sociaux (Répétitif) + +| Type | Fréquence | Plateforme | +|------|-----------|-----------| +| Astuce traduction | 2x/semaine | Twitter, LinkedIn | +| Before/After document | 1x/semaine | Twitter, Instagram | +| Thread technique | 1x/2 semaines | Twitter | +| Témoignage client | Quand disponible | Tous | +| Mise à jour produit | Selon releases | Tous | + +--- + +## 5. Stratégie Tarifaire & Positionnement Prix + +### Forfaits actuels (à communiquer) + +| Plan | Prix | Public cible | Message clé | +|------|------|-------------|-------------| +| **Starter** | Prix entry-level | Freelancers, étudiants | "Testez sans risque" | +| **Pro** | Prix milieu | PME, consultants | "Le meilleur rapport qualité-prix" | +| **Business** | Prix premium | Agences, entreprises | "Volume illimité + support dédié" | + +### Message tarifaire +- Insister sur le **coût par page** vs traduction humaine (généralement 50-100x moins cher) +- Mettre en avant le **freemium** ou l'**essai gratuit** si disponible +- Comparer avec les coûts des solutions concurrentes + +--- + +## 6. KPIs à Suivre + +| Métrique | Objectif Mois 1 | Objectif Mois 3 | +|----------|----------------|-----------------| +| Visiteurs uniques | 5,000 | 25,000 | +| Inscriptions | 200 | 1,500 | +| Documents traduits | 500 | 5,000 | +| Taux de conversion | 2% | 4% | +| NPS | > 40 | > 50 | +| Revenue mensuel | Variable | Variable | + +--- + +## 7. Plan d'Action pour l'Agent Marketing + +### Checklist Exécutable + +- [ ] **Captures d'écran** : Réaliser les 8 captures listées en section 2A +- [ ] **Vidéo courte** : Produire la démo 60-90s (section 2B) +- [ ] **Vidéo tutoriel** : Produire le screencast 3-5 min +- [ ] **Landing page** : Concevoir et publier avec tous les assets +- [ ] **Product Hunt** : Préparer le listing complet +- [ ] **Reddit posts** : Rédiger 5 posts adaptés par subreddit +- [ ] **Show HN** : Écrire la soumission Hacker News +- [ ] **Twitter thread** : Préparer le thread de lancement (10-15 tweets) +- [ ] **Articles SEO** : Rédiger les 5 articles de blog +- [ ] **OG Image** : Créer l'image de partage réseaux sociaux +- [ ] **GIF animé** : Créer le loop de 15s du workflow +- [ ] **Infographie comparatif** : Créer le visuel avant/après +- [ ] **Setup analytics** : Google Analytics + Mixpanel/PostHog +- [ ] **Email sequence** : 5 emails onboarding post-inscription +- [ ] **FAQ page** : Répondre aux 10 questions les plus fréquentes + +--- + +## 8. Concurrence Directe + +| Concurrent | Força | Faiblesse | Notre avantage | +|-----------|-------|-----------|---------------| +| **Google Translate (docs)** | Gratuit, connu | Détruit les formats complexes | Préservation du format | +| **DeepL (docs)** | Qualité de traduction | Cher, formatage limité | Multi-provider + prix | +| **DocTranslator** | Simple | Qualité inégale, publicité | Interface pro + glossaires | +| **Smartcat** | Complet | Complexe, cher | Simplicité + self-hostable | +| **Transifex** | Enterprise | Trop cher pour PME | Prix accessible | + +--- + +## 9. Timeline Recommandée + +``` +Semaine 1-2 : Production des assets (captures, vidéos, images) +Semaine 3 : Lancement sur Product Hunt + Reddit + HN + Twitter +Semaine 4 : Articles SEO + contenu evergreen +Semaine 5-6 : Partenariats + communautés + Google Ads +Semaine 7-8 : Optimisation basée sur les premiers retours +``` + +--- + +*Ce document doit être mis à jour au fur et à mesure des retours utilisateur et des métriques observées.* diff --git a/alembic/versions/006_fix_tier_check_constraint.py b/alembic/versions/006_fix_tier_check_constraint.py new file mode 100644 index 0000000..9613de2 --- /dev/null +++ b/alembic/versions/006_fix_tier_check_constraint.py @@ -0,0 +1,31 @@ +"""Fix tier CHECK constraint to allow all plan tiers + +Revision ID: 006 +Revises: cb71a958ad92 +""" +from alembic import op + +revision = "006" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE users DROP CONSTRAINT IF EXISTS ck_users_tier" + ) + op.execute( + "ALTER TABLE users ADD CONSTRAINT ck_users_tier " + "CHECK (tier IN ('free', 'starter', 'pro', 'business', 'enterprise'))" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE users DROP CONSTRAINT IF EXISTS ck_users_tier" + ) + op.execute( + "ALTER TABLE users ADD CONSTRAINT ck_users_tier " + "CHECK (tier IN ('free', 'pro'))" + ) diff --git a/config.py b/config.py index dc54c87..c7bd948 100644 --- a/config.py +++ b/config.py @@ -32,7 +32,7 @@ class Config: LOGS_DIR = BASE_DIR / "logs" # Supported file types - SUPPORTED_EXTENSIONS = {".xlsx", ".docx", ".pptx"} + SUPPORTED_EXTENSIONS = {".xlsx", ".docx", ".pptx", ".pdf"} # ============== Rate Limiting (SaaS) ============== RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true" diff --git a/database/models.py b/database/models.py index 772ed21..dbdd839 100644 --- a/database/models.py +++ b/database/models.py @@ -100,7 +100,7 @@ class User(Base): api_keys = relationship("ApiKey", back_populates="user", lazy="select") __table_args__ = ( - CheckConstraint("tier IN ('free', 'pro')", name="ck_users_tier"), + CheckConstraint("tier IN ('free', 'starter', 'pro', 'business', 'enterprise')", name="ck_users_tier"), Index("ix_users_email_active", "email", "is_active"), Index("ix_users_stripe_customer", "stripe_customer_id"), ) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index e0f7377..caa067b 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -40,7 +40,7 @@ services: image: redis:7-alpine container_name: translate-redis restart: unless-stopped - command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru --loglevel warning + command: redis-server --appendonly yes --appendfsync everysec --maxmemory 256mb --maxmemory-policy allkeys-lru --loglevel warning volumes: - redis_data:/data ports: diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml new file mode 100644 index 0000000..3261d46 --- /dev/null +++ b/docker-compose.monitoring.yml @@ -0,0 +1,146 @@ +# ============================================ +# Wordly.art - Monitoring Stack (Prometheus + Grafana) +# ============================================ +# Deploys alongside the main docker-compose.yml +# Grafana accessible via NPM sur monitoring.wordly.art (ou IP:3001) +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d +# +# Access: +# Grafana: http://:3001 (admin / WordlyGrafana2026!) +# Ou via NPM: monitoring.wordly.art -> wordly-grafana:3000 +# ============================================ + +services: + # =========================================== + # Prometheus - Metrics Collection + # =========================================== + prometheus: + image: prom/prometheus:v2.52.0 + container_name: wordly-prometheus + restart: unless-stopped + volumes: + - ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./docker/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + - '--storage.tsdb.retention.size=5GB' + - '--web.enable-lifecycle' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + networks: + - wordly-network + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"] + interval: 30s + timeout: 5s + retries: 3 + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 128M + + # =========================================== + # Grafana - Dashboards & Visualization + # =========================================== + grafana: + image: grafana/grafana:11.0.0 + container_name: wordly-grafana + restart: unless-stopped + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=WordlyGrafana2026! + - GF_USERS_ALLOW_SIGN_UP=false + - GF_SERVER_ROOT_URL=https://monitoring.wordly.art + - GF_INSTALL_PLUGINS=grafana-clock-panel + volumes: + - grafana_data:/var/lib/grafana + - ./docker/grafana/provisioning:/etc/grafana/provisioning:ro + - ./docker/grafana/dashboards:/var/lib/grafana/dashboards:ro + ports: + - "3001:3000" + networks: + - wordly-network + depends_on: + prometheus: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 5s + retries: 3 + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 64M + + # =========================================== + # Node Exporter - System Metrics (CPU, RAM, Disk, Network) + # =========================================== + node-exporter: + image: prom/node-exporter:v1.8.0 + container_name: wordly-node-exporter + restart: unless-stopped + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - '--path.procfs=/host/proc' + - '--path.sysfs=/host/sys' + - '--path.rootfs=/rootfs' + - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|etc|var/lib/docker)($$|/)' + networks: + - wordly-network + deploy: + resources: + limits: + memory: 64M + + # =========================================== + # cAdvisor - Docker Container Metrics + # =========================================== + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.49.1 + container_name: wordly-cadvisor + restart: unless-stopped + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk:/dev/disk:ro + privileged: true + devices: + - /dev/kmsg + networks: + - wordly-network + deploy: + resources: + limits: + memory: 128M + +# =========================================== +# Volumes +# =========================================== +volumes: + prometheus_data: + driver: local + grafana_data: + driver: local + +# =========================================== +# Networks - Must match main docker-compose +# =========================================== +networks: + wordly-network: + external: true diff --git a/docker-compose.yml b/docker-compose.yml index 12cb65f..d5e3877 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,15 @@ -# Document Translation API - Production Docker Compose -# Usage: docker compose up -d -# (or: docker-compose up -d) +# ============================================ +# Wordly.art - Production Docker Compose +# ============================================ +# Architecture: Nginx Proxy Manager (NPM) gere le SSL et reverse proxy +# NPM pointe vers les services internes sur ce réseau Docker # -# All services run in production mode (no code mounts, built images). -# Secrets: set in .env at project root (see .env.example "Production / VPS" section). - -version: '3.8' +# Usage: +# docker compose up -d +# +# Puis configurer NPM pour pointer vers: +# wordly.art -> frontend:3000 (et backend:8000 pour /api/ et /translate) +# ============================================ services: # =========================================== @@ -13,18 +17,17 @@ services: # =========================================== postgres: image: postgres:16-alpine - container_name: translate-postgres + container_name: wordly-postgres restart: unless-stopped environment: - POSTGRES_USER=${POSTGRES_USER:-translate} - # Production: set POSTGRES_PASSWORD in .env (required, no default — NFR10) - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB:-translate_db} - PGDATA=/var/lib/postgresql/data/pgdata volumes: - postgres_data:/var/lib/postgresql/data networks: - - translate-network + - wordly-network healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"] interval: 10s @@ -39,41 +42,53 @@ services: memory: 128M # =========================================== - # Backend API Service + # Redis (Caching & Sessions) + # =========================================== + redis: + image: redis:7-alpine + container_name: wordly-redis + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + networks: + - wordly-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + + # =========================================== + # Backend API (FastAPI) # =========================================== backend: build: context: . dockerfile: docker/backend/Dockerfile target: production - container_name: translate-backend + container_name: wordly-backend restart: unless-stopped env_file: - .env environment: - # Database (POSTGRES_PASSWORD must be set in .env — no default) - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-translate}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-translate_db} - # Redis - REDIS_URL=redis://redis:6379/0 - # Translation Services - TRANSLATION_SERVICE=${TRANSLATION_SERVICE:-ollama} - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434} - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3} - DEEPL_API_KEY=${DEEPL_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - # File Limits - MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-50} - # Rate Limiting - RATE_LIMIT_REQUESTS_PER_MINUTE=${RATE_LIMIT_REQUESTS_PER_MINUTE:-60} - RATE_LIMIT_TRANSLATIONS_PER_MINUTE=${RATE_LIMIT_TRANSLATIONS_PER_MINUTE:-10} - # Admin Auth (CHANGE IN PRODUCTION!) - ADMIN_USERNAME=${ADMIN_USERNAME} - ADMIN_PASSWORD=${ADMIN_PASSWORD} - # Security (.env.example documents JWT_SECRET_KEY) - JWT_SECRET_KEY=${JWT_SECRET_KEY} - - CORS_ORIGINS=${CORS_ORIGINS:-https://yourdomain.com} - # Stripe Payments + - ADMIN_TOKEN_SECRET=${ADMIN_TOKEN_SECRET} + - CORS_ORIGINS=${CORS_ORIGINS:-https://wordly.art} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} @@ -84,7 +99,7 @@ services: - outputs_data:/app/outputs - logs_data:/app/logs networks: - - translate-network + - wordly-network depends_on: postgres: condition: service_healthy @@ -95,7 +110,7 @@ services: interval: 30s timeout: 10s retries: 3 - start_period: 10s + start_period: 15s deploy: resources: limits: @@ -104,23 +119,23 @@ services: memory: 512M # =========================================== - # Frontend Web Service + # Frontend (Next.js) # =========================================== frontend: build: context: . dockerfile: docker/frontend/Dockerfile target: production - args: - - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000} - container_name: translate-frontend + args: + NEXT_PUBLIC_API_URL: "" + container_name: wordly-frontend restart: unless-stopped env_file: - .env environment: - - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000} + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://wordly.art} networks: - - translate-network + - wordly-network depends_on: backend: condition: service_healthy @@ -131,44 +146,17 @@ services: reservations: memory: 128M - # =========================================== - # Nginx Reverse Proxy - # =========================================== - nginx: - image: nginx:alpine - container_name: translate-nginx - restart: unless-stopped - ports: - - "${HTTP_PORT:-80}:80" - - "${HTTPS_PORT:-443}:443" - volumes: - - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - - ./docker/nginx/conf.d:/etc/nginx/conf.d:ro - - ./docker/nginx/ssl:/etc/nginx/ssl:ro - - nginx_cache:/var/cache/nginx - networks: - - translate-network - depends_on: - - frontend - - backend - # Config syntax only; to verify proxy reachability run: curl -f https://your-domain/health (see DEPLOYMENT_GUIDE) - healthcheck: - test: ["CMD", "nginx", "-t"] - interval: 30s - timeout: 10s - retries: 3 - # =========================================== # Ollama (Optional - Local LLM) # =========================================== ollama: image: ollama/ollama:latest - container_name: translate-ollama + container_name: wordly-ollama restart: unless-stopped volumes: - ollama_data:/root/.ollama networks: - - translate-network + - wordly-network deploy: resources: reservations: @@ -179,74 +167,13 @@ services: profiles: - with-ollama - # =========================================== - # Redis (Caching & Sessions) - # =========================================== - redis: - image: redis:7-alpine - container_name: translate-redis - restart: unless-stopped - command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru - volumes: - - redis_data:/data - networks: - - translate-network - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 5s - - # =========================================== - # Prometheus (Optional - Monitoring) - # =========================================== - prometheus: - image: prom/prometheus:latest - container_name: translate-prometheus - restart: unless-stopped - volumes: - - ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus_data:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - - '--web.enable-lifecycle' - networks: - - translate-network - profiles: - - with-monitoring - - # =========================================== - # Grafana (Optional - Dashboards) - # =========================================== - grafana: - image: grafana/grafana:latest - container_name: translate-grafana - restart: unless-stopped - environment: - - GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin} - - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin} - - GF_USERS_ALLOW_SIGN_UP=false - volumes: - - grafana_data:/var/lib/grafana - - ./docker/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro - networks: - - translate-network - depends_on: - - prometheus - profiles: - - with-monitoring - # =========================================== # Networks # =========================================== networks: - translate-network: + wordly-network: driver: bridge - ipam: - config: - - subnet: 172.28.0.0/16 + name: wordly-network # =========================================== # Volumes @@ -260,13 +187,7 @@ volumes: driver: local logs_data: driver: local - nginx_cache: + redis_data: driver: local ollama_data: driver: local - redis_data: - driver: local - prometheus_data: - driver: local - grafana_data: - driver: local diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index cc994d5..7d17277 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -26,11 +26,15 @@ FROM python:3.12-slim AS production WORKDIR /app -# Install runtime dependencies only +# Install runtime dependencies + LibreOffice headless (required for DOCX→PDF) RUN apt-get update && apt-get install -y --no-install-recommends \ libmagic1 \ libpq5 \ curl \ + fonts-noto \ + fonts-noto-cjk \ + fonts-noto-cjk-extra \ + libreoffice-writer-nogui \ && rm -rf /var/lib/apt/lists/* \ && apt-get clean @@ -38,8 +42,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" -# Create non-root user for security -RUN groupadd -r translator && useradd -r -g translator translator +# Create non-root user with a proper home directory (LibreOffice needs it) +RUN groupadd -r translator && \ + useradd -r -g translator -m -d /home/translator translator && \ + mkdir -p /home/translator/.cache && \ + chown -R translator:translator /home/translator # Create necessary directories RUN mkdir -p /app/uploads /app/outputs /app/logs /app/temp \ diff --git a/docker/backend/entrypoint.sh b/docker/backend/entrypoint.sh index cc54a06..982245a 100644 --- a/docker/backend/entrypoint.sh +++ b/docker/backend/entrypoint.sh @@ -6,19 +6,28 @@ echo "🚀 Starting Document Translation API..." # Wait for database to be ready (if DATABASE_URL is set) if [ -n "$DATABASE_URL" ]; then echo "⏳ Waiting for database to be ready..." - - # Extract host and port from DATABASE_URL - # postgresql://user:pass@host:port/db - DB_HOST=$(echo $DATABASE_URL | sed -e 's/.*@\([^:]*\):.*/\1/') - DB_PORT=$(echo $DATABASE_URL | sed -e 's/.*:\([0-9]*\)\/.*/\1/') - + + # Extract host and port from DATABASE_URL (handles postgresql+asyncpg:// and postgresql://) + DB_HOST=$(python -c " +import re +m = re.search(r'@([^:/]+)', '$DATABASE_URL') +print(m.group(1) if m else 'postgres') +") + DB_PORT=$(python -c " +import re +m = re.search(r'@[^:]+:(\d+)', '$DATABASE_URL') +print(m.group(1) if m else '5432') +") + + echo " Connecting to ${DB_HOST}:${DB_PORT}..." + # Wait up to 30 seconds for database - for i in {1..30}; do + for i in $(seq 1 30); do if python -c " import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: - s.connect(('$DB_HOST', $DB_PORT)) + s.connect(('$DB_HOST', int('$DB_PORT'))) s.close() exit(0) except: @@ -30,7 +39,7 @@ except: echo " Waiting for database... ($i/30)" sleep 1 done - + # Run database migrations echo "📦 Running database migrations..." alembic upgrade head || echo "⚠️ Migration skipped (may already be up to date)" @@ -39,15 +48,23 @@ fi # Wait for Redis if configured if [ -n "$REDIS_URL" ]; then echo "⏳ Waiting for Redis..." - REDIS_HOST=$(echo $REDIS_URL | sed -e 's/redis:\/\/\([^:]*\):.*/\1/') - REDIS_PORT=$(echo $REDIS_URL | sed -e 's/.*:\([0-9]*\)\/.*/\1/') - - for i in {1..10}; do + REDIS_HOST=$(python -c " +import re +m = re.search(r'://([^:/]+)', '$REDIS_URL') +print(m.group(1) if m else 'redis') +") + REDIS_PORT=$(python -c " +import re +m = re.search(r'://[^:]+:(\d+)', '$REDIS_URL') +print(m.group(1) if m else '6379') +") + + for i in $(seq 1 10); do if python -c " import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: - s.connect(('$REDIS_HOST', $REDIS_PORT)) + s.connect(('$REDIS_HOST', int('$REDIS_PORT'))) s.close() exit(0) except: diff --git a/docker/grafana/dashboards/wordly-infrastructure.json b/docker/grafana/dashboards/wordly-infrastructure.json new file mode 100644 index 0000000..ff69dab --- /dev/null +++ b/docker/grafana/dashboards/wordly-infrastructure.json @@ -0,0 +1,168 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "title": "CPU Usage (%)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "100 - (avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", + "legendFormat": "{{instance}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 20 }, + "unit": "percent", + "max": 100 + } + } + }, + { + "title": "RAM Usage", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes * 100", + "legendFormat": "Used %" + }, + { + "expr": "node_memory_Buffers_bytes / node_memory_MemTotal_bytes * 100", + "legendFormat": "Buffers %" + }, + { + "expr": "node_memory_Cached_bytes / node_memory_MemTotal_bytes * 100", + "legendFormat": "Cache %" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2, "fillOpacity": 15 }, + "unit": "percent", + "max": 100 + } + } + }, + { + "title": "Disk Space", + "type": "gauge", + "gridPos": { "h": 6, "w": 6, "x": 0, "y": 8 }, + "targets": [ + { + "expr": "(1 - node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"}) * 100", + "legendFormat": "Used" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 70 }, + { "color": "red", "value": 90 } + ] + } + } + } + }, + { + "title": "Network I/O", + "type": "timeseries", + "gridPos": { "h": 6, "w": 10, "x": 6, "y": 8 }, + "targets": [ + { + "expr": "rate(node_network_receive_bytes_total{device!=\"lo\"}[5m]) * 8", + "legendFormat": "In {{device}}" + }, + { + "expr": "-rate(node_network_transmit_bytes_total{device!=\"lo\"}[5m]) * 8", + "legendFormat": "Out {{device}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { "lineWidth": 2 }, + "unit": "bps" + } + } + }, + { + "title": "Container Memory", + "type": "barchart", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 }, + "targets": [ + { + "expr": "container_memory_usage_bytes{name=~\"wordly.*|translate.*\"}", + "legendFormat": "{{name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes" + } + } + }, + { + "title": "Container CPU %", + "type": "barchart", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "targets": [ + { + "expr": "rate(container_cpu_usage_seconds_total{name=~\"wordly.*|translate.*\"}[5m]) * 100", + "legendFormat": "{{name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent" + } + } + }, + { + "title": "Service Status (Up/Down)", + "type": "stat", + "gridPos": { "h": 4, "w": 24, "x": 0, "y": 22 }, + "targets": [ + { + "expr": "up", + "legendFormat": "{{job}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "green", "value": 1 } + ] + }, + "mappings": [ + { "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } } + ] + } + } + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["wordly", "infrastructure"], + "time": { "from": "now-1h", "to": "now" }, + "timezone": "Europe/Paris", + "title": "Wordly - Infrastructure", + "uid": "wordly-infra", + "version": 1 +} diff --git a/docker/grafana/dashboards/wordly-overview.json b/docker/grafana/dashboards/wordly-overview.json new file mode 100644 index 0000000..bc2eb8d --- /dev/null +++ b/docker/grafana/dashboards/wordly-overview.json @@ -0,0 +1,206 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "title": "Traductions (dernières 24h)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "targets": [ + { + "expr": "sum(increase(translation_total[24h]))", + "legendFormat": "Total" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "steps": [ + { "color": "blue", "value": null }, + { "color": "green", "value": 10 }, + { "color": "orange", "value": 50 } + ] + }, + "unit": "none" + } + } + }, + { + "title": "Temps moyen (secondes)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "targets": [ + { + "expr": "avg(rate(translation_duration_seconds_sum[5m]) / rate(translation_duration_seconds_count[5m]))", + "legendFormat": "Avg" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 30 }, + { "color": "red", "value": 60 } + ] + }, + "unit": "s" + } + } + }, + { + "title": "Taux d'erreur (%)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "targets": [ + { + "expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m])) * 100", + "legendFormat": "Error %" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + }, + "unit": "percent", + "max": 100 + } + } + }, + { + "title": "Utilisateurs actifs (1h)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "targets": [ + { + "expr": "count(increase(http_requests_total{path!=\"/health\",path!=\"/metrics\"}[1h]) > 0)", + "legendFormat": "Active" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "thresholds": { + "steps": [ + { "color": "blue", "value": null }, + { "color": "green", "value": 5 } + ] + } + } + } + }, + { + "title": "Requetes par minute (par endpoint)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "targets": [ + { + "expr": "sum by (path) (rate(http_requests_total[5m]) * 60)", + "legendFormat": "{{path}}" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 10 + }, + "unit": "req/min" + } + } + }, + { + "title": "Temps de traduction (percentiles)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "targets": [ + { + "expr": "histogram_quantile(0.5, sum(rate(translation_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p50" + }, + { + "expr": "histogram_quantile(0.95, sum(rate(translation_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p95" + }, + { + "expr": "histogram_quantile(0.99, sum(rate(translation_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p99" + } + ], + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 5 + }, + "unit": "s" + } + } + }, + { + "title": "Traductions par provider", + "type": "piechart", + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 12 }, + "targets": [ + { + "expr": "sum by (provider) (increase(translation_total[24h]))", + "legendFormat": "{{provider}}" + } + ] + }, + { + "title": "Taille des fichiers uploades", + "type": "histogram", + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 12 }, + "targets": [ + { + "expr": "sum by (le) (increase(file_size_bytes_bucket[24h]))", + "legendFormat": "{{le}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes" + } + } + }, + { + "title": "Fichiers par type", + "type": "piechart", + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 }, + "targets": [ + { + "expr": "sum by (file_type) (increase(translation_total[24h]))", + "legendFormat": "{{file_type}}" + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["wordly", "application"], + "templating": { "list": [] }, + "time": { "from": "now-24h", "to": "now" }, + "timepicker": {}, + "timezone": "Europe/Paris", + "title": "Wordly - Application", + "uid": "wordly-app", + "version": 1 +} diff --git a/docker/grafana/provisioning/dashboards/dashboards.yml b/docker/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..abf7f69 --- /dev/null +++ b/docker/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: 'Wordly Dashboards' + orgId: 1 + folder: 'Wordly' + type: file + disableDeletion: false + editable: true + updateIntervalSeconds: 30 + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true diff --git a/docker/grafana/provisioning/datasources/datasources.yml b/docker/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..a7dd864 --- /dev/null +++ b/docker/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://wordly-prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: '15s' + httpMethod: POST diff --git a/docker/nginx/conf.d/default.conf b/docker/nginx/conf.d/default.conf index c5f7285..162743f 100644 --- a/docker/nginx/conf.d/default.conf +++ b/docker/nginx/conf.d/default.conf @@ -1,18 +1,11 @@ -# Document Translation API - Main Server Block -# HTTP to HTTPS redirect and main application routing +# Wordly.art - Production Nginx Config +# HTTP to HTTPS redirect + main application routing -# HTTP server - redirect to HTTPS +# HTTP server - redirect to HTTPS + Let's Encrypt server { listen 80; listen [::]:80; - server_name _; - - # Allow health checks on HTTP - location /health { - proxy_pass http://backend/health; - proxy_http_version 1.1; - proxy_set_header Connection ""; - } + server_name wordly.art www.wordly.art; # ACME challenge for Let's Encrypt location /.well-known/acme-challenge/ { @@ -21,7 +14,7 @@ server { # Redirect all other traffic to HTTPS location / { - return 301 https://$host$request_uri; + return 301 https://wordly.art$request_uri; } } @@ -29,19 +22,16 @@ server { server { listen 443 ssl http2; listen [::]:443 ssl http2; - server_name _; + server_name wordly.art; - # SSL certificates (replace with your paths) + # SSL certificates ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/privkey.pem; - ssl_trusted_certificate /etc/nginx/ssl/chain.pem; - # SSL configuration + # SSL hardening ssl_session_timeout 1d; ssl_session_cache shared:SSL:50m; ssl_session_tickets off; - - # Modern SSL configuration ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; @@ -58,9 +48,16 @@ server { add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:;" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://wordly.art ws: wss:;" always; - # API routes - proxy to backend (preserve full path so FastAPI receives /api/v1/...) + # File upload size + client_max_body_size 100M; + client_body_timeout 300s; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + # API routes -> Backend location /api/ { limit_req zone=api_limit burst=20 nodelay; limit_conn conn_limit 10; @@ -72,8 +69,7 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Connection ""; - - # CORS headers for API (Origin restricted to same-origin/localhost via map in nginx.conf) + add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-API-Key" always; @@ -84,7 +80,7 @@ server { } } - # File upload endpoint - special handling + # Translation endpoint - extended timeouts location /translate { limit_req zone=upload_limit burst=5 nodelay; limit_conn conn_limit 5; @@ -95,21 +91,51 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - - # Increased timeouts for file processing + proxy_connect_timeout 60s; proxy_send_timeout 600s; proxy_read_timeout 600s; } - # Health check endpoint + # Health check location /health { proxy_pass http://backend/health; proxy_http_version 1.1; proxy_set_header Connection ""; } - # Admin UI -> Frontend (Next.js page) + # Prometheus metrics (internal only - restrict access) + location /metrics { + # Allow only from Docker network and localhost + allow 172.28.0.0/16; + allow 127.0.0.1; + deny all; + + proxy_pass http://backend/metrics; + proxy_http_version 1.1; + proxy_set_header Connection ""; + } + + # API docs + location /docs { + proxy_pass http://backend/docs; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location /redoc { + proxy_pass http://backend/redoc; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location /openapi.json { + proxy_pass http://backend/openapi.json; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + # Admin -> Frontend location /admin { proxy_pass http://frontend; proxy_http_version 1.1; @@ -119,7 +145,13 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Frontend - Next.js application + # Frontend static assets with aggressive caching + location /_next/static/ { + proxy_pass http://frontend; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Frontend -> Next.js location / { proxy_pass http://frontend; proxy_http_version 1.1; @@ -131,16 +163,21 @@ server { proxy_set_header Connection "upgrade"; } - # Static files caching - location /_next/static/ { - proxy_pass http://frontend; - proxy_cache_valid 200 365d; - add_header Cache-Control "public, max-age=31536000, immutable"; - } - # Error pages error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } } + +# Redirect www to non-www +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name www.wordly.art; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + return 301 https://wordly.art$request_uri; +} diff --git a/docker/prometheus/alerts.yml b/docker/prometheus/alerts.yml new file mode 100644 index 0000000..111746f --- /dev/null +++ b/docker/prometheus/alerts.yml @@ -0,0 +1,101 @@ +# Wordly.art - Prometheus Alert Rules + +groups: + # Application alerts + - name: wordly_app + rules: + - alert: BackendDown + expr: up{job="wordly-backend"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: "Wordly backend is down" + description: "Backend has been down for more than 2 minutes." + + - alert: HighErrorRate + expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.1 + for: 5m + labels: + severity: warning + annotations: + summary: "High error rate detected" + description: "More than 10% of requests are returning 5xx errors." + + - alert: SlowTranslations + expr: histogram_quantile(0.95, rate(translation_duration_seconds_bucket[5m])) > 120 + for: 10m + labels: + severity: warning + annotations: + summary: "Translations are slow" + description: "95th percentile translation time is over 120 seconds." + + - alert: HighTranslationQueue + expr: translation_queue_size > 20 + for: 5m + labels: + severity: warning + annotations: + summary: "Translation queue is backing up" + description: "More than 20 translations queued." + + # System alerts + - name: wordly_system + rules: + - alert: HighMemoryUsage + expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes > 0.9 + for: 5m + labels: + severity: warning + annotations: + summary: "High memory usage" + description: "Server memory usage is above 90%." + + - alert: DiskSpaceLow + expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.15 + for: 10m + labels: + severity: warning + annotations: + summary: "Low disk space" + description: "Less than 15% disk space remaining on /." + + - alert: DiskSpaceCritical + expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.05 + for: 5m + labels: + severity: critical + annotations: + summary: "Critical disk space" + description: "Less than 5% disk space remaining on /." + + - alert: HighCPUUsage + expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85 + for: 10m + labels: + severity: warning + annotations: + summary: "High CPU usage" + description: "CPU usage is above 85% for 10 minutes." + + # Docker alerts + - name: wordly_docker + rules: + - alert: ContainerRestarted + expr: increase(container_restart_count[1h]) > 2 + for: 5m + labels: + severity: warning + annotations: + summary: "Container restarting" + description: "Container {{ $labels.name }} has restarted more than 2 times in the last hour." + + - alert: ContainerOOM + expr: increase(container_oom_events_total[1h]) > 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Container OOM killed" + description: "Container {{ $labels.name }} was OOM killed." diff --git a/docker/prometheus/prometheus.yml b/docker/prometheus/prometheus.yml index 6fb22f0..70870dc 100644 --- a/docker/prometheus/prometheus.yml +++ b/docker/prometheus/prometheus.yml @@ -1,37 +1,34 @@ -# Prometheus Configuration for Document Translation API +# Wordly.art - Prometheus Configuration global: scrape_interval: 15s evaluation_interval: 15s external_labels: - monitor: 'translate-api' + monitor: 'wordly-homelab' + environment: 'production' -alerting: - alertmanagers: - - static_configs: - - targets: [] - -rule_files: [] +rule_files: + - 'alerts.yml' scrape_configs: - # Backend API metrics - - job_name: 'translate-backend' + # Backend FastAPI + - job_name: 'wordly-backend' static_configs: - targets: ['backend:8000'] metrics_path: /metrics scrape_interval: 10s - # Nginx metrics (requires nginx-prometheus-exporter) - - job_name: 'nginx' - static_configs: - - targets: ['nginx-exporter:9113'] - - # Node exporter for system metrics + # Systeme (CPU, RAM, Disk, Reseau) - job_name: 'node' static_configs: - targets: ['node-exporter:9100'] - # Docker metrics + # Containers Docker - job_name: 'docker' static_configs: - targets: ['cadvisor:8080'] + + # Prometheus lui-meme + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dee306e..875cd33 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -172,6 +172,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -534,6 +535,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -574,6 +576,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -4591,7 +4594,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4612,7 +4614,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -4688,8 +4689,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4781,6 +4781,7 @@ "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4791,6 +4792,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4801,6 +4803,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4851,6 +4854,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -5495,6 +5499,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5638,7 +5643,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -6072,6 +6076,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6630,7 +6635,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -6668,8 +6672,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -7008,6 +7011,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7193,6 +7197,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7490,6 +7495,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8142,6 +8148,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -8863,6 +8870,7 @@ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", @@ -9345,7 +9353,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10191,7 +10198,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10207,7 +10213,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10220,8 +10225,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prop-types": { "version": "15.8.1", @@ -10356,6 +10360,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10365,6 +10370,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11501,6 +11507,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11764,6 +11771,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12011,6 +12019,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12104,6 +12113,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12468,6 +12478,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx deleted file mode 100644 index c8ff947..0000000 --- a/frontend/src/app/(app)/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Sidebar } from "@/components/sidebar" - -export default function AppLayout({ - children, -}: { - children: React.ReactNode -}) { - return ( - <> - -
-
- {children} -
-
- - ) -} diff --git a/frontend/src/app/(app)/ollama-setup/page.tsx b/frontend/src/app/(app)/ollama-setup/page.tsx deleted file mode 100644 index e7761e0..0000000 --- a/frontend/src/app/(app)/ollama-setup/page.tsx +++ /dev/null @@ -1,364 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { - Server, - Check, - AlertCircle, - Loader2, - ExternalLink, - Copy, - Terminal, - Cpu, - HardDrive -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { cn } from "@/lib/utils"; - -interface OllamaModel { - name: string; - size: string; - quantization: string; -} - -const recommendedModels: OllamaModel[] = [ - { name: "llama3.2:3b", size: "2 GB", quantization: "Q4_0" }, - { name: "mistral:7b", size: "4.1 GB", quantization: "Q4_0" }, - { name: "qwen2.5:7b", size: "4.7 GB", quantization: "Q4_K_M" }, - { name: "llama3.1:8b", size: "4.7 GB", quantization: "Q4_0" }, - { name: "gemma2:9b", size: "5.4 GB", quantization: "Q4_0" }, -]; - -export default function OllamaSetupPage() { - const [endpoint, setEndpoint] = useState("http://localhost:11434"); - const [selectedModel, setSelectedModel] = useState(""); - const [availableModels, setAvailableModels] = useState([]); - const [testing, setTesting] = useState(false); - const [connectionStatus, setConnectionStatus] = useState<"idle" | "success" | "error">("idle"); - const [errorMessage, setErrorMessage] = useState(""); - const [copied, setCopied] = useState(null); - - const testConnection = async () => { - setTesting(true); - setConnectionStatus("idle"); - setErrorMessage(""); - - try { - // Test connection to Ollama - const res = await fetch(`${endpoint}/api/tags`); - if (!res.ok) throw new Error("Failed to connect to Ollama"); - - const data = await res.json(); - const models = data.models?.map((m: any) => m.name) || []; - setAvailableModels(models); - setConnectionStatus("success"); - - // Auto-select first model if available - if (models.length > 0 && !selectedModel) { - setSelectedModel(models[0]); - } - } catch (error: any) { - setConnectionStatus("error"); - setErrorMessage(error.message || "Failed to connect to Ollama"); - } finally { - setTesting(false); - } - }; - - const copyToClipboard = (text: string, id: string) => { - navigator.clipboard.writeText(text); - setCopied(id); - setTimeout(() => setCopied(null), 2000); - }; - - const saveSettings = () => { - // Save to localStorage or user settings - const settings = { ollamaEndpoint: endpoint, ollamaModel: selectedModel }; - localStorage.setItem("ollamaSettings", JSON.stringify(settings)); - - // Also save to user account if logged in - const token = localStorage.getItem("token"); - if (token) { - fetch("http://localhost:8000/api/auth/settings", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - ollama_endpoint: endpoint, - ollama_model: selectedModel, - }), - }); - } - - alert("Settings saved successfully!"); - }; - - return ( -
-
- {/* Header */} -
- - Self-Hosted - -

- Configure Your Ollama Server -

-

- Connect your own Ollama instance for unlimited, free translations using local AI models. -

-
- - {/* What is Ollama */} -
-

- - What is Ollama? -

-

- Ollama is a free, open-source tool that lets you run large language models locally on your computer. - With Ollama, you can translate documents without sending data to external servers, ensuring complete privacy. -

- -
- - {/* Installation Guide */} -
-

- - Quick Installation Guide -

- -
- {/* Step 1 */} -
-

1. Install Ollama

-
-
-
- macOS / Linux: - curl -fsSL https://ollama.ai/install.sh | sh -
- -
-

- Windows: Download from ollama.ai/download -

-
-
- - {/* Step 2 */} -
-

2. Pull a Translation Model

-
- ollama pull llama3.2:3b - -
-
- - {/* Step 3 */} -
-

3. Start Ollama Server

-
- ollama serve - -
-

- On macOS/Windows with the desktop app, Ollama runs automatically in the background. -

-
-
-
- - {/* Recommended Models */} -
-

- - Recommended Models for Translation -

-
- {recommendedModels.map((model) => ( -
-
- {model.name} -
- - - {model.size} - -
-
- -
- ))} -
-

- 💡 Tip: For best results with limited RAM (8GB), use llama3.2:3b. - With 16GB+ RAM, try mistral:7b or larger. -

-
- - {/* Configuration */} -
-

Configure Connection

- -
-
- -
- setEndpoint(e.target.value)} - placeholder="http://localhost:11434" - className="bg-zinc-800 border-zinc-700 text-white" - /> - -
-
- - {/* Connection Status */} - {connectionStatus === "success" && ( -
- - Connected successfully! Found {availableModels.length} model(s). -
- )} - - {connectionStatus === "error" && ( -
- - {errorMessage} -
- )} - - {/* Model Selection */} - {availableModels.length > 0 && ( -
- -
- {availableModels.map((model) => ( - - ))} -
-
- )} - - {/* Save Button */} - {connectionStatus === "success" && selectedModel && ( - - )} -
-
- - {/* Benefits */} -
-

Why Self-Host?

-
-
-
🔒
-

Complete Privacy

-

Your documents never leave your computer

-
-
-
♾️
-

Unlimited Usage

-

No monthly limits or quotas

-
-
-
💰
-

Free Forever

-

No subscription or API costs

-
-
-
-
-
- ); -} diff --git a/frontend/src/app/(app)/settings/context/page.tsx b/frontend/src/app/(app)/settings/context/page.tsx deleted file mode 100644 index 6fd2442..0000000 --- a/frontend/src/app/(app)/settings/context/page.tsx +++ /dev/null @@ -1,354 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; -import { Badge } from "@/components/ui/badge"; -import { useTranslationStore } from "@/lib/store"; -import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2, ArrowRight, AlertCircle, CheckCircle, Zap } from "lucide-react"; -import { cn } from "@/lib/utils"; - -export default function ContextGlossaryPage() { - const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore(); - const [isSaving, setIsSaving] = useState(false); - - const [localSettings, setLocalSettings] = useState({ - systemPrompt: settings.systemPrompt, - glossary: settings.glossary, - }); - - useEffect(() => { - setLocalSettings({ - systemPrompt: settings.systemPrompt, - glossary: settings.glossary, - }); - }, [settings]); - - const handleSave = async () => { - setIsSaving(true); - try { - updateSettings(localSettings); - await new Promise((resolve) => setTimeout(resolve, 500)); - } finally { - setIsSaving(false); - } - }; - - const handleApplyPreset = (preset: 'hvac' | 'it' | 'legal' | 'medical') => { - applyPreset(preset); - // Need to get updated values from store after applying preset - setTimeout(() => { - setLocalSettings({ - systemPrompt: useTranslationStore.getState().settings.systemPrompt, - glossary: useTranslationStore.getState().settings.glossary, - }); - }, 0); - }; - - const handleClear = () => { - clearContext(); - setLocalSettings({ - systemPrompt: "", - glossary: "", - }); - }; - - // Check which LLM providers are configured - const isOllamaConfigured = settings.ollamaUrl && settings.ollamaModel; - const isOpenAIConfigured = !!settings.openaiApiKey; - const isWebLLMAvailable = typeof window !== 'undefined' && 'gpu' in navigator; - - return ( -
-
- {/* Header */} -
- - - Context & Glossary - -

- Context & Glossary -

-

- Configure translation context and glossary for LLM-based providers -

- - {/* LLM Provider Status */} -
- -
- {isOllamaConfigured && } - 🤖 Ollama -
-
- -
- {isOpenAIConfigured && } - 🧠 OpenAI -
-
- -
- {isWebLLMAvailable && } - 💻 WebLLM -
-
-
-
- - {/* Info Banner */} - - -
-
- -
-
-

- Context & Glossary Settings -

-

- These settings apply to all LLM providers: Ollama, OpenAI, and WebLLM. - Use them to improve translation quality with domain-specific instructions and terminology. -

-
-
-
-
- -
- {/* Left Column - System Prompt */} -
- - -
-
- -
-
- System Prompt - - Instructions for LLM to follow during translation - -
-
-
- -