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

12 KiB
Raw Blame History

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

sudo systemctl stop absences
sudo systemctl disable absences   # rollback toujours possible avec `enable + start`

3. Vérifier

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

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)

# 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

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) :
    sudo cp /opt/absences/data /opt/backups/absences-$(date +%F).tgz   # à adapter
    
  2. Backup DB Reflex dev (qui deviendra la prod) :
    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)

# 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)

# 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 :

# 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 :
    0 3 * * * cp /opt/eptm-dashboard/data-prod/eptm.db /opt/backups/eptm-$(date +\%F).db
    

Annexe : commandes de check post-déploiement

# 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)