eptm_dashboard/DEPLOY_PROD.md
Julien Balet 7d3b6e9136 v1.1.0 — fixes sync + UX dev/prod
Sync push_then_sync : préserve les absences 'publiee_escada' contre
écrasement/orphelines après push (PDF Escada stale). UI reconnaît le
statut (calendrier, éditeur, KPIs) au lieu d'afficher 'présent'.

Sync_esacada : timeout grille 20s → 45s + retry après reload (AUTOMAT 1
échouait à la 1re classe après changement de langue).

Telegram : ajoute liste d'erreurs + tail du log dans les notifs d'échec
même en mode normal — avant on avait juste 'a échoué (code 1)'.

UX :
- Calendrier toujours visible (même sans absences) et démarre sur le
  mois courant (pas sur le 1er mois d'absence) ; tous les jours
  cliquables pour pouvoir ajouter une absence.
- Date du jour pré-sélectionnée aussi via navigate_to (clic depuis
  /classe).
- KPIs cards taggées kpi-card/kpi-value pour CSS responsive mobile.
- Badge 'DEV' dans la sidebar (APP_ENV=dev) — invisible en prod.
- Badge 'Built with Reflex' masqué.
- KPIs retirés du dashboard /accueil.

Prod :
- Dockerfile.prod multi-stage (Reflex export bundle + runtime slim).
- docker-compose.prod.yml séparé (port 3002, projet eptm-dashboard-prod).
- .gitignore + .dockerignore nettoyés.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:11:39 +02:00

357 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Déploiement en production — EPTM Dashboard
Procédure de bascule de l'app **Streamlit legacy** (`absences.service`,
`dashboard.eptm-automation.ch`) vers l'app **Reflex** (en prod sur le
même sous-domaine).
**État : stack prod construite et démarrée le 2026-05-12. Cutover NPM
encore à faire (cf. § « Étape manuelle restante »).**
---
## État actuel (2026-05-11)
```
Internet :80/:443
┌────────────────────────────────────┐
│ NPM (nginx-proxy-manager) │
│ container "npm" sur proxy_net │
└────────────────────────────────────┘
├── dashboard.eptm-automation.ch
│ → 172.17.0.1:8501
│ → Streamlit legacy (systemd "absences.service")
│ /opt/absences/.venv/bin/streamlit run src/app.py
└── dev.dashboard.eptm-automation.ch
→ eptm-dashboard-app-1:3001 (Reflex dev)
+ /_event → :8001
(network proxy_net)
```
- Streamlit tourne en systemd `absences.service` (enabled, depuis le 2026-05-09).
- L'app Reflex dev est containerisée, déjà sur `proxy_net`, accessible via NPM.
- Pas encore de Dockerfile/compose **prod** : il faut les créer.
## État cible
```
Internet :80/:443 → NPM
├── dashboard.eptm-automation.ch
│ → eptm-dashboard-prod-app-1:3002 (Reflex prod)
│ + /_event → :8002
└── dev.dashboard.eptm-automation.ch
→ eptm-dashboard-app-1:3001 (Reflex dev, inchangé)
+ /_event → :8001
```
- Streamlit `absences.service` arrêté + disabled (binaires gardés en backup
quelques semaines, suppression plus tard).
- Deux stacks compose côte à côte sur `proxy_net` :
- dev : 3001 (frontend Vite) + 8001 (backend granian) — séparés car HMR
- prod : **3002** uniquement (Reflex 0.9+ exige frontend + backend même
port en prod ; granian sert tout depuis 3002, y compris `/_event` en WS)
- Projets compose nommés : `eptm-dashboard-dev` et `eptm-dashboard-prod`
(pour éviter que `compose up` d'une stack recrée le container de l'autre).
- Aucun port host exposé — tout passe par NPM sur `proxy_net`.
- NPM continue de gérer les certs Let's Encrypt (déjà émis pour ce
sous-domaine, juste à conserver — pas de renouvellement à forcer).
---
## ✅ Décisions prises (2026-05-12)
| # | Question | Hypothèse par défaut |
|---|---|---|
| # | Question | Décision |
|---|---|---|
| 1 | Données prod | **Isolées** : `./data_prod/` séparé de `./data/` |
| 2 | DB initiale prod | **Copie** de `data/absences.db` au cutover |
| 3 | Streamlit après cutover | **Disabled** ~1 mois (rollback plan B), purge en tâche TODO |
| 4 | Build | **Local** sur le serveur (pas de CI / registry pour démarrer) |
| 5 | Anciens `Dockerfile` + `docker-compose.yml` | **Renommés `.back`**, nouveaux fichiers `.prod` créés à côté |
| 6 | `.env.prod` | **Minimal** : `REFLEX_SECRET_KEY` + Telegram bot + `TZ`. SMTP/Escada/etc. restent dans `settings.json` |
| 7 | Emplacement `data_prod/` | `/opt/eptm-dashboard/data_prod/` (gitignored) |
| 8 | `browser_profile/` côté prod | **Non copié** — session SSO à refaire au 1er sync (Chromium non-headless une fois) |
| 9 | Templates AcroForm + docs `.md` | **Déménagés** depuis `data/` vers le repo (`./templates/` et `./docs/`) → propagés par `COPY .` du Dockerfile, plus besoin de sync manuel |
| 10 | Crontab prod | **Séparée** de la dev (lignes distinctes pour les deux containers) |
---
## Étape manuelle restante : cutover NPM + Streamlit
L'image prod est buildée, le container `eptm-dashboard-prod-app-1` tourne sur
`proxy_net:3002`. Pour basculer `dashboard.eptm-automation.ch` :
### 1. Reconfigurer NPM (UI sur `https://npm.eptm-automation.ch:81`)
Sur le **proxy host** existant `dashboard.eptm-automation.ch` (qui pointe
actuellement sur Streamlit `172.17.0.1:8501`) :
- **Forward Hostname / IP** : `eptm-dashboard-prod-app-1`
- **Forward Port** : `3002`
- **Cache Assets** : décoché (Reflex bundle Vite est déjà optimisé)
- **Block Common Exploits** : OK
- **Websockets Support** : ✅ **coché** (indispensable — Reflex utilise WS)
- **Onglet SSL** : conserver le certificat Let's Encrypt déjà émis, Force SSL
ON, HSTS ON, HTTP/2 ON
> ⚠️ Pas besoin de custom location `/_event` cette fois (à la différence du
> dev) : en prod le backend et le frontend sont sur le même port (3002).
### 2. Stopper Streamlit
```bash
sudo systemctl stop absences
sudo systemctl disable absences # rollback toujours possible avec `enable + start`
```
### 3. Vérifier
```bash
curl -fsS -o /dev/null -w "HTTP %{http_code}\n" https://dashboard.eptm-automation.ch/
# → 200 attendu, server: granian dans les headers
```
### 4. Ajouter une ligne crontab pour les cron_tick prod
```bash
crontab -e
# Ajouter :
* * * * * docker exec eptm-dashboard-prod-app-1 python /app/scripts/cron_tick.py >> /var/log/cron-prod.log 2>&1
```
(La ligne dev existante reste — les deux containers exécutent leur propre
cron_tick avec leur propre table `cron_jobs`.)
---
## Fichiers créés (déjà en place)
### 1. `Dockerfile.prod` (multi-stage)
```dockerfile
# Stage 1 : builder — installe deps + export frontend
FROM python:3.13-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
nodejs npm curl unzip && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN reflex export --frontend-only --no-zip
# Stage 2 : runtime — backend granian uniquement
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app /app
RUN pip install --no-cache-dir -r requirements.txt
ENV FRONTEND_PORT=3002 BACKEND_PORT=8002
EXPOSE 3002 8002
CMD ["reflex", "run", "--env", "prod"]
```
> NB : à vérifier — Reflex 0.9.x peut exiger `reflex run --backend-only` côté
> runtime si le frontend export est servi par le même process. **TODO :
> tester avec un build pilote avant cutover.**
### 2. `docker-compose.prod.yml`
```yaml
services:
app:
build:
context: .
dockerfile: Dockerfile.prod
image: eptm-dashboard-prod
container_name: eptm-dashboard-prod-app-1
init: true
restart: unless-stopped
volumes:
- ./data-prod:/app/data # ← séparé de dev (cf. Q1)
- ./logs-prod:/logs
env_file:
- .env.prod
environment:
- FRONTEND_PORT=3002
- BACKEND_PORT=8002
- API_URL=https://dashboard.eptm-automation.ch
- REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data
- TZ=Europe/Zurich
networks:
- default
- proxy_net
networks:
proxy_net:
external: true
```
### 3. `.env.prod`
À auditer — copier les valeurs sensibles depuis le `settings.json` plutôt
que de tout mettre en env (séparation cleane). Variables minimales :
- `DATA_DIR=/app/data` (déjà override probable)
- `(à voir avec l'user — TZ, secrets, etc.)`
---
## Procédure de cutover (jour J)
### J1 (préparation)
1. **Backup DB Streamlit** (si pertinent — vérifier si Streamlit a sa propre DB) :
```bash
sudo cp /opt/absences/data /opt/backups/absences-$(date +%F).tgz # à adapter
```
2. **Backup DB Reflex dev** (qui deviendra la prod) :
```bash
cp /opt/eptm-dashboard/data/eptm.db /opt/backups/eptm-pre-cutover-$(date +%F).db
```
3. Créer `Dockerfile.prod`, `docker-compose.prod.yml`, `.env.prod` (cf. ci-dessus).
4. Préparer `./data-prod/` (copie de `./data/` au moment opportun).
### J0 (cutover, ~10 min de fenêtre)
```bash
# 1. Build l'image prod (peut être fait avant la fenêtre — pas de downtime)
cd /opt/eptm-dashboard
docker compose -f docker-compose.prod.yml build app
# 2. Snapshot DB de dev → data-prod
cp -r data/ data-prod/
# (optionnel : purger data-prod/browser_profile et data-prod/pdfs si volumineux
# et resync-able depuis Escada)
# 3. Démarrer le container prod (encore inaccessible — NPM pointe encore sur Streamlit)
docker compose -f docker-compose.prod.yml up -d app
docker logs -f eptm-dashboard-prod-app-1 # vérifier "App running" puis Ctrl-C
# 4. Stopper Streamlit
sudo systemctl stop absences
sudo systemctl disable absences
# 5. Reconfigurer NPM proxy host #2 (dashboard.eptm-automation.ch)
# via UI https://npm.eptm-automation.ch :
# - Forward Hostname / IP : eptm-dashboard-prod-app-1
# - Forward Port : 3002
# - WebSocket support : ON
# - Custom location :
# Location : /_event
# Forward : eptm-dashboard-prod-app-1:8002
# Advanced : proxy_read_timeout 86400;
# - SSL : conserver le certificat Let's Encrypt déjà émis pour
# ce domaine, Force SSL, HSTS, HTTP/2
# 6. Recharger NPM si l'UI ne le fait pas auto :
docker exec npm nginx -s reload
# 7. Vérification
curl -I https://dashboard.eptm-automation.ch
# → doit retourner 200 + headers Reflex (Server: granian)
```
### J+1 → J+30 (stabilisation)
- Garder `absences.service` en `disabled` (rollback rapide possible).
- Surveiller `docker logs -f eptm-dashboard-prod-app-1` + `data-prod/logs/`.
- Si stable après ~1 mois : purger `/opt/absences/`, retirer le user systemd file.
---
## Rollback (si quelque chose plante après le cutover)
```bash
# 1. Rebasculer NPM sur Streamlit
# UI NPM → proxy host #2 → Forward : 172.17.0.1:8501
# 2. Relancer Streamlit
sudo systemctl start absences
# 3. Stopper le container prod Reflex
docker compose -f docker-compose.prod.yml down
# 4. Investiguer les logs Reflex tranquillement
docker logs eptm-dashboard-prod-app-1 > /tmp/cutover-fail.log
```
---
## Workflow déploiements suivants
Une fois la prod en place :
```bash
# 1. Dev : commit & push
git add -A && git commit -m "feat: xxx" && git push
# 2. Build nouvelle image prod
cd /opt/eptm-dashboard
docker compose -f docker-compose.prod.yml build app
# 3. Redémarrer le container (downtime ~10s)
docker compose -f docker-compose.prod.yml up -d app
# 4. Vérifier
docker logs -f eptm-dashboard-prod-app-1
curl -I https://dashboard.eptm-automation.ch
```
Optionnel : tagger les versions prod (`git tag -a prod-2026-05-15 && git push --tags`).
---
## Caveats / pièges à surveiller
- **WebSocket NPM** : déjà OK en dev (cf. memory NPM). Reproduire **exactement**
la même config sur proxy host #2 : WS support cocheé + custom location
`/_event` avec `proxy_read_timeout 86400`. Sans ça, le state Reflex ne
fonctionne pas.
- **`API_URL`** : doit matcher l'URL publique exacte (`https://dashboard.eptm-automation.ch`),
sinon le frontend ne joint pas le backend.
- **Ports internes prod** : `3002`/`8002` (pas `3001`/`8001` qui sont pris par dev).
- **`reflex export`** prend 1-5 min (npm install + bundle). À faire **avant**
la fenêtre de cutover.
- **`data-prod/browser_profile/`** : éviter de copier — le profil Chrome
contient une session SSO valide pour le compte de dev. La prod doit re-login
au premier sync Escada (ouvrir un Chromium visible avec `headless=False`
une fois, ou pré-importer le profile sur un compte service dédié).
- **localStorage des users connectés** : le LocalStorage du browser des
utilisateurs survit au cutover (clés `username`, `theme`, etc.) — ils ne
seront pas déconnectés.
- **DNS / certs** : `dashboard.eptm-automation.ch` pointe déjà sur l'IP
publique du serveur, le cert NPM est déjà émis et auto-renouvelé. Pas
d'action DNS/cert nécessaire.
- **Backups DB** : ajouter un cron quotidien après cutover :
```cron
0 3 * * * cp /opt/eptm-dashboard/data-prod/eptm.db /opt/backups/eptm-$(date +\%F).db
```
---
## Annexe : commandes de check post-déploiement
```bash
# Container prod up ?
docker ps --filter name=eptm-dashboard-prod-app-1
# Logs récents
docker logs --tail 50 eptm-dashboard-prod-app-1
# Proxy NPM répond ?
curl -fsS -o /dev/null -w "HTTP %{http_code}\n" https://dashboard.eptm-automation.ch/login
# WS handshake OK ?
curl -fsS -o /dev/null -w "HTTP %{http_code}\n" \
-H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://dashboard.eptm-automation.ch/_event
# → attend HTTP 101 (Switching Protocols)
```