Compare commits

..
Author SHA1 Message Date
Johan LEROY ebf6476fb6 docs(infra): rappelle que data/raw n'est pas dans git au provisionnement
Le DAG `historical_import` arrivé par #138 lit `./data/raw`, monté en lecture seule dans
le scheduler. Le dossier est vide dans un clone : sans dépôt manuel des fichiers, le DAG
n'a rien à charger sur la VM.
2026-09-22 08:22:07 +02:00
Johan LEROY d2ca2644d2 Merge remote-tracking branch 'origin/dev' into feat/deploy-rec-prod
# Conflicts:
#	docs/architecture/00-vue-ensemble.md
2026-09-22 08:21:59 +02:00
Meryemel-ghamandGitHub 91a1bfda33 Merge pull request #138 from ineszang/feat/dag-historical-import
Orchestre l'import historique avec Airflow
2026-09-21 16:34:06 +02:00
Meryemel-gham fe100653d0 fix(etl): traite les retours de revue du DAG historique
Airflow / Lint et intégrité des DAGs (push) Successful in 57s
Airflow / Construction de l'image (push) Successful in 2m52s
SonarQube / build-back (push) Successful in 1m17s
SonarQube / test-ml (push) Failing after 1m50s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 55s
SonarQube / test-front (push) Failing after 5m3s
SonarQube / SonarQube (push) Skipped
2026-09-21 16:05:55 +02:00
Johan LEROY bccf7ed774 fix(infra,ci): le déploiement migre la base, et la doc cesse de dire déployé
`make stack-up` enchaîne `alembic upgrade head` dans le conteneur backend. Rien ne
migrait la base sur le chemin de déploiement, et `/api/v1/health/ready`, qui ne teste
que la connexion et l'extension TimescaleDB, aurait laissé passer un déploiement vert
sur une base sans schéma applicatif.

deploy.yml borne le job à 30 minutes et sort les journaux du backend et du proxy quand
la sonde échoue. provision-host.sh rappelle la création du premier administrateur, et
la propriété de /srv/enervision sans laquelle le runner ne peut ni manipuler les clones
ni lire un `.env` en 600.

Les statuts de livraison continue repassent à `En cours` : le code est écrit, la machine
n'est pas provisionnée, le runner n'est pas enregistré, rien n'a été déployé. À basculer
sur `Fait` au premier déploiement vert. Décompte des jobs corrigé, 18 et non 17.

Refs #21, #22
2026-09-21 15:58:03 +02:00
Johan LEROY bf2b66e4ad feat(dx): make dev démarre la base, Mailpit et Airflow puis peuple la base
`make dev` ne lançait que le backend et le frontend : la base, Mailpit et
Airflow restaient à démarrer à la main, et les tables `prediction`, `alert` et
`recommendation` vides laissaient les vues correspondantes sans rien à afficher.

- `services-up` démarre les conteneurs, `db-wait` attend la base.
- `db-ensure-airflow` crée la base de métadonnées Airflow quand le volume
  `pgdata` est antérieur à `db/init/120-airflow-database.sql` : `db/init` ne
  rejoue qu'à la première initialisation, et `airflow-init` bouclait dessus.
- `demo-data` renseigne prédictions, alertes et recommandations si elles
  manquent, en ancrant scoring et détection au 31/12/2024 (`DEMO_NOW`), fin du
  jeu historique, plutôt qu'à l'horloge réelle.
- `ml-score` accepte `NOW=`, les cibles hors conteneur reçoivent
  `ML_DATABASE_URL` dérivé du `.env`.
2026-09-21 15:42:25 +02:00
Johan LEROY 8ea388ce9a fix(backend): découpe l'insert des alertes en lots de 1000
`AlertRepository.create_many` envoyait un `INSERT` d'un seul tenant. À douze
colonnes par alerte, le plafond asyncpg de 32 767 paramètres tombe à 2 730
lignes : une détection sur une fenêtre chargée échouait en `InterfaceError`,
et le DAG `alertes` avec elle.

Reprend le patron déjà en place dans `RecommendationRepository.create_missing`.
2026-09-21 15:42:16 +02:00
Johan LEROYandGitHub b4da0bbefc Merge pull request #137 from ineszang/feat/vue-recommandations
feat(frontend): vue recommandations liée à une alerte ou au site consulté
2026-09-21 15:23:14 +02:00
Johan LEROYandGitHub 25e0bb4ce1 Merge pull request #136 from ineszang/feat/widget-alertes
feat(frontend): flux d'alertes filtrable par site et sévérité, refonte du tableau de bord
2026-09-21 15:22:42 +02:00
Johan LEROYandClaude Opus 5 ce3d1d1992 fix(frontend): rend .ev-table-card plus spécifique que le padding de ev-card
Frontend / Audit des dépendances (push) Successful in 5s
SonarQube / build-back (push) Successful in 1m6s
SonarQube / test-ml (push) Failing after 1m10s
SonarQube / build-front (push) Successful in 9m37s
Frontend / build (push) Successful in 23m57s
SonarQube / test-back (push) Failing after 49s
SonarQube / test-front (push) Failing after 5m22s
SonarQube / SonarQube (push) Skipped
Frontend / test (push) Failing after 5m3s
`.ev-table-card` et le `:host` de `ev-card`, compilé en `[_nghost-…]`, ont la
même spécificité. Les styles de composant sont injectés dans le head après la
feuille globale : le padding de la carte gagnait, et le tableau de la liste des
sites comme celui des prévisions perdaient leur mise à plat. Le sélecteur
d'élément `ev-card.ev-table-card` tranche, comme `_auth-page.scss` le fait déjà.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:10:01 +02:00
Johan LEROY cb23895026 feat(infra,ci): deux environnements rec et prod sur la VM ENI, déployés par un runner auto-hébergé
Un projet Compose par environnement sur la même machine : ports du proxy et origine
publique en variables dans docker-compose.prod.yml, réglage mémoire des deux bases
TimescaleDB et du webserver Airflow. Le workflow deploy.yml déploie dev en recette et
main en production depuis un runner installé sur la VM, jamais sur pull_request.
scripts/provision-host.sh prépare les deux dossiers, secrets et certificats compris,
sans rien démarrer.

L'image frontend quitte dhi.io/nginx, registre authentifié dont personne n'a l'accès,
pour nginx:1.28-alpine : elle n'avait jamais été construite.

ADR 0009, vues infra et CI/CD, README et .env.example mis à jour.

Refs #21, #22
2026-09-21 14:59:32 +02:00
Johan LEROY 2c86e00629 style(frontend): ramène deux commentaires du dashboard à deux lignes 2026-09-21 14:44:23 +02:00
Meryemel-gham cb4df846cb docs(etl): documente l'orchestration de l'import historique 2026-09-21 14:41:38 +02:00
Meryemel-gham ae4082c584 ci(etl): valide l'import historique dans l'image Airflow 2026-09-21 14:41:26 +02:00
Meryemel-gham f18d4f9ef9 test(etl): couvre le DAG d'import historique 2026-09-21 14:41:08 +02:00
Meryemel-gham 308769b325 feat(etl): orchestre l'import historique avec Airflow 2026-09-21 14:40:21 +02:00
Johan LEROY f0ff953a5e docs(frontend): met à jour l'état du frontend et le design système
30-frontend.md : le mode fixtures est inactif dans les deux environnements
(la doc affirmait l'inverse), services et pages manquants, widget alertes et
états de chargement, strict activé. 32-design-systeme-frontend.md : tokens
typographiques, ev-icon, app-alert-feed, classes de tableau.
2026-09-21 14:35:30 +02:00
Johan LEROY 7fd8d1ce30 fix(frontend): restaure useMockFixtures à sa valeur d'origine après chaque test
L'afterEach forçait le drapeau à true alors qu'il vaut false dans les deux
environnements : avec isolate désactivé, tout spec joué ensuite sur
/stats/summary ou /alerts aurait reçu une fixture au lieu d'atteindre
HttpTestingController.
2026-09-21 14:35:30 +02:00
Johan LEROY 6785d06a9a refactor(frontend): modernise la mise en page du tableau de bord
- ligne d'état « Actualisé à HH:mm:ss · N sites suivis » avec un point animé,
  figé sous prefers-reduced-motion
- indicateurs en cartes : libellé en capitales, grand nombre en chiffres
  tabulaires, filet supérieur, ombre au survol ; la barre de charge moyenne
  passe au jaune à 70 % et au rouge à 90 %, avec un texte d'aide
- grille à deux colonnes : graphique de charge et prévisions à gauche, flux
  d'alertes en colonne collante à droite, une colonne sous 900 px
- prévisions présentées en tableau (site, prévision, échéance, lien détail)
- liens de navigation de l'en-tête restylés en pastilles, par SCSS seulement :
  le bloc HTML de navigation est inchangé octet pour octet
- styles de tableau sortis dans _tables.scss (.ev-table, .ev-table-card), la
  liste des sites les adopte au lieu de sa copie locale
2026-09-21 14:35:30 +02:00
Johan LEROY c2bd1317ed refactor(frontend): remplace la section alertes du dashboard par app-alert-feed
Le dashboard ne charge plus les alertes lui-même : le widget porte le flux, ses
filtres, ses états et son rafraîchissement. Disparaissent la liste rouge quelle
que soit la sévérité, le bandeau d'erreur dédié et la copie locale de la table
sévérité vers ton, désormais dans alert-presentation.ts. Le spec passe par un
helper setup() qui fournit aussi les services du widget enfant.
2026-09-21 14:32:07 +02:00
Johan LEROY d276070ee2 feat(frontend): widget app-alert-feed filtrable, rafraîchi toutes les 60 s
Flux des alertes actives sur GET /alerts : filtres site et sévérité posés en
paramètres de requête (l'API les accepte), couleur et badge par sévérité, icône
par type, nom du site résolu depuis GET /sites, mesure et seuil avec leur unité.
Rafraîchissement par timer(0, 60 s) relancé à chaque changement de filtre, états
chargement / vide / indisponible, pagination côté client par dix car l'API ne
pagine pas. L'input `siteId` fige le site et masque son filtre, pour la vue
détail d'un site.
2026-09-21 14:29:22 +02:00
Johan LEROY e10ab44dc7 feat(frontend): composant ev-icon en SVG inline pour les types d'alerte
Cinq tracés (spike, threshold, anomaly, outage, sensor) dessinés en trait sur
currentColor, dimensionnés par font-size comme ev-brand. Décoratif par défaut
(aria-hidden), rôle image et libellé accessible quand `label` est fourni.
Aucune bibliothèque : la CSP du reverse proxy interdit les scripts tiers, pas le
SVG inline.
2026-09-21 14:29:22 +02:00
Johan LEROY 32dd0587e9 feat(frontend): ajoute l'échelle typographique et l'ombre de survol aux tokens
Six tailles de police (--font-size-xs à --font-size-2xl) et --shadow-card-hover,
pour que les nouveaux composants cessent de poser des rem en dur.
2026-09-21 14:29:22 +02:00
40 changed files with 1845 additions and 472 deletions
+18
View File
@@ -53,3 +53,21 @@ AIRFLOW_APP_SECRET_KEY=change_me
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
PUBLIC_HOST=enervision.local
ACME_EMAIL=
# Deux environnements sur la même machine (ADR 0009) : un dossier, un `.env` et un projet Compose
# chacun. Le nom de projet préfixe volumes, réseau et conteneurs et l'emporte sur `name:`.
# Vide sur un poste de développement : le projet reste `enervision`.
COMPOSE_PROJECT_NAME=
# Origine publique, avec le port si le proxy HTTPS n'écoute pas 443. Vide : https://PUBLIC_HOST.
# Recette : PUBLIC_HOST=rec.enervision.local et PUBLIC_ORIGIN=https://rec.enervision.local:8443.
PUBLIC_ORIGIN=
# Ports publiés par le proxy. Vides : 80 et 443. Recette : PROXY_HTTPS_PORT=8443 et
# PROXY_HTTP_PORT=127.0.0.1:8081, la redirection vers 443 n'ayant pas à être joignable de
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
PROXY_HTTP_PORT=
PROXY_HTTPS_PORT=
# Réglages mémoire de la stack déployée. Sans eux, timescaledb-tune réserve 25 % de la RAM de la
# machine à chaque base au premier démarrage, et le webserver Airflow lance 4 workers gunicorn.
TS_TUNE_MEMORY=2GB
TS_TUNE_NUM_CPUS=2
AIRFLOW_WEBSERVER_WORKERS=2
+5 -4
View File
@@ -91,11 +91,12 @@ jobs:
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
# l'import du module prouve que l'environnement /opt/backend est complet. Les deux
# commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui.
- name: Vérifie que les deux commandes du DAG alertes s'importent sans réseau
# l'import des modules prouve que l'environnement /opt/backend est complet.
# Les deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes.
- name: Vérifie que les trois commandes backend s'importent sans réseau
run: >
docker run --rm --network none enervision-airflow:ci
bash -c "cd /opt/backend
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help"
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help"
+57
View File
@@ -0,0 +1,57 @@
# Pourquoi : le runner tourne sur la VM ENI, adresse privée que les runners hébergés par GitHub
# ne joignent pas, et travaille dans un dossier stable par environnement plutôt que dans son
# espace de travail : `.env`, certificats et volumes y survivent d'un déploiement à l'autre.
# Piège : jamais de déclencheur `pull_request` ici. Sur un dépôt public, une PR de fork
# exécuterait son code sur la machine de production (ADR 0009) - job deploy.
name: Déploiement
on:
push:
branches: [dev, main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: false
jobs:
deploy:
runs-on: [self-hosted, linux, eni-g3]
timeout-minutes: 30
environment:
name: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
url: ${{ github.ref_name == 'main' && 'https://enervision.local' || 'https://rec.enervision.local:8443' }}
env:
ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
PORT_HTTPS: ${{ github.ref_name == 'main' && '443' || '8443' }}
steps:
- name: Aligner le dossier de l'environnement sur la branche poussée
run: |
cd "/srv/enervision/${ENVIRONNEMENT}"
git fetch --quiet origin "${GITHUB_REF_NAME}"
git checkout --quiet "${GITHUB_REF_NAME}"
git reset --quiet --hard "origin/${GITHUB_REF_NAME}"
git log -1 --format='%h %s'
- name: Reconstruire et redémarrer la stack
run: |
cd "/srv/enervision/${ENVIRONNEMENT}"
make stack-up
- name: Attendre que l'API réponde derrière le proxy
run: |
for tentative in $(seq 1 36); do
if curl --fail --silent --insecure "https://localhost:${PORT_HTTPS}/api/v1/health/ready"; then
exit 0
fi
sleep 5
done
echo "L'API ne répond pas après 3 minutes" >&2
cd "/srv/enervision/${ENVIRONNEMENT}"
docker compose ps
docker compose logs --tail=50 backend proxy
exit 1
+61 -6
View File
@@ -13,11 +13,28 @@ ifdef ACME_EMAIL
export ACME_EMAIL
endif
# Piege : make ne lit pas `.env`, que seul docker compose interpole. Les cibles hors conteneur
# (ml-*, demo-data, db-wait) joignent la base par le port publie et ont besoin de ces valeurs.
env-val = $(shell sed -n 's/^$(1)=//p' .env 2>/dev/null | tail -1)
PG_USER := $(or $(strip $(call env-val,POSTGRES_USER)),enervision)
PG_PASSWORD := $(or $(strip $(call env-val,POSTGRES_PASSWORD)),change_me)
PG_DB := $(or $(strip $(call env-val,POSTGRES_DB)),enervision)
PG_PORT := $(or $(strip $(call env-val,POSTGRES_PORT)),5433)
AIRFLOW_PORT := $(or $(strip $(call env-val,AIRFLOW_PORT)),8080)
MAILPIT_UI_PORT := $(or $(strip $(call env-val,MAILPIT_UI_PORT)),8025)
ML_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_DB)
export ML_DATABASE_URL
# Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne
# verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score.
DEMO_NOW ?= 2024-12-31T00:00:00Z
.DEFAULT_GOAL := help
.PHONY: help install install-backend install-frontend install-ml install-airflow \
dev dev-backend dev-frontend \
lint format typecheck test test-cov test-integration check \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \
migrate bootstrap-admin services-up demo-data demo-data-force \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
@@ -39,12 +56,19 @@ install-ml: ## Installe les dépendances du pipeline ML
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
cd $(AIRFLOW) && uv sync --all-groups
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airflow, puis backend et frontend
@echo "airflow -> http://localhost:$(AIRFLOW_PORT) mailpit -> http://localhost:$(MAILPIT_UI_PORT)"
@trap 'kill 0' EXIT INT TERM; \
$(MAKE) --no-print-directory dev-backend & \
$(MAKE) --no-print-directory dev-frontend & \
wait
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow)
docker compose up -d db mailpit
@$(MAKE) --no-print-directory db-wait
@$(MAKE) --no-print-directory db-ensure-airflow
docker compose up -d airflow-init airflow-webserver airflow-scheduler
dev-backend: ## Lance l'API seule en rechargement à chaud
@echo "backend -> http://localhost:8000 (docs sur /docs)"
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
@@ -91,8 +115,8 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV= et NOW= optionnels
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) $(if $(NOW),--now $(NOW),)
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
@@ -108,7 +132,7 @@ airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structur
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
airflow-up: db-ensure-airflow ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
docker compose up -d airflow-init airflow-webserver airflow-scheduler
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
@@ -124,12 +148,15 @@ docker-build: ## Construit l'image du backend
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin
# Piège : l'image backend ne migre pas au démarrage, et `/health/ready` ne teste que la connexion
# et l'extension. Sans `alembic upgrade head`, la stack démarre verte sur une base sans schéma.
stack-up: ## Démarre la stack derrière le reverse proxy, puis migre la base. PUBLIC_HOST=... au besoin
@test -f infra/proxy/tls/fullchain.pem \
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
$(COMPOSE_PROD) up -d --build
$(COMPOSE_PROD) exec -T backend alembic upgrade head
stack-down: ## Arrête la stack complète en conservant les données
$(COMPOSE_PROD) stop
@@ -165,8 +192,36 @@ db-logs: ## Suit les journaux de la base
db-psql: ## Ouvre une session psql sur la base applicative
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision}
db-wait: ## Attend que la base accepte les connexions
@for _ in $$(seq 1 60); do \
docker compose exec -T db pg_isready -U $(PG_USER) -d $(PG_DB) >/dev/null 2>&1 && exit 0; \
sleep 1; \
done; \
echo "La base n'accepte toujours pas de connexion apres 60s"; exit 1
# Piege : db/init ne rejoue qu'a la premiere initialisation du volume. Un `pgdata` cree avant
# db/init/120-airflow-database.sql n'a pas de base `airflow`, et airflow-init boucle dessus.
db-ensure-airflow: ## Crée la base de métadonnées Airflow si le volume pgdata est antérieur à db/init/120
@docker compose exec -T db psql -U $(PG_USER) -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname = 'airflow'" | grep -q 1 \
|| docker compose exec -T db psql -U $(PG_USER) -d postgres -c "CREATE DATABASE airflow"
migrate: ## Applique les migrations Alembic
cd $(BACKEND) && uv run alembic upgrade head
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier
cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis}
demo-data: ## Renseigne prédictions, alertes et recommandations si elles manquent. NOW= optionnel
@nombre=$$(docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -tAc 'SELECT count(*) FROM alert') \
|| { echo "demo-data : base injoignable ou migrations non appliquees"; exit 1; }; \
if [ "$$nombre" = 0 ]; then \
$(MAKE) --no-print-directory demo-data-force; \
else \
echo "demo-data : $$nombre alerte(s) deja en base (make demo-data-force pour rejouer)"; \
fi
demo-data-force: ## Rejoue le peuplement sans regarder l'existant. Les trois etapes sont idempotentes
$(MAKE) --no-print-directory ml-score NOW=$(DEMO_NOW)
$(MAKE) --no-print-directory detect-alerts NOW=$(DEMO_NOW)
$(MAKE) --no-print-directory recommendations
+45 -12
View File
@@ -21,7 +21,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
| ETL | Apache Airflow | `etl/airflow` | Trois DAGs |
| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
@@ -48,7 +48,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ ├── migrations/ Migrations SQL versionnees
│ └── seeds/ Jeux de donnees de reference
├── etl/airflow/
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes)
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique)
│ ├── plugins/ Operateurs et hooks maison
│ ├── include/ Requetes SQL et ressources des DAGs
│ └── tests/ Tests d'integrite des DAGs
@@ -75,25 +75,52 @@ Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Pyth
cp .env.example .env # variables de docker-compose
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
make install # dependances du backend et du frontend
make migrate # applique les migrations Alembic
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
make install # dependances du backend, du frontend, du ML et des DAGs
make dev # toute la stack, voir ci-dessous
make check # lint + typage + tests
```
`make dev` enchaine tout : demarrage des services conteneurises (base sur le port 5433, Mailpit,
Airflow), migrations Alembic, peuplement de demonstration si les alertes manquent, puis backend
et frontend en rechargement a chaud sur le poste.
| Service | Adresse |
|---|---|
| Backend | <http://localhost:8000> (documentation sur `/docs`) |
| Frontend | <http://localhost:4200> |
| Airflow | <http://localhost:8080> (`AIRFLOW_ADMIN_USERNAME` / `AIRFLOW_ADMIN_PASSWORD` du `.env`) |
| Mailpit | <http://localhost:8025> |
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
`AIRFLOW_WEBSERVER_SECRET_KEY`, `AIRFLOW_APP_SECRET_KEY` et `AIRFLOW_ADMIN_PASSWORD`. Sans elles
`airflow-init` refuse de demarrer, et `airflow-webserver` comme `airflow-scheduler` avec lui.
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
`make airflow-up`, `make dev-backend`, `make dev-frontend`.
`make help` liste les cibles disponibles.
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`,
`apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que
5432, souvent deja pris par une autre base.
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend`
et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend`
du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc
toute modification y demande un `docker compose up -d --build backend`.
Le backend et le frontend tournent sur le poste, lances ensemble par `make dev` (logs
entrelaces dans le meme terminal, Ctrl+C arrete les deux) ; la base, Mailpit et Airflow tournent
en conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
et n'embarque pas le source, donc toute modification y demande un
`docker compose up -d --build backend`.
### Donnees de demonstration
Le jeu historique s'arrete au 31/12/2024. `make demo-data` renseigne les tables que les vues
alertes, recommandations et previsions lisent, en ancrant le scoring et la detection a cette
date (`DEMO_NOW`) plutot qu'a l'horloge reelle, qui ne verrait qu'un parc muet depuis des mois.
La cible ne fait rien si des alertes existent deja ; `make demo-data-force` rejoue les trois
etapes, toutes idempotentes en base.
Un volume `pgdata` cree avant `db/init/120-airflow-database.sql` n'a pas de base `airflow` :
`db/init` ne rejoue qu'a la premiere initialisation. `make db-ensure-airflow`, appelee par
`make dev` et `make airflow-up`, la cree au besoin, sans detruire les donnees applicatives.
Verifier que la base repond et que l'extension est chargee :
@@ -116,6 +143,12 @@ nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvel
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
Sur la VM ENI, deux environnements cohabitent, recette sur `dev` et production sur `main`,
chacun dans son dossier et son projet Compose : `scripts/provision-host.sh` les prépare, le
workflow `deploy.yml` les redéploie à chaque push par un runner auto-hébergé. Ports, noms
d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et
[l'ADR 0009](docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
## Conventions
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
+17 -8
View File
@@ -6,6 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert
# Douze colonnes par alerte, contre quatre pour une recommandation : le plafond asyncpg de
# 32 767 parametres tombe a 2 730 lignes, d'ou un lot plus petit que `recommendation.py`.
TAILLE_DE_LOT = 1000
class AlertRepository:
def __init__(self, session: AsyncSession) -> None:
@@ -44,12 +48,17 @@ class AlertRepository:
}
for alerte in alerts
]
requete = (
insert(Alert)
.values(valeurs)
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
.returning(Alert)
)
resultat = await self._session.execute(requete)
creees: list[Alert] = []
# Piège : asyncpg plafonne une requête à 32 767 paramètres. Une détection sur une fenêtre
# chargée dépasse ce seuil, et l'`INSERT` d'un seul tenant échouerait.
for debut in range(0, len(valeurs), TAILLE_DE_LOT):
requete = (
insert(Alert)
.values(valeurs[debut : debut + TAILLE_DE_LOT])
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
.returning(Alert)
)
resultat = await self._session.execute(requete)
creees.extend(resultat.scalars().all())
await self._session.flush()
return resultat.scalars().all()
return creees
@@ -5,6 +5,7 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert
from app.repositories import alert as module_alert
from app.repositories.alert import AlertRepository
from app.schemas.alert import AlertSeverity
from tests.repositories.test_site import creer as creer_site
@@ -146,3 +147,20 @@ async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession)
creees = await depot.create_many([])
assert creees == []
async def test_create_many_inserts_every_alert_across_several_batches(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(module_alert, "TAILLE_DE_LOT", 2)
site = await creer_site(session)
depot = AlertRepository(session)
a_inserer = [
_alerte_a_inserer(site_id=site.site_id, source_alert_id=f"threshold:lot-{index}")
for index in range(5)
]
creees = await depot.create_many(a_inserer)
await session.rollback()
assert len(creees) == 5
+1 -1
View File
@@ -23,7 +23,7 @@ RUN npm run build
# ==================
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner
FROM nginx:1.28-alpine AS runner
# Copie de la configuration de nginx
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
@@ -8,8 +8,10 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
describe('mockApiInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let useMockFixturesInitial: boolean;
beforeEach(() => {
useMockFixturesInitial = environment.useMockFixtures;
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([mockApiInterceptor])),
@@ -21,7 +23,7 @@ describe('mockApiInterceptor', () => {
});
afterEach(() => {
environment.useMockFixtures = true;
environment.useMockFixtures = useMockFixturesInitial;
httpMock.verify();
});
@@ -28,87 +28,120 @@
@if (statsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (alertsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (predictionsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (stats(); as s) {
<section class="overview">
<ev-card class="card card--gauge">
<span class="card__label">Consommation vs capacité</span>
<p class="dashboard__status">
<span class="dashboard__pulse" aria-hidden="true"></span>
Actualisé à {{ s.timestamp | date: 'HH:mm:ss' }} · {{ s.total_sites }} sites suivis
</p>
<section class="overview" aria-label="Indicateurs du parc">
<ev-card class="kpi kpi--gauge">
<span class="kpi__label">Consommation vs capacité</span>
<app-consumption-gauge
[consumption]="s.total_consumption_kw"
[capacity]="s.total_capacity_kw"
/>
<span class="card__value"
>{{ s.total_consumption_kw | number: '1.0-1' }} /
{{ s.total_capacity_kw | number }} kW</span
<span class="kpi__value">
{{ s.total_consumption_kw | number: '1.0-1' }}
<small>/ {{ s.total_capacity_kw | number }} kW</small>
</span>
</ev-card>
<ev-card class="kpi">
<span class="kpi__label">Charge moyenne du parc</span>
<span class="kpi__value"
>{{ s.average_load_percent | number: '1.0-0' }} <small>%</small></span
>
</ev-card>
<ev-card class="card">
<span class="card__label">Charge moyenne du parc</span>
<span class="card__value">{{ s.average_load_percent }} %</span>
<div class="progress-bar">
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
<div
class="progress-bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="s.average_load_percent"
>
<div
class="progress-bar__fill"
[class]="'progress-bar__fill--' + loadTone(s.average_load_percent)"
[style.width.%]="s.average_load_percent"
></div>
</div>
<span class="kpi__hint">{{ loadHint(s.average_load_percent) }}</span>
</ev-card>
<ev-card class="card">
<span class="card__label">Sites suivis</span>
<span class="card__value">{{ s.total_sites }}</span>
<ev-card class="kpi">
<span class="kpi__label">Sites suivis</span>
<span class="kpi__value">{{ s.total_sites }}</span>
<a routerLink="/sites" class="ev-link kpi__link">Voir la liste des sites</a>
</ev-card>
</section>
<section class="chart-section">
<h2>Charge et alerte visuelle par site</h2>
<app-site-load-chart [sites]="s.sites" />
</section>
}
@if (alerts().length > 0) {
<section class="alerts-section">
<h2>Alertes actives</h2>
<ul class="alerts-list">
@for (alert of alerts(); track alert.alert_id) {
<li class="alert-item">
<ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
<span class="alert-item__message">{{ alert.message }}</span>
</li>
<div class="dashboard__grid">
<div class="dashboard__main">
@if (stats(); as s) {
<section class="chart-section">
<h2>Charge par site</h2>
<ev-card class="chart-card">
<app-site-load-chart [sites]="s.sites" />
</ev-card>
</section>
}
<section class="predictions-section">
<h2>Prévisions de consommation</h2>
@if (predictions().length > 0) {
<ev-card class="ev-table-card">
<table class="ev-table">
<thead>
<tr>
<th>Site</th>
<th>Prévision</th>
<th>Échéance</th>
<th></th>
</tr>
</thead>
<tbody>
@for (site of predictions(); track site.site_id) {
<tr>
<td>{{ site.site_name }}</td>
@if (site.prediction; as prediction) {
@if (prediction.status === 'available') {
<td class="ev-table__number">
{{ prediction.predicted_value | number: '1.0-1' }} kWh
</td>
<td>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</td>
} @else {
<td>
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
prediction.status === 'insufficient_data'
? 'Historique insuffisant'
: 'Erreur'
}}</ev-badge>
</td>
<td class="ev-table__muted">-</td>
}
} @else {
<td><ev-badge tone="neutral">Pas encore de prévision</ev-badge></td>
<td class="ev-table__muted">-</td>
}
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
</tr>
}
</tbody>
</table>
</ev-card>
} @else if (!predictionsError()) {
<p class="dashboard__empty">Aucune prévision disponible pour le moment.</p>
}
</ul>
</section>
}
</section>
</div>
@if (predictions().length > 0) {
<section class="predictions-section">
<h2>Prévisions de consommation</h2>
<ul class="predictions-list">
@for (site of predictions(); track site.site_id) {
<li class="prediction-item">
<span class="prediction-item__site">{{ site.site_name }}</span>
@if (site.prediction; as prediction) {
@if (prediction.status === 'available') {
<span class="prediction-item__value">
{{ prediction.predicted_value | number: '1.0-1' }} kWh
<span class="prediction-item__target"
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
>
</span>
} @else {
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur'
}}</ev-badge>
}
} @else {
<ev-badge tone="neutral">Pas encore de prévision</ev-badge>
}
</li>
}
</ul>
</section>
}
<aside class="dashboard__side" aria-label="Alertes actives">
<app-alert-feed />
</aside>
</div>
</div>
@@ -8,9 +8,11 @@
.dashboard__header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.dashboard__brand {
@@ -20,8 +22,9 @@
h1 {
margin: 0;
font-size: 1.75rem;
font-size: var(--font-size-xl);
font-weight: 700;
letter-spacing: -0.01em;
}
}
@@ -31,19 +34,69 @@
.dashboard__subtitle {
margin: 0.25rem 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.dashboard__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
gap: var(--space-2);
.ev-link {
padding: 0.45rem 0.9rem;
border-radius: var(--radius-pill);
background: var(--color-primary-light);
color: var(--color-primary-hover);
font-size: var(--font-size-sm);
transition: background 0.15s ease;
&:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
text-decoration: none;
}
}
}
.dashboard__status {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0 0 var(--space-4);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.dashboard__pulse {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--color-success);
animation: pulse 2s ease-out infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45);
}
100% {
box-shadow: 0 0 0 8px rgba(22, 163, 74, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard__pulse {
animation: none;
}
}
h2 {
font-size: 1.1rem;
margin: 0 0 var(--space-3);
font-size: var(--font-size-lg);
font-weight: 600;
margin: 0 0 1rem;
}
.banner-error {
@@ -54,116 +107,124 @@ h2 {
.overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin-bottom: 2.5rem;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.card {
padding: 1.25rem;
gap: 0.35rem;
.kpi {
position: relative;
overflow: hidden;
padding: var(--space-4);
gap: var(--space-1);
transition:
box-shadow 0.15s ease,
transform 0.15s ease;
&::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: var(--color-primary);
}
&:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-1px);
}
}
.card--gauge {
.kpi--gauge {
align-items: center;
text-align: center;
}
.card--link {
cursor: pointer;
transition: border-color 0.15s ease;
.kpi__label {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
&:hover {
border-color: var(--color-primary);
.kpi__value {
font-size: var(--font-size-2xl);
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
small {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-muted);
}
}
.card__label {
font-size: 0.8rem;
.kpi__hint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.card__value {
font-size: 1.6rem;
font-weight: 700;
.kpi__link {
margin-top: auto;
font-size: var(--font-size-sm);
}
.progress-bar {
height: 6px;
height: 8px;
margin: var(--space-1) 0;
background: var(--color-border-light);
border-radius: var(--radius-pill);
overflow: hidden;
margin-top: 0.25rem;
}
.progress-bar__fill {
height: 100%;
background: var(--color-primary);
border-radius: var(--radius-pill);
background: var(--color-success);
transition: width 0.3s ease;
}
.progress-bar__fill--warning {
background: var(--color-warning);
}
.progress-bar__fill--danger {
background: var(--color-danger);
}
.dashboard__grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
gap: var(--space-4);
align-items: start;
}
.dashboard__side {
position: sticky;
top: var(--space-3);
}
.chart-section {
margin-bottom: 2.5rem;
margin-bottom: var(--space-5);
}
.alerts-list {
list-style: none;
.chart-card {
padding: var(--space-3);
}
.dashboard__empty {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.alert-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
}
.alert-item__message {
font-size: 0.9rem;
}
.predictions-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prediction-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
}
.prediction-item__site {
font-size: 0.9rem;
font-weight: 600;
}
.prediction-item__value {
font-size: 0.9rem;
font-weight: 600;
}
.prediction-item__target {
margin-left: 0.35rem;
font-size: 0.8rem;
font-weight: 400;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
@media (max-width: 900px) {
.dashboard__grid {
grid-template-columns: 1fr;
}
.dashboard__side {
position: static;
}
}
@@ -1,12 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { of, throwError } from 'rxjs';
import { Observable, of, throwError } from 'rxjs';
import { Router, provideRouter } from '@angular/router';
import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service';
import { SitesService } from '../../core/services/sites.service';
import { PredictionsService } from '../../core/services/predictions.service';
import {AuthService} from '../../core/services/auth.service';
import {Router, provideRouter} from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
vi.mock('chart.js', () => {
class ChartMock {
@@ -18,66 +19,73 @@ vi.mock('chart.js', () => {
return { Chart: ChartMock, registerables: [] };
});
const STATS = { total_sites: 7, sites: [] };
function predictionsMock(sites: unknown[] = []) {
return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
return {
getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })),
};
}
function setup(
options: {
stats?: Observable<unknown>;
predictions?: { getPredictions: ReturnType<typeof vi.fn> };
auth?: Record<string, unknown>;
} = {},
) {
const statsMock = { getSummary: vi.fn().mockReturnValue(options.stats ?? of(STATS)) };
const predictions = options.predictions ?? predictionsMock();
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: { getAlerts: vi.fn().mockReturnValue(of([])) } },
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of([])) } },
{ provide: PredictionsService, useValue: predictions },
...(options.auth ? [{ provide: AuthService, useValue: options.auth }] : []),
provideRouter([]),
],
});
return { fixture: TestBed.createComponent(Dashboard), statsMock, predictions };
}
describe('Dashboard', () => {
afterEach(() => vi.useRealTimers());
it('charge les stats, les alertes et les prévisions au démarrage', async () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
it('charge les stats et les prévisions au démarrage', async () => {
const { fixture, statsMock, predictions } = setup({
predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]),
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
// laisse le timer(0, ...) se déclencher avant de vérifier
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(statsMock.getSummary).toHaveBeenCalled();
expect(alertsMock.getAlerts).toHaveBeenCalled();
expect(predictions.getPredictions).toHaveBeenCalled();
expect(fixture.componentInstance.alerts().length).toBe(1);
expect(fixture.componentInstance.predictions().length).toBe(1);
expect(fixture.componentInstance.statsError()).toBeNull();
expect(fixture.componentInstance.alertsError()).toBeNull();
expect(fixture.componentInstance.predictionsError()).toBeNull();
});
it('délègue les alertes au widget app-alert-feed', () => {
const { fixture } = setup();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-alert-feed')).not.toBeNull();
});
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
vi.useFakeTimers();
const statsMock = {
getSummary: vi
.fn()
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
.mockReturnValue(of({ total_sites: 7, sites: [] })),
};
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
const { fixture, statsMock } = setup({
stats: throwError(() => new Error('API injoignable')),
});
statsMock.getSummary
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
.mockReturnValue(of(STATS));
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
vi.advanceTimersByTime(1);
@@ -91,45 +99,11 @@ describe('Dashboard', () => {
expect(fixture.componentInstance.statsError()).toBeNull();
});
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.alerts().length).toBe(0);
expect(fixture.componentInstance.alertsError()).not.toBeNull();
});
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const predictions = {
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
const { fixture } = setup({
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.predictions().length).toBe(0);
@@ -138,25 +112,11 @@ describe('Dashboard', () => {
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
vi.useFakeTimers();
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const predictions = {
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
const { fixture } = setup({
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
@@ -168,109 +128,106 @@ describe('Dashboard', () => {
});
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = {
logout: vi.fn().mockReturnValue(of(undefined)),
clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.logout).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = {
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock },
provideRouter([]),
],
logout: vi.fn().mockReturnValue(of(undefined)),
clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
};
const { fixture } = setup({ auth: authMock });
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture.nativeElement.querySelector('.logout-button').click();
expect(authMock.logout).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
const authMock = {
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }),
};
const { fixture } = setup({ auth: authMock });
fixture.detectChanges();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture.nativeElement.querySelector('.logout-button').click();
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.clearSession).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
expect(authMock.clearSession).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
it('distingue le ton des sévérités high et critical', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
it('affiche l’heure du dernier relevé et les indicateurs du parc', () => {
vi.useFakeTimers();
const { fixture } = setup({
stats: of({
timestamp: '2026-09-18T09:00:00Z',
total_sites: 7,
total_consumption_kw: 1234.5,
total_capacity_kw: 5000,
average_load_percent: 24.7,
sites: [],
}),
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
vi.advanceTimersByTime(1);
fixture.detectChanges();
const texte = fixture.nativeElement.textContent as string;
expect(texte).toContain('Actualisé à');
expect(texte).toContain('7 sites suivis');
expect(texte).toContain('Marge confortable');
expect(fixture.nativeElement.querySelector('.progress-bar__fill--success')).not.toBeNull();
});
it('présente les prévisions en tableau avec un lien vers chaque site', () => {
const { fixture } = setup({
predictions: predictionsMock([
{
site_id: 'SITE001',
site_name: 'Usine Nantes',
prediction: {
target_at: '2026-09-18T10:00:00Z',
target_metric: 'consumption_kwh',
period_minutes: 60,
predicted_value: 118.4,
status: 'available',
failure_reason: null,
model_reference: 'lightgbm-v1',
created_at: '2026-09-18T09:00:00Z',
},
},
{ site_id: 'SITE002', site_name: 'Bureau Lille', prediction: null },
]),
});
fixture.detectChanges();
const table = fixture.nativeElement.querySelector('table.ev-table');
expect(table).not.toBeNull();
expect(table.textContent).toContain('118.4 kWh');
expect(table.textContent).toContain('Pas encore de prévision');
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE001"]')).not.toBeNull();
});
it('colore la charge moyenne selon les seuils 70 % et 90 %', () => {
const { fixture } = setup();
const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForSeverity('low')).toBe('success');
expect(dashboard.badgeToneForSeverity('medium')).toBe('warning');
expect(dashboard.badgeToneForSeverity('high')).toBe('danger');
expect(dashboard.badgeToneForSeverity('critical')).toBe('critical');
expect(dashboard.badgeToneForSeverity('high')).not.toBe(
dashboard.badgeToneForSeverity('critical'),
);
expect(dashboard.loadTone(69.9)).toBe('success');
expect(dashboard.loadTone(70)).toBe('warning');
expect(dashboard.loadTone(89.9)).toBe('warning');
expect(dashboard.loadTone(90)).toBe('danger');
expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc');
});
it('distingue le ton des statuts de prévision', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
const { fixture } = setup();
const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
@@ -6,11 +6,10 @@ import { Router, RouterLink } from '@angular/router';
import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
import { AlertsService } from '../../core/services/alerts.service';
import { AlertFeed } from '../../shared/components/alert-feed/alert-feed';
import { PredictionsService } from '../../core/services/predictions.service';
import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
import { Card } from '../../shared/components/ui/card/card';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
@@ -22,22 +21,19 @@ const REFRESH_INTERVAL_MS = 10000;
const UNAVAILABLE_MESSAGE =
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
low: 'success',
medium: 'warning',
high: 'danger',
critical: 'critical',
};
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
// tomber sur `undefined` si ce statut apparaît un jour.
// `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` :
// mieux vaut un ton défini que `undefined` le jour où ce statut apparaît.
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
available: 'success',
insufficient_data: 'warning',
error: 'danger',
};
const SEUIL_CHARGE_SOUTENUE = 70;
const SEUIL_CHARGE_CRITIQUE = 90;
export type LoadTone = 'success' | 'warning' | 'danger';
@Component({
selector: 'app-dashboard',
standalone: true,
@@ -47,6 +43,7 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
RouterLink,
ConsumptionGauge,
SiteLoadChart,
AlertFeed,
Card,
EvAlert,
Badge,
@@ -58,32 +55,20 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
})
export class Dashboard implements OnInit {
private statsService = inject(StatsService);
private alertsService = inject(AlertsService);
public auth = inject(AuthService);
private predictionsService = inject(PredictionsService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
stats = signal<StatsSummary | null>(null);
alerts = signal<Alert[]>([]);
predictions = signal<SitePredictionSummary[]>([]);
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
// Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en
// silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur.
statsError = signal<string | null>(null);
alertsError = signal<string | null>(null);
predictionsError = signal<string | null>(null);
ngOnInit(): void {
this.alertsService
.getAlerts()
.pipe(catchError(() => this.reportUnavailable(this.alertsError)))
.subscribe((alerts) => {
this.alertsError.set(null);
this.alerts.set(alerts);
});
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
this.predictionsService
@@ -99,7 +84,9 @@ export class Dashboard implements OnInit {
timer(0, REFRESH_INTERVAL_MS)
.pipe(
switchMap(() =>
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
this.statsService
.getSummary()
.pipe(catchError(() => this.reportUnavailable(this.statsError))),
),
takeUntilDestroyed(this.destroyRef),
)
@@ -109,14 +96,28 @@ export class Dashboard implements OnInit {
});
}
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
return TON_PAR_SEVERITE[severity];
}
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
return TON_PAR_STATUT_PREDICTION[status];
}
loadTone(percent: number): LoadTone {
if (percent >= SEUIL_CHARGE_CRITIQUE) {
return 'danger';
}
return percent >= SEUIL_CHARGE_SOUTENUE ? 'warning' : 'success';
}
loadHint(percent: number): string {
switch (this.loadTone(percent)) {
case 'danger':
return 'Proche de la capacité du parc';
case 'warning':
return 'Charge soutenue';
default:
return 'Marge confortable';
}
}
onLogout(): void {
this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']),
@@ -17,8 +17,8 @@
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
<ev-card class="table-card">
<table class="sites-table">
<ev-card class="ev-table-card">
<table class="ev-table">
<thead>
<tr>
<th>Nom</th>
@@ -36,7 +36,9 @@
<td>{{ site.site_type }}</td>
<td>{{ site.location || '-' }}</td>
<td>{{ site.capacity_kw ?? '-' }}</td>
<td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td>
<td>
<ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge>
</td>
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
</tr>
}
@@ -32,32 +32,3 @@
display: block;
margin: 0 0 1.5rem;
}
.table-card {
padding: 0;
overflow: hidden;
}
.sites-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.85rem 1.25rem;
text-align: left;
border-bottom: 1px solid var(--color-border-light);
}
th {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
font-weight: 600;
}
tr:last-child td {
border-bottom: none;
}
}
@@ -0,0 +1,95 @@
<section class="alert-feed">
<header class="alert-feed__header">
<div>
<h2 class="alert-feed__title">Alertes actives</h2>
@if (!loading() || alerts().length > 0) {
<p class="alert-feed__count">
{{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }}
</p>
}
</div>
<div class="alert-feed__filters">
@if (!siteId()) {
<label class="alert-feed__filter">
<span class="form-label">Site</span>
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
<option value="">Tous les sites</option>
@for (site of sites(); track site.site_id) {
<option [value]="site.site_id">{{ site.site_name }}</option>
}
</select>
</label>
}
<label class="alert-feed__filter">
<span class="form-label">Sévérité</span>
<select
class="form-select"
data-testid="severity-filter"
(change)="onSeverityChange($event)"
>
<option value="">Toutes</option>
@for (severite of severites; track severite) {
<option [value]="severite">{{ severityLabel(severite) }}</option>
}
</select>
</label>
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="alert-feed__banner">{{ message }}</ev-alert>
}
@if (loading() && alerts().length === 0 && !error()) {
<p class="alert-feed__state" aria-live="polite">Chargement des alertes…</p>
} @else if (alerts().length === 0 && !error()) {
<ev-alert severity="success" class="alert-feed__banner"
>Aucune alerte pour ces critères.</ev-alert
>
}
<ul class="alert-feed__list" [attr.aria-busy]="loading()">
@for (alert of visibleAlerts(); track alert.alert_id) {
<li class="alert-feed__item" [class]="'alert-feed__item--' + alert.severity">
<span class="alert-feed__icon">
<ev-icon [name]="alert.type" [label]="typeLabel(alert.type)" />
</span>
<div class="alert-feed__body">
<div class="alert-feed__meta">
<ev-badge [tone]="toneFor(alert.severity)">{{
severityLabel(alert.severity)
}}</ev-badge>
<span class="alert-feed__type">{{ typeLabel(alert.type) }}</span>
<span class="alert-feed__site">{{ siteName(alert.site_id) }}</span>
<time class="alert-feed__time" [attr.datetime]="alert.timestamp">
{{ alert.timestamp | date: 'dd/MM/yyyy HH:mm' }}
</time>
</div>
<p class="alert-feed__message">{{ alert.message }}</p>
@if (alert.value !== null) {
<p class="alert-feed__values">
<strong>{{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }}</strong>
@if (alert.threshold !== null) {
<span
>seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }}</span
>
}
</p>
}
</div>
</li>
}
</ul>
@if (hiddenCount() > 0) {
<ev-button
variant="secondary"
[fullWidth]="false"
class="alert-feed__more"
data-testid="show-more"
(click)="showMore()"
>
Afficher plus ({{ hiddenCount() }} restantes)
</ev-button>
}
</section>
@@ -0,0 +1,154 @@
:host {
display: block;
}
.alert-feed__header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.alert-feed__title {
margin: 0;
font-size: var(--font-size-lg);
font-weight: 600;
}
.alert-feed__count {
margin: 0.15rem 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.alert-feed__filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.alert-feed__filter {
display: flex;
flex-direction: column;
min-width: 10rem;
.form-label {
margin-top: 0;
}
}
.alert-feed__banner {
display: block;
margin-bottom: var(--space-3);
}
.alert-feed__state {
margin: 0 0 var(--space-3);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.alert-feed__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.alert-feed__item {
display: flex;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-left: 4px solid var(--color-border);
}
.alert-feed__item--medium {
border-left-color: var(--color-warning);
background: var(--color-warning-bg);
.alert-feed__icon {
color: var(--color-warning-text);
}
}
.alert-feed__item--high {
border-left-color: var(--color-danger);
background: var(--color-danger-bg);
.alert-feed__icon {
color: var(--color-danger);
}
}
.alert-feed__item--critical {
border-left-color: var(--color-critical);
background: var(--color-danger-bg);
.alert-feed__icon {
color: var(--color-critical);
}
}
.alert-feed__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: var(--font-size-lg);
box-shadow: var(--shadow-card);
}
.alert-feed__body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.alert-feed__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.alert-feed__type {
font-weight: 600;
color: var(--color-text);
}
.alert-feed__message {
margin: 0;
font-size: var(--font-size-sm);
}
.alert-feed__values {
display: flex;
gap: var(--space-2);
margin: 0;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
strong {
color: var(--color-text);
}
}
.alert-feed__more {
display: inline-block;
margin-top: var(--space-3);
}
@@ -0,0 +1,218 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { of, throwError } from 'rxjs';
import { AlertFeed } from './alert-feed';
import { AlertsService } from '../../../core/services/alerts.service';
import { SitesService } from '../../../core/services/sites.service';
import { Alert } from '../../models/alert.model';
import { Site } from '../../models/site.model';
const SITES: Site[] = [
{
site_id: 'SITE001',
site_name: 'Usine Nantes',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
];
function alerte(surcharges: Partial<Alert> = {}): Alert {
return {
alert_id: 1,
site_id: 'SITE001',
timestamp: '2026-09-15T11:12:00Z',
type: 'spike',
severity: 'critical',
message: 'Variation brutale entre deux lectures consécutives',
value: 812.5,
threshold: 400,
metric: 'consumption_kw',
prediction_id: null,
...surcharges,
};
}
function setup(
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
sitesMock: { getSites: ReturnType<typeof vi.fn> } = {
getSites: vi.fn().mockReturnValue(of(SITES)),
},
) {
TestBed.configureTestingModule({
imports: [AlertFeed],
providers: [
{ provide: AlertsService, useValue: alertsMock },
{ provide: SitesService, useValue: sitesMock },
],
});
return TestBed.createComponent(AlertFeed);
}
function premierChargement(fixture: ComponentFixture<AlertFeed>) {
fixture.detectChanges();
vi.advanceTimersByTime(1);
fixture.detectChanges();
}
function texte(fixture: ComponentFixture<AlertFeed>): string {
return (fixture.nativeElement as HTMLElement).textContent ?? '';
}
function choisir(fixture: ComponentFixture<AlertFeed>, testId: string, value: string) {
const select = fixture.nativeElement.querySelector(
`[data-testid="${testId}"]`,
) as HTMLSelectElement;
select.value = value;
select.dispatchEvent(new Event('change'));
fixture.detectChanges();
vi.advanceTimersByTime(1);
fixture.detectChanges();
}
describe('AlertFeed', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('charge les alertes au démarrage sans filtre et les affiche avec leur contexte', () => {
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
const fixture = setup({ getAlerts });
premierChargement(fixture);
expect(getAlerts).toHaveBeenCalledTimes(1);
expect(getAlerts.mock.calls[0][0]).toEqual({});
const contenu = texte(fixture);
expect(contenu).toContain('Usine Nantes');
expect(contenu).toContain('Critique');
expect(contenu).toContain('Pic de consommation');
expect(contenu).toContain('15/09/2026');
expect(contenu).toContain('812.5 kW');
expect(contenu).toContain('seuil 400 kW');
expect(fixture.nativeElement.querySelector('ev-icon svg')).not.toBeNull();
expect(fixture.nativeElement.querySelector('.alert-feed__item--critical')).not.toBeNull();
});
it('annonce le chargement avant la première réponse', () => {
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
fixture.detectChanges();
expect(fixture.componentInstance.loading()).toBe(true);
expect(texte(fixture)).toContain('Chargement des alertes');
});
it("annonce l'absence d'alerte pour les critères choisis", () => {
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
premierChargement(fixture);
expect(texte(fixture)).toContain('Aucune alerte pour ces critères.');
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(0);
});
it('relance la requête avec la sévérité choisie et revient à la première page', () => {
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
const fixture = setup({ getAlerts });
premierChargement(fixture);
fixture.componentInstance.showMore();
choisir(fixture, 'severity-filter', 'high');
expect(getAlerts).toHaveBeenCalledTimes(2);
expect(getAlerts.mock.calls[1][0]).toEqual({ severity: 'high' });
expect(fixture.componentInstance.visibleCount()).toBe(10);
});
it('relance la requête avec le site choisi dans le filtre', () => {
const getAlerts = vi.fn().mockReturnValue(of([]));
const fixture = setup({ getAlerts });
premierChargement(fixture);
choisir(fixture, 'site-filter', 'SITE001');
expect(getAlerts.mock.calls[1][0]).toEqual({ site_id: 'SITE001' });
});
it('masque le filtre site et force site_id quand le parent fixe le site', () => {
const getAlerts = vi.fn().mockReturnValue(of([]));
const fixture = setup({ getAlerts });
fixture.componentRef.setInput('siteId', 'SITE001');
premierChargement(fixture);
expect(getAlerts.mock.calls[0][0]).toEqual({ site_id: 'SITE001' });
expect(fixture.nativeElement.querySelector('[data-testid="site-filter"]')).toBeNull();
expect(fixture.nativeElement.querySelector('[data-testid="severity-filter"]')).not.toBeNull();
});
it("signale l'indisponibilité en gardant la liste, puis repart au rafraîchissement suivant", () => {
const getAlerts = vi
.fn()
.mockReturnValueOnce(of([alerte()]))
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
.mockReturnValue(of([alerte(), alerte({ alert_id: 2 })]));
const fixture = setup({ getAlerts });
premierChargement(fixture);
vi.advanceTimersByTime(60_000);
fixture.detectChanges();
expect(getAlerts).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.alerts().length).toBe(1);
expect(texte(fixture)).toContain('Alertes indisponibles');
vi.advanceTimersByTime(60_000);
fixture.detectChanges();
expect(getAlerts).toHaveBeenCalledTimes(3);
expect(fixture.componentInstance.error()).toBeNull();
expect(fixture.componentInstance.alerts().length).toBe(2);
});
it('pagine côté client par dix et dévoile le reste à la demande', () => {
const alertes = Array.from({ length: 25 }, (_, i) => alerte({ alert_id: i + 1 }));
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(alertes)) });
premierChargement(fixture);
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(10);
expect(texte(fixture)).toContain('Afficher plus (15 restantes)');
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(20);
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(25);
expect(fixture.nativeElement.querySelector('[data-testid="show-more"]')).toBeNull();
});
it("replie sur l'identifiant quand le site est inconnu ou que la liste des sites échoue", () => {
const fixture = setup(
{ getAlerts: vi.fn().mockReturnValue(of([alerte({ site_id: 'SITE999' })])) },
{ getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
);
premierChargement(fixture);
expect(texte(fixture)).toContain('SITE999');
});
it("n'affiche pas de mesure pour une alerte sans valeur", () => {
const fixture = setup({
getAlerts: vi
.fn()
.mockReturnValue(
of([alerte({ type: 'outage', value: null, threshold: null, metric: null })]),
),
});
premierChargement(fixture);
expect(fixture.nativeElement.querySelector('.alert-feed__values')).toBeNull();
expect(texte(fixture)).toContain('Coupure');
});
});
@@ -0,0 +1,130 @@
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { DatePipe, DecimalPipe } from '@angular/common';
import { catchError, EMPTY, Observable, of, switchMap, tap, timer } from 'rxjs';
import { AlertFilters, AlertsService } from '../../../core/services/alerts.service';
import { SitesService } from '../../../core/services/sites.service';
import { Alert, AlertMetric, AlertSeverity, AlertType } from '../../models/alert.model';
import { Site } from '../../models/site.model';
import {
LIBELLE_PAR_SEVERITE,
LIBELLE_PAR_TYPE,
SEVERITES,
TON_PAR_SEVERITE,
UNITE_PAR_METRIQUE,
} from '../../models/alert-presentation';
import { Badge, BadgeTone } from '../ui/badge/badge';
import { Button } from '../ui/button/button';
import { Alert as EvAlert } from '../ui/alert/alert';
import { Icon } from '../ui/icon/icon';
// Le DAG de détection tourne toutes les heures : une minute suffit largement pour suivre le flux.
const REFRESH_INTERVAL_MS = 60_000;
const PAGE_SIZE = 10;
const UNAVAILABLE_MESSAGE = 'Alertes indisponibles, la liste affichée date du dernier chargement.';
@Component({
selector: 'app-alert-feed',
standalone: true,
imports: [DatePipe, DecimalPipe, Badge, Button, EvAlert, Icon],
templateUrl: './alert-feed.html',
styleUrl: './alert-feed.scss',
})
export class AlertFeed {
private alertsService = inject(AlertsService);
private sitesService = inject(SitesService);
private destroyRef = inject(DestroyRef);
siteId = input<string | null>(null);
readonly severites = SEVERITES;
severity = signal<AlertSeverity | null>(null);
siteFilter = signal<string | null>(null);
alerts = signal<Alert[]>([]);
loading = signal(true);
error = signal<string | null>(null);
visibleCount = signal(PAGE_SIZE);
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
initialValue: [] as Site[],
});
private filters = computed<AlertFilters>(() => ({
site_id: this.siteId() ?? this.siteFilter() ?? undefined,
severity: this.severity() ?? undefined,
}));
private siteNameById = computed(
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
);
visibleAlerts = computed(() => this.alerts().slice(0, this.visibleCount()));
hiddenCount = computed(() => Math.max(this.alerts().length - this.visibleCount(), 0));
constructor() {
toObservable(this.filters)
.pipe(
tap(() => {
this.loading.set(true);
this.visibleCount.set(PAGE_SIZE);
}),
// Piège : catchError sur l'observable interne ; sur le flux externe il terminerait le
// timer et le rafraîchissement ne repartirait jamais.
switchMap((filters) =>
timer(0, REFRESH_INTERVAL_MS).pipe(
switchMap(() =>
this.alertsService
.getAlerts(filters)
.pipe(catchError(() => this.reportUnavailable())),
),
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((alerts) => {
this.loading.set(false);
this.error.set(null);
this.alerts.set(alerts);
});
}
onSiteChange(event: Event): void {
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
}
onSeverityChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value;
this.severity.set(value ? (value as AlertSeverity) : null);
}
showMore(): void {
this.visibleCount.update((count) => count + PAGE_SIZE);
}
toneFor(severity: AlertSeverity): BadgeTone {
return TON_PAR_SEVERITE[severity];
}
severityLabel(severity: AlertSeverity): string {
return LIBELLE_PAR_SEVERITE[severity];
}
typeLabel(type: AlertType): string {
return LIBELLE_PAR_TYPE[type];
}
siteName(siteId: string): string {
return this.siteNameById().get(siteId) ?? siteId;
}
unitFor(metric: AlertMetric | null): string {
return metric ? UNITE_PAR_METRIQUE[metric] : '';
}
private reportUnavailable(): Observable<never> {
this.loading.set(false);
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -0,0 +1,37 @@
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
focusable="false"
[attr.role]="label() ? 'img' : null"
[attr.aria-label]="label()"
[attr.aria-hidden]="label() ? null : 'true'"
>
@switch (name()) {
@case ('spike') {
<polyline points="3 17 8 12 11 15 21 5" />
<polyline points="15 5 21 5 21 11" />
}
@case ('threshold') {
<line x1="3" y1="10" x2="21" y2="10" stroke-dasharray="3 3" />
<polyline points="4 19 9 13 13 15 20 6" />
}
@case ('anomaly') {
<polyline points="3 12 7 12 9.5 6 12.5 18 15 12 21 12" />
}
@case ('outage') {
<path d="M7.5 6.5a7 7 0 1 0 9 0" />
<line x1="12" y1="3" x2="12" y2="11" />
}
@case ('sensor') {
<circle cx="12" cy="13" r="2" />
<path d="M8.5 9.5a5 5 0 0 0 0 7" />
<path d="M15.5 9.5a5 5 0 0 1 0 7" />
<path d="M5.5 6.5a9 9 0 0 0 0 13" />
<path d="M18.5 6.5a9 9 0 0 1 0 13" />
}
}
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,12 @@
:host {
display: inline-flex;
flex-shrink: 0;
width: 1em;
height: 1em;
vertical-align: -0.125em;
}
svg {
width: 100%;
height: 100%;
}
@@ -0,0 +1,49 @@
import { TestBed } from '@angular/core/testing';
import { Icon, IconName } from './icon';
const NOMS: IconName[] = ['spike', 'threshold', 'anomaly', 'outage', 'sensor'];
function rendre(name: IconName, label: string | null = null) {
const fixture = TestBed.createComponent(Icon);
fixture.componentRef.setInput('name', name);
fixture.componentRef.setInput('label', label);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
describe('Icon', () => {
beforeEach(() => {
TestBed.configureTestingModule({ imports: [Icon] });
});
it('dessine un tracé distinct pour chacun des cinq types', () => {
const traces = NOMS.map((name) => rendre(name).querySelector('svg')?.innerHTML.trim());
for (const trace of traces) {
expect(trace).toBeTruthy();
}
expect(new Set(traces).size).toBe(NOMS.length);
});
it('reste décoratif sans libellé', () => {
const svg = rendre('spike').querySelector('svg');
expect(svg?.getAttribute('aria-hidden')).toBe('true');
expect(svg?.hasAttribute('role')).toBe(false);
});
it('expose un rôle image et un libellé accessible quand on lui en donne un', () => {
const svg = rendre('outage', 'Coupure').querySelector('svg');
expect(svg?.getAttribute('role')).toBe('img');
expect(svg?.getAttribute('aria-label')).toBe('Coupure');
expect(svg?.hasAttribute('aria-hidden')).toBe(false);
});
it('hérite de la couleur du parent via currentColor', () => {
const svg = rendre('sensor').querySelector('svg');
expect(svg?.getAttribute('stroke')).toBe('currentColor');
expect(svg?.getAttribute('fill')).toBe('none');
});
});
@@ -0,0 +1,14 @@
import { Component, input } from '@angular/core';
export type IconName = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
@Component({
selector: 'ev-icon',
standalone: true,
templateUrl: './icon.html',
styleUrl: './icon.scss',
})
export class Icon {
name = input.required<IconName>();
label = input<string | null>(null);
}
+1
View File
@@ -2,6 +2,7 @@
@use 'styles/forms';
@use 'styles/auth-page';
@use 'styles/links';
@use 'styles/tables';
body {
margin: 0;
+43
View File
@@ -0,0 +1,43 @@
// Piège : `ev-card` pose son padding sur `:host`, compilé en `[_nghost-…]` (même spécificité
// qu'une classe) et injecté après la feuille globale ; il faut le sélecteur d'élément pour gagner.
ev-card.ev-table-card {
padding: 0;
overflow: hidden;
}
.ev-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.85rem 1.25rem;
text-align: left;
border-bottom: 1px solid var(--color-border-light);
}
th {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: var(--color-bg);
}
}
.ev-table__number {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ev-table__muted {
color: var(--color-text-muted);
}
+7
View File
@@ -29,10 +29,17 @@
// Typo, rayons, ombre
--font-family: 'Segoe UI', system-ui, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.85rem;
--font-size-md: 1rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.75rem;
--font-size-2xl: 2.25rem;
--radius-sm: 8px;
--radius-md: 12px;
--radius-pill: 999px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-card-hover: 0 6px 16px rgba(0, 0, 0, 0.08);
// Espacements
--space-1: 0.35rem;
+11 -4
View File
@@ -5,6 +5,8 @@
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
# Pourquoi : ports du proxy et origine publique en variables, pour que deux environnements
# cohabitent sur la même machine, chacun dans son projet Compose (ADR 0009).
name: enervision
@@ -12,6 +14,9 @@ services:
db:
ports: !override
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
environment:
TS_TUNE_MEMORY: ${TS_TUNE_MEMORY:-2GB}
TS_TUNE_NUM_CPUS: ${TS_TUNE_NUM_CPUS:-2}
mailpit:
ports: !override
@@ -20,6 +25,8 @@ services:
airflow-webserver:
ports: !override
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
environment:
AIRFLOW__WEBSERVER__WORKERS: ${AIRFLOW_WEBSERVER_WORKERS:-2}
backend:
ports: !reset null
@@ -37,8 +44,8 @@ services:
APP_ENV: prod
APP_DEBUG: "false"
APP_TRUST_PROXY_HEADERS: "true"
APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local}
APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password
APP_CORS_ORIGINS: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}
APP_FRONTEND_RESET_PASSWORD_URL: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}/reset-password
frontend:
ports: !reset null
@@ -51,8 +58,8 @@ services:
frontend:
condition: service_started
ports:
- "80:80"
- "443:443"
- "${PROXY_HTTP_PORT:-80}:80"
- "${PROXY_HTTPS_PORT:-443}:443"
volumes:
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
+1
View File
@@ -36,6 +36,7 @@ x-airflow-common: &airflow-common
volumes:
- ./etl/airflow/dags:/opt/airflow/dags
- ./etl/airflow/plugins:/opt/airflow/plugins
- ./data/raw:/opt/data/raw:ro
- airflow_logs:/opt/airflow/logs
- airflow_ml_state:/opt/ml/state
restart: unless-stopped
+1
View File
@@ -15,3 +15,4 @@
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
| [0009](adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
@@ -0,0 +1,80 @@
# 0009 - Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé
- Statut : accepté
- Date : 2026-09-21
## Contexte
La grille note EC03 à EC06 sur ce qui est déployé et fonctionnel au J10. Au 21/09, rien ne
l'est : la CI s'arrête au merge (issue #21), la topologie Compose avec reverse proxy
([ADR 0007](0007-terminaison-tls-et-reverse-proxy-nginx.md)) n'a jamais quitté le poste, et le
module Terraform k3s n'a jamais été appliqué. L'école met à disposition une seule VM,
`eadl-2025-nantes-g3`, sur une adresse privée que les runners hébergés par GitHub ne joignent
pas, sans DNS public.
Il faut deux environnements, recette et production, parce que la stratégie de branches en a
déjà deux, `dev` et `main`, et qu'un déploiement direct en production à chaque merge sur `dev`
n'est pas défendable.
La branche `feat/deploy` tentait de déployer par provisioners Terraform : nginx système et copie
du build Angular. La revue postée sur #21 relève huit points bloquants, dont des racines `rec`
et `prod` qui ne passent pas `terraform validate`.
## Décision
**Un projet Docker Compose par environnement, sur la même machine.** Deux clones du dépôt,
`/srv/enervision/rec` sur `dev` et `/srv/enervision/prod` sur `main`, chacun avec son `.env` et
son `COMPOSE_PROJECT_NAME`. Le nom de projet préfixe volumes, réseau et conteneurs : les deux
stacks ne partagent rien.
**Les ports du proxy et l'origine publique deviennent des variables** de
`docker-compose.prod.yml`. La production garde 80 et 443. La recette publie 8443 et ramène sa
redirection HTTP sur la boucle locale, faute de quoi elle renverrait vers la production. Base,
Mailpit et Airflow restent sur `127.0.0.1`, décalés d'un port.
**Deux noms d'hôte**, `enervision.local` et `rec.enervision.local`, sur la même IP. Le cookie de
rafraîchissement est posé par hôte, pas par port : un seul nom ferait se déconnecter la
production à chaque connexion en recette.
**Un runner GitHub Actions auto-hébergé sur la VM** exécute `deploy.yml` : un `push` sur `dev`
déploie la recette, un `push` sur `main` déploie la production après approbation dans
l'environnement GitHub `prod`. Le job aligne le clone sur la branche puis lance `make stack-up`.
Les images sont construites sur la machine.
**Les secrets vivent dans le `.env` de chaque dossier**, générés sur la machine par
`scripts/provision-host.sh`, jamais dans git ni dans GitHub. Le runner n'a besoin d'aucun
secret.
## Alternatives écartées
- **k3s avec un namespace par environnement** : le cluster serait vide, sans manifeste, sans
registre, sans stockage persistant. C'est la cible de `10-infra.md`, pas celle de la semaine.
- **Provisioners Terraform de `feat/deploy`** : voir la revue sur #21. Terraform reste l'outil
de provisionnement de la machine, pas de livraison applicative.
- **Deux machines**, VM Proxmox et VM Azure ENI : une deuxième infrastructure à justifier devant
le jury et à provisionner, pour un bénéfice nul sur la grille.
- **Un seul proxy frontal routant par nom d'hôte vers les deux stacks** : des URL sans port,
mais le proxy devrait joindre deux réseaux Compose où les services portent les mêmes noms.
La complexité dépasse le gain.
- **Images publiées sur GHCR et déployées par digest** : la bonne pratique, remise à plus tard.
Un registre à authentifier sur la machine, alors que le runner y construit déjà.
## Conséquences
- Deux TimescaleDB sur une machine de 8 Go : sans réglage, chacune se réserverait 25 % de la
RAM au premier démarrage. L'overlay fixe `TS_TUNE_MEMORY` à 2 Go et `TS_TUNE_NUM_CPUS` à 2 par
base, et 2 workers gunicorn par webserver Airflow. La montée à 32 Go prévue par les
consignes est à demander.
- Un runner auto-hébergé sur un dépôt public exécute le code qu'on lui envoie. `deploy.yml` ne
se déclenche jamais sur `pull_request`, le runner tourne sous un utilisateur dédié, et le
dépôt doit exiger une approbation pour les workflows des PR externes.
- Les deux environnements construisent leurs images séparément à partir du même commit : ce qui
tourne en production a été construit deux fois, pas promu. Le passage à GHCR lèvera cette
limite.
- Le `make stack-up` du runner reconstruit l'image Airflow, qui copie `ml/` et `apps/backend/`,
à chaque push : plusieurs minutes par déploiement, acceptable pour la cadence du projet.
- `environments/prod` de Terraform reste vide. Le provisionnement de la machine est porté par
`scripts/provision-host.sh`, que Terraform pourra appeler par `remote-exec` le jour où une
racine visant la VM existera.
- L'image frontend quitte `dhi.io/nginx`, registre authentifié dont personne n'a l'accès, pour
`nginx:1.28-alpine`, la même image que le proxy. Elle n'avait jamais été construite.
+8 -5
View File
@@ -70,10 +70,11 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
[30-frontend.md](30-frontend.md).
Le lien `airflow --> db` est maintenant en trait plein : trois DAGs tournent, deux pour
Le lien `airflow --> db` est maintenant en trait plein : quatre DAGs tournent, deux pour
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
génération des recommandations (issue #116), cf. plus bas et [20-backend.md](20-backend.md). Le
reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit.
génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset
historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des
deux sources restent à compléter dans l'issue #15.
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
collecteur ne vient le lire.
@@ -88,8 +89,8 @@ collecteur ne vient le lire.
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 5 workflows, 16 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 6 workflows, 18 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) |
## Flux bout en bout
@@ -186,3 +187,5 @@ Elles vivent dans `../adr/`, pas ici.
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
| [0008](../adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
| [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
+37 -2
View File
@@ -7,6 +7,7 @@ quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre
|---|---|---|
| Docker Compose | Développer et recetter sur le poste | `Fait` |
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` |
| k3s single-node | Cible à terme | `En cours` |
## Poste de développement
@@ -51,7 +52,7 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver :
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
### Airflow (issues #115 et #116)
### Airflow (issues #115, #116 et #119)
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
airflow-up`, pas dans `make dev`) :
@@ -75,6 +76,12 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` |
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
`./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les
modifier.
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
@@ -169,6 +176,31 @@ Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
compte sur l'IP du proxy et devient globale.
### Deux environnements sur la même machine
Statut : `En cours`, la machine n'étant pas encore provisionnée. Décision et motifs dans
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
La VM `eadl-2025-nantes-g3` portera la recette et la production, chacune dans son clone du dépôt,
son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et conteneurs : rien
n'est partagé. `scripts/provision-host.sh` prépare les deux dossiers, génère les secrets et les
certificats, et ne démarre rien.
| | Recette | Production |
|---|---|---|
| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` |
| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` |
| Proxy HTTP, HTTPS | `127.0.0.1:8081`, `8443` | `80`, `443` |
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
Les deux noms d'hôte visent la même IP, à déclarer dans le `/etc/hosts` des postes. Deux noms
distincts sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
La redirection HTTP de la recette est ramenée sur la boucle locale parce que la configuration
Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur
la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
## Cible à terme, k3s
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
@@ -227,6 +259,9 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
| Un projet Compose par environnement, sur la même machine | Une seule VM, et l'isolation par nom de projet ne demande ni cluster ni registre | `.env` de chaque dossier, [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) |
| Runner GitHub Actions auto-hébergé sur la VM | Les runners hébergés par GitHub ne joignent pas une adresse privée d'école | `.github/workflows/deploy.yml` |
| Secrets dans le `.env` de chaque environnement, sur la machine | Ni dans git, ni dans GitHub : le runner n'a rien à recevoir | `scripts/provision-host.sh` |
## Ports et noms
@@ -237,7 +272,7 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| API | `8000` | Identique en conteneur et hors conteneur |
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`. 80 ne sert que la redirection et le défi ACME |
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. 80 ne sert que la redirection et le défi ACME. La recette publie `8443` et `127.0.0.1:8081` |
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
+31 -23
View File
@@ -4,39 +4,47 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
## État actuel
Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée
par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API.
Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, la
supervision des capteurs (admin) et le flux des alertes actives, tous branchés sur l'API réelle.
Ce qui est en place :
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
`provideHttpClient(withInterceptors([mockApiInterceptor]))`.
- Une route `/dashboard` en composant différé, et une redirection depuis la racine.
- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et
`AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification
(jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`,
`features/dashboard` la page principale, `shared/components` la jauge de consommation et le
graphique de charge par site, tous deux construits sur Chart.js.
`provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`.
- Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`,
`/monitoring/sensors` réservée au rôle `admin`) et une redirection depuis la racine.
- `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses
filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`,
`SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur
d'authentification (jeton porteur, rafraîchissement sur 401), `core/guards` la garde `authGuard`.
- `features/` porte une page par domaine. `shared/components` porte la jauge de consommation et
les graphiques Chart.js, le widget `app-alert-feed` (flux d'alertes filtrable par site et
sévérité, rafraîchi toutes les 60 s, première vue de l'application avec des états chargement /
vide / indisponible) et, dans `shared/models`, des types alignés sur les schémas Pydantic du
backend, plus les tables de présentation partagées (`alert-presentation.ts` : ton, libellé et
unité par sévérité, type et métrique).
- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation,
changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail :
changement de mot de passe, garde de route sur toute la zone authentifiée. Détail :
[31-contrat-authentification.md](31-contrat-authentification.md).
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
`ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit
réutiliser plutôt que redéfinir ses propres styles. Détail :
`ev-badge`, `ev-brand`, `ev-icon`, tokens CSS dans `styles/_tokens.scss`, classes globales de
formulaire, de navigation et de tableau) que toute nouvelle page doit réutiliser plutôt que
redéfinir ses propres styles. Détail :
[32-design-systeme-frontend.md](32-design-systeme-frontend.md).
- L'état vit dans des signaux, sans bibliothèque dédiée.
- TypeScript en `"strict": true` ; `strictTemplates` n'est pas encore activé.
- Vitest via le builder `@angular/build:unit-test`, couverture activée.
- Prettier configuré, parser `angular` pour les gabarits HTML.
Ce qui n'existe pas encore :
- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien
ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement.
`GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette
PR (voir plus bas).
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
- **Le mode fixtures est inactif.** `useMockFixtures` vaut `false` dans `environment.ts` comme dans
`environment.development.ts` : `mockApiInterceptor` ne sert `/stats/summary` et `/alerts` que
dans son propre spec. En développement, toutes les pages exigent un backend joignable et un jeton
valide.
- Un état de chargement généralisé : seul `app-alert-feed` en a un, les autres pages restent vides
tant que la première réponse n'est pas arrivée.
- Aucun lint : ESLint n'est pas installé.
## Arborescence
@@ -87,11 +95,11 @@ sequenceDiagram
```
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il
suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide
et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche.
`environment.useMockFixtures` est vrai. Le drapeau vaut `false` dans les deux fichiers
d'environnement : en pratique toutes les requêtes suivent le chemin réel et l'intercepteur n'est
exercé que par son spec. `/predictions` et `/auth/*` ne sont de toute façon jamais mockés. En
développement, un jeton valide et un backend joignable sont donc nécessaires pour que le tableau de
bord s'affiche.
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
@@ -20,8 +20,9 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
| `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) |
| `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) |
| `--font-family` | Police unique de l'application |
| `--font-size-xs` à `--font-size-2xl` | Échelle typographique (0.75rem à 2.25rem) : libellés, corps, titres, grands nombres |
| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) |
| `--shadow-card` | Ombre portée des cartes |
| `--shadow-card`, `--shadow-card-hover` | Ombre portée des cartes, au repos et au survol |
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`)
@@ -84,6 +85,23 @@ dans le tableau `imports` du composant qui l'utilise.
```html
<ev-brand />
```
- **`<ev-icon>`** (`icon/`) : `name` (obligatoire : `spike` / `threshold` / `anomaly` / `outage` /
`sensor`, les types d'alerte du contrat) et `label` (facultatif). SVG inline en trait sur
`currentColor`, dimensionné par `font-size` comme `ev-brand`. Sans `label` l'icône est décorative
(`aria-hidden`) ; avec, elle porte `role="img"` et `aria-label`. Pas de bibliothèque d'icônes : la
CSP du reverse proxy (`script-src 'self'`) interdit les scripts tiers, pas le SVG inline.
```html
<ev-icon name="spike" label="Pic de consommation" />
```
- **`<app-alert-feed>`** (`shared/components/alert-feed/`) : widget métier plutôt qu'atome du kit,
mais réutilisable tel quel. Input `siteId` (facultatif : fige le site et masque son filtre). Il
porte ses filtres (`.form-select`), ses états et son rafraîchissement ; le parent ne fait que le
poser dans une section.
Les classes de tableau partagées sont dans `apps/frontend/src/styles/_tables.scss`, importées
globalement : `.ev-table-card` sur la `<ev-card>` qui enveloppe un tableau (padding nul),
`.ev-table` sur le `<table>`, `.ev-table__number` pour une cellule numérique en chiffres
tabulaires, `.ev-table__muted` pour une cellule sans valeur.
## Logo
+64 -10
View File
@@ -6,11 +6,15 @@ vérifié, ce qui bloque, et ce qui ne l'est pas.
| Étage | Sert à | Statut |
|---|---|---|
| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` |
| Livraison continue | Porter un artefact vérifié jusqu'à la machine de déploiement | `Cible` |
| Livraison continue | Déployer chaque branche d'intégration sur son environnement de la VM ENI | `En cours` |
Le **D** de CI/CD n'existe pas encore : aucun job de déploiement, aucune construction d'image
publiée, aucun environnement GitHub. L'issue #21 le porte. C'est la limite principale de cet
étage, et elle est nommée ici plutôt que découverte en soutenance.
Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en
production sur la VM de l'école, par un runner auto-hébergé (issue #21,
[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Il n'a encore rien
déployé : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Statut à
basculer sur `Fait` au premier déploiement vert. Sa limite, nommée ici plutôt que découverte en
soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact
n'est publié puis promu d'un environnement à l'autre.
## Vue d'ensemble
@@ -54,7 +58,12 @@ flowchart TB
push --> mv & ms
push --> av & ab
push --> sb1 & sb2 --> sscan
sscan -.-> cd["deploy<br/>issue #21"]
subgraph cd["Déploiement · deploy.yml"]
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
end
push -->|"push sur dev ou main"| dep
```
## Déclenchement
@@ -85,6 +94,46 @@ git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push
ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image
Docker et son propre environnement.
## Déploiement
`deploy.yml` est le sixième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
entrant n'est ouvert.
| Événement | Environnement GitHub | Dossier sur la VM | Garde |
|---|---|---|---|
| `push` sur `dev` | `rec` | `/srv/enervision/rec` | aucune : la recette suit `dev` |
| `push` sur `main` | `prod` | `/srv/enervision/prod` | approbation d'un relecteur dans l'environnement `prod`, branche `main` seule autorisée |
Le job aligne le clone sur la branche (`fetch`, `checkout`, `reset --hard`), lance
`make stack-up`, qui reconstruit les images, redémarre les conteneurs puis applique les
migrations Alembic dans le conteneur backend, et attend jusqu'à trois minutes que
`/api/v1/health/ready` réponde derrière le proxy. Cette sonde ne vérifie que la connexion à la
base et la présence de TimescaleDB : sans la migration, le déploiement serait vert sur une base
sans schéma, et c'est pourquoi `make stack-up` la porte. Un groupe de concurrence par branche,
sans annulation, empêche deux déploiements simultanés du même environnement.
Le job ne fait pas de `actions/checkout` dans son espace de travail, et c'est voulu : le dossier
de l'environnement est stable, hors du runner, parce que `.env`, certificats et volumes doivent
survivre d'un déploiement à l'autre.
**Piège à connaître.** Un runner auto-hébergé sur un dépôt public exécute ce qu'un workflow lui
envoie, et une PR de fork peut réécrire un workflow. Trois parades, et les trois sont
nécessaires : `deploy.yml` ne se déclenche jamais sur `pull_request` ; le runner tourne sous un
utilisateur dédié membre du groupe `docker`, jamais root ; le dépôt doit exiger une approbation
pour les workflows des PR externes (Settings, Actions, « Require approval for all outside
collaborators »), ce qui reste à activer. Les workflows de CI restent sur `ubuntu-latest`.
Cet utilisateur dédié doit posséder `/srv/enervision` : sinon git refuse les deux clones pour
propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=<utilisateur du runner>`
passé à `scripts/provision-host.sh` fixe ce propriétaire.
La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou
plus, clone les deux branches, génère les secrets de chaque `.env` et les certificats
auto-signés, et ne démarre rien. Le détail des deux environnements, ports et noms d'hôte, est
dans [10-infra.md](10-infra.md).
## Ce qui bloque un merge
| Gate | Où | Seuil | Effet d'un échec |
@@ -180,18 +229,23 @@ les tests ne se merge pas.
## Secrets
Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions.
Un seul secret est consommé côté GitHub : **`SONAR_TOKEN`**, porté par les secrets du dépôt.
Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le
workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le
run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement : le job de
déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par
l'issue #22.
run.
Le déploiement ne consomme **aucun secret GitHub** (issue #22). Les secrets de chaque
environnement, mots de passe PostgreSQL et Airflow, clés de signature, clé Fernet, vivent dans le
`.env` de son dossier sur la VM, en `600`, générés sur la machine par `scripts/provision-host.sh`.
Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce dossier, n'a rien
à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se
régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow.
## Ce qui manque, et pourquoi
| Manque | Issue | Conséquence assumée |
|---|---|---|
| Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine |
| Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit |
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
| Tests de charge | #47 | Aucun garde-fou de performance |
+10 -2
View File
@@ -663,8 +663,16 @@ mock_api_import.py
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline ML (`ml_train`/`ml_score`, issue #115) ainsi que la détection d'alertes et la génération des recommandations (`alertes`, issue #116). Il n'orchestre pas encore ces deux imports : `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) restent à faire.
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline
ML (`ml_train`/`ml_score`, issue #115), la détection d'alertes et la génération des
recommandations (`alertes`, issue #116), ainsi que l'import historique
(`historical_import`, issue #119).
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
Le DAG `historical_import` est déclenché manuellement. Il exécute
`app.etl.historical_import` avec les fichiers montés en lecture seule depuis `data/raw` vers
`/opt/data/raw`. L'orchestration de l'import API Mock et la réconciliation globale des deux
sources restent couvertes par l'issue #15.
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` et `historical_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
+45
View File
@@ -0,0 +1,45 @@
"""DAG d'import du dataset historique EnerVision (issue #119).
Orchestre le pipeline existant `app.etl.historical_import` sans dupliquer sa logique ETL.
Le dataset historique sert à initialiser l'environnement : le DAG reste donc manuel.
Le backend est exécuté dans l'environnement `/opt/backend` embarqué dans l'image Airflow,
sur le même patron que le DAG `alertes` (ADR 0008).
"""
from __future__ import annotations
from datetime import datetime, timedelta
from airflow.models.dag import DAG
from airflow.operators.bash import BashOperator
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
CSV_PATH = "/opt/data/raw/all_sites_combined.csv"
METADATA_PATH = "/opt/data/raw/dataset_metadata.json"
SOURCE_TIMEZONE = "UTC"
BATCH_SIZE = 1000
with DAG(
dag_id="historical_import",
description="Importe le dataset historique CSV/JSON dans dataset, site et reading.",
schedule=None,
start_date=datetime(2026, 1, 1),
catchup=False,
max_active_runs=1,
tags=["etl", "historical"],
) as dag:
BashOperator(
task_id="import_historical",
bash_command=(
f"{COMMANDE_BACKEND} app.etl.historical_import "
f"--csv {CSV_PATH} "
f"--metadata {METADATA_PATH} "
"--source-timezone UTC "
"--batch-size 1000"
),
retries=1,
retry_delay=timedelta(minutes=2),
execution_timeout=timedelta(minutes=30),
)
+27 -1
View File
@@ -10,12 +10,13 @@ from airflow.models.dagbag import DagBag
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
DAG_IDS = ["ml_train", "ml_score", "alertes"]
DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import"]
TACHES = [
("ml_train", "train"),
("ml_score", "score"),
("alertes", "detection"),
("alertes", "recommandations"),
("historical_import", "import_historical"),
]
@@ -47,6 +48,10 @@ def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None:
assert dagbag.dags["alertes"].timetable.summary == "15 * * * *"
def test_historical_import_has_no_schedule(dagbag: DagBag) -> None:
assert dagbag.dags["historical_import"].timetable.summary == "None"
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
tache = dagbag.dags["ml_train"].get_task("train")
assert "enervision_ml.train" in tache.bash_command
@@ -67,12 +72,29 @@ def test_alertes_recommendation_task_calls_the_backend_cli(dagbag: DagBag) -> No
assert "app.cli generate-recommendations" in tache.bash_command
def test_historical_import_calls_the_existing_backend_module(dagbag: DagBag) -> None:
tache = dagbag.dags["historical_import"].get_task("import_historical")
assert "app.etl.historical_import" in tache.bash_command
def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> None:
commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command
assert "--csv /opt/data/raw/all_sites_combined.csv" in commande
assert "--metadata /opt/data/raw/dataset_metadata.json" in commande
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None:
# Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008).
assert "/opt/backend" in dagbag.dags["alertes"].get_task(task_id).bash_command
def test_historical_import_runs_in_the_backend_environment(dagbag: DagBag) -> None:
commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command
assert "/opt/backend" in commande
def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None:
# `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire
# tant que la detection n'a pas ecrit.
@@ -133,6 +155,10 @@ def test_alertes_retries_after_a_transient_failure(dagbag: DagBag, task_id: str)
assert dagbag.dags["alertes"].get_task(task_id).retries >= 1
def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) -> None:
assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
def test_tasks_never_resync_the_baked_environment(
dagbag: DagBag, dag_id: str, task_id: str
+6
View File
@@ -15,6 +15,12 @@ configuration est montée en volume par `docker-compose.prod.yml`.
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
Les ports publiés sont `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`, 80 et 443 par défaut. Quand deux
environnements partagent la machine ([ADR 0009](../../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md)),
la recette publie `8443` et ramène son port 80 sur `127.0.0.1:8081` : la redirection ci-dessous
renvoie vers `https://$host` sans port, donc vers la production. `PUBLIC_ORIGIN` porte alors
l'origine avec son port pour le CORS et le lien de réinitialisation.
## Routage
| Chemin | Destination | Remarque |
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Pourquoi : la machine porte deux environnements, chacun un clone du dépôt, un `.env` et un
# projet Compose (ADR 0009). Ce script prépare la machine et les deux dossiers sans rien
# démarrer : construction des images et démarrage restent à l'opérateur, puis au runner GitHub.
# Rejouable : un dossier déjà cloné est réaligné sur sa branche, un `.env` existant n'est jamais
# réécrit, un certificat présent n'est jamais régénéré.
set -euo pipefail
DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}"
RACINE="${RACINE:-/srv/enervision}"
ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}"
PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}"
COMPOSE_MINIMALE="2.24.4"
erreur() { echo "erreur : $*" >&2; exit 1; }
secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; }
# Clé Fernet : 32 octets en base64 urlsafe, padding compris.
fernet() { openssl rand -base64 32 | tr '+/' '-_'; }
verifier_outils() {
for outil in git make openssl curl; do
command -v "$outil" >/dev/null || erreur "$outil absent (apt-get install $outil)"
done
command -v docker >/dev/null || erreur "Docker absent : https://docs.docker.com/engine/install/debian/"
docker info >/dev/null 2>&1 || erreur "le démon Docker ne répond pas, ou l'utilisateur n'est pas dans le groupe docker"
local version
version="$(docker compose version --short 2>/dev/null || true)"
[[ -n "$version" ]] || erreur "plugin docker compose absent (paquet docker-compose-plugin)"
[[ "$(printf '%s\n%s\n' "$COMPOSE_MINIMALE" "${version#v}" | sort -V | head -1)" == "$COMPOSE_MINIMALE" ]] \
|| erreur "docker compose $version trop ancien : $COMPOSE_MINIMALE requis pour !override et !reset"
curl -fsSI --max-time 10 https://github.com >/dev/null || erreur "pas de sortie HTTPS vers github.com"
echo "docker compose $version, sortie Internet : ok"
}
preparer() {
local env="$1" branche="$2" hote="$3" origine="$4"
local port_https="$5" port_http="$6" port_pg="$7" port_mailpit="$8" port_airflow="$9"
local dossier="$RACINE/$env"
if [[ -d "$dossier/.git" ]]; then
git -C "$dossier" fetch --quiet origin "$branche"
git -C "$dossier" checkout --quiet "$branche"
git -C "$dossier" reset --quiet --hard "origin/$branche"
else
git clone --quiet --branch "$branche" "$DEPOT" "$dossier"
fi
if [[ ! -f "$dossier/.env" ]]; then
sed -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|" \
-e "s|^POSTGRES_PORT=.*|POSTGRES_PORT=$port_pg|" \
-e "s|^APP_SECRET_KEY=.*|APP_SECRET_KEY=$(secret)|" \
-e "s|^MAILPIT_UI_PORT=.*|MAILPIT_UI_PORT=$port_mailpit|" \
-e "s|^AIRFLOW_PORT=.*|AIRFLOW_PORT=$port_airflow|" \
-e "s|^AIRFLOW_FERNET_KEY=.*|AIRFLOW_FERNET_KEY=$(fernet)|" \
-e "s|^AIRFLOW_WEBSERVER_SECRET_KEY=.*|AIRFLOW_WEBSERVER_SECRET_KEY=$(secret)|" \
-e "s|^AIRFLOW_ADMIN_PASSWORD=.*|AIRFLOW_ADMIN_PASSWORD=$(secret | cut -c1-20)|" \
-e "s|^AIRFLOW_APP_SECRET_KEY=.*|AIRFLOW_APP_SECRET_KEY=$(secret)|" \
-e "s|^PUBLIC_HOST=.*|PUBLIC_HOST=$hote|" \
-e "s|^PUBLIC_ORIGIN=.*|PUBLIC_ORIGIN=$origine|" \
-e "s|^COMPOSE_PROJECT_NAME=.*|COMPOSE_PROJECT_NAME=enervision-$env|" \
-e "s|^PROXY_HTTP_PORT=.*|PROXY_HTTP_PORT=$port_http|" \
-e "s|^PROXY_HTTPS_PORT=.*|PROXY_HTTPS_PORT=$port_https|" \
"$dossier/.env.example" > "$dossier/.env"
# Branche antérieure à l'ADR 0009 : ces clés manquent alors dans .env.example.
for cle in "COMPOSE_PROJECT_NAME=enervision-$env" "PUBLIC_ORIGIN=$origine" \
"PROXY_HTTP_PORT=$port_http" "PROXY_HTTPS_PORT=$port_https"; do
grep -q "^${cle%%=*}=" "$dossier/.env" || echo "$cle" >> "$dossier/.env"
done
chmod 600 "$dossier/.env"
echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD."
fi
if [[ ! -f "$dossier/infra/proxy/tls/fullchain.pem" ]]; then
(cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh)
fi
echo "$env : $dossier sur $branche, $origine"
}
verifier_outils
mkdir -p "$RACINE"
# env branche hôte origine https http pg mailpit airflow
preparer prod main enervision.local https://enervision.local 443 80 5433 8025 8080
preparer rec dev rec.enervision.local https://rec.enervision.local:8443 8443 127.0.0.1:8081 5434 8026 8082
if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then
chown -R "$PROPRIETAIRE" "$RACINE"
fi
cat <<FIN
Démarrage, dans chaque dossier : make stack-up, qui applique aussi les migrations.
Premier administrateur, stack démarrée, dans chaque dossier :
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \\
python -m app.cli create-admin --email <adresse>
Le runner GitHub Actions (label eni-g3) rejouera le déploiement à chaque push sur dev et main.
L'installer sous le propriétaire de $RACINE, sinon git refuse ces dépôts et le .env en 600 lui
échappe : relancer au besoin ce script avec PROPRIETAIRE=<utilisateur du runner>.
Données historiques : git ne porte pas data/raw, déposer les fichiers dans chaque dossier avant
de déclencher le DAG historical_import.
Depuis un poste : ajouter « $ADRESSE enervision.local rec.enervision.local » à /etc/hosts.
FIN