Compare commits

...
Author SHA1 Message Date
Johan LEROY d506e8f7ef fix(backend,ci): repare trois angles morts de la surveillance de derive
Airflow / Construction de l'image (push) Successful in 1m2s
Backend / Analyse statique de sécurité (push) Successful in 7s
Backend / Tests exigeant une base (push) Failing after 5m3s
Airflow / Lint et intégrité des DAGs (push) Successful in 9m41s
ML / Analyse statique de sécurité (push) Successful in 6s
Backend / Lint, typage et tests (push) Successful in 10m10s
Backend / Audit des dépendances (push) Successful in 9m36s
ML / ML - DB et chaîne ML - DB - API (push) Failing after 5m6s
SonarQube / test-ml (push) Failing after 6m6s
ML / Lint, typage et tests (push) Successful in 11m31s
SonarQube / build-front (push) Successful in 9m49s
SonarQube / build-back (push) Successful in 9m54s
SonarQube / test-front (push) Failing after 5m10s
SonarQube / test-back (push) Failing after 5m13s
SonarQube / SonarQube (push) Skipped
Revue de la branche : trois defauts empechaient la surveillance de tenir ce qu'elle annonce.

- `evaluate()` gardait les microsecondes de `now()` dans `window_end`, la cle de
  `uq_drift_report_window`. Deux executions ne collidaient donc jamais et l'index ne
  dedoublonnait rien, contrairement a ce qu'affirmaient l'ADR 0011, 20-backend et le docstring
  du DAG. L'instant de reference est desormais tronque a l'heure.
- Un site qui cessait d'etre score disparaissait du rapport : la liste des sites ne venait que
  de la fenetre recente. La panne que cette surveillance existe pour dire etait exactement
  celle qu'elle taisait. La fenetre de reference entre maintenant dans l'union, et le site
  recoit sa ligne `indetermine` a zero observation.
- Sans fenetre de reference, `_plafond` rendait `None` et le verdict tombait sur `stable`, une
  affirmation que la donnee ne portait pas. C'est `indetermine` desormais.

`ml.yml` ecoute `apps/backend/app/**` et non les seuls modeles : ce workflow est le seul a
jouer `-m chaine`, or la chaine traverse les endpoints, les services et les schemas jusqu'a
`GET /predictions`. Une PR touchant `predictions.py` ne declenchait pas le test qui l'assert.

Hygiene de tests : le nettoyage des fixtures API connait `drift_report` (cle etrangere RESTRICT
vers `site`), le test sans rapport rend ses overrides en teardown, `test_chaine_ml_api` compare
les `created_at` strictement (un `>=` passait aussi quand l'API resservait la premiere ligne),
et `test_data_integration` filtre sur le site seme au lieu de juger tout le contenu d'une
fenetre dans une base partagee.

Docs remises d'aplomb : sept revisions Alembic et non six, `derive.py` dans l'inventaire de
etl/README, et le diagramme de 20-backend gagne DriftService, le depot drift et sa treizieme
table.
2026-09-22 14:58:34 +02:00
Johan LEROY cb961ec2c5 fix(ml): borne la fenetre de scoring a l'instant demande, pour que --now rejoue l'historique
`load_recent_from_database` n'avait qu'une borne basse. `build_scoring_frame` repartait donc de
la derniere lecture de toute la table quel que soit `--now` : `target_at` valait toujours
"fin du jeu + 1h", et `_age = instant - derniere_lecture` devenait negatif, ce qui passait le
seuil de peremption sans rien signaler.

Consequence concrete : sur le jeu historique, arrete au 31/12/2024, aucune boucle de rattrapage
ne pouvait produire une prevision dont le realise existe deja. La surveillance de derive livree
par la migration precedente n'aurait donc rien eu a comparer en demonstration.

`until` est desormais obligatoire sur ce chargeur, ce qui interdit de l'oublier, et le mode CSV
filtre symetriquement. En exploitation rien ne change, aucune lecture n'etant posterieure a
l'heure courante.
2026-09-22 14:29:03 +02:00
Johan LEROY 68239371f6 docs(ml,backend,etl): ordonnance la derive et corrige ce que le depot disait faux
Trois phrases du depot annonçaient une surveillance de derive inexistante, et une quatrieme
disait qu'aucune base PostgreSQL n'etait joignable pour tester le chargement ML. Les quatre
sont maintenant fausses, donc reecrites plutot que laissees en dette.

- ADR 0011 : ou vit le calcul et pourquoi pas dans `ml/`, les deux dedoublonnages qu'impose la
  jointure, et quatre alternatives ecartees avec la contrainte qui les interdit (la metrique
  MLflow n'est pas la meme grandeur, `alert` borne ses valeurs et refuse un site nul, Prometheus
  n'a pas de collecteur, ne rien persister ne repond pas a la question du jury).
- DAG `derive` quotidien, hors du DAG `alertes` : un echec de derive y ferait croire que la
  detection a echoue, et la fenetre de 168 h ne se recalcule pas toutes les heures.
- ML-START : la limite de `--now` est dite au lieu d'etre decouverte en demonstration. Elle ne
  decale que l'instant de reference, pas la fenetre de lecture, donc aucun rattrapage ne peut
  fabriquer de paires prevu/realise sur un jeu fige.
- 50-cicd : pourquoi le job ML installe aussi le backend (le schema n'a qu'une source), et ce
  que coute le filtre de chemins qui l'accompagne.
2026-09-22 14:27:13 +02:00
Johan LEROY 9bf2f27127 feat(backend): surveille la derive du modele de prevision
EC06 attendait une reponse a « comment savez-vous que le modele se degrade ? ». Elle n'existait
nulle part : `docs/architecture/00-vue-ensemble.md` et `docs/ML-START.md` le disaient tous les
deux.

Le calcul vit dans le backend, et `ml/` ne gagne pas une ligne. Trois raisons : `prediction`
n'est pas dans le perimetre de lecture que `ML_DATABASE_URL` vise (ADR 0003 et ML-START le
bornent a `reading` et `site`) ; l'alignement prevu contre realise existe deja une fois ici,
dans `AlertService._detect_anomaly`, et le dupliquer en SQL brut creerait une seconde source de
verite, ce que l'ADR 0006 refuse ; et FastAPI continue de ne jamais faire tourner LightGBM.

Ce qui est mesure : la jointure `prediction` x `reading` sur `(site_id, target_at)`, avec un
`DISTINCT ON` des deux cotes. Les runs de scoring s'empilent volontairement, et
`uq_reading_source` autorise deux lectures au meme instant quand la source differe : sans ce
dedoublonnage, la meme heure pesait plusieurs fois dans la moyenne. La fenetre est fermee a
droite par un delai de grace, sinon la derniere heure, dont le realise n'est pas encore
ingere, ferait chuter la couverture a chaque execution.

Le verdict a trois valeurs, pas deux : avec trois points on ne declare pas une derive, on dit
qu'on ne sait pas. La comparaison se fait entre deux fenetres vives de meme duree, jamais
contre la metrique loguee a l'entrainement : celle-ci mesure un backtest a meteo connue, le
scoring prevoit une heure dont la meteo ne l'est pas.

`drift_report` porte une ligne par site plus une ligne globale, que `site_id` a NULL designe.
L'idempotence passe par un index a `coalesce` et non par une contrainte d'unicite, sans quoi
deux lignes globales ne seraient jamais egales.
2026-09-22 14:22:54 +02:00
Johan LEROY 568060a903 test(backend): traverse l'API jusqu'a PostgreSQL sur les sites et les recommandations
Les tests d'API remplacaient tous leur service par un double : rien ne prouvait que
`endpoint -> service -> repository -> SQL` rende ce que l'endpoint serialise. Seules
l'authentification et la matrice de roles traversaient vraiment la base.

`tests/api/conftest.py` seme un jeu metier valide en base et nettoie derriere lui. Il valide
ses ecritures, contrairement aux fixtures de `tests/repositories` : un endpoint ouvre sa
propre session et ne verrait pas une transaction en cours. L'isolation vient de la marque
portee par chaque `site_id`, jamais d'un total : ces routes listent toute la base.

Les recommandations d'abord, parce que `POST /generate` est la seule route d'ecriture : son
idempotence tient a une contrainte d'unicite et a un `on_conflict_do_nothing`, invérifiables
hors base, et sa relecture par une seconde requete HTTP est la seule assertion du depot qui
prouve que la validation atteint le disque. Cote sites, le depart des ex aequo par
`reading_id` quand deux sources ecrivent la meme heure ne peut se demontrer qu'ainsi.
2026-09-22 14:13:00 +02:00
Johan LEROY aa4af62290 test(ml,backend): couvre ML vers DB, puis la chaine complete jusqu'a l'API
Le pipeline ML n'avait aucun test touchant PostgreSQL : `ml/README.md` le disait, faute de
base joignable en CI. Le marqueur `integration` de `ml/pyproject.toml` etait declare et porte
par zero test.

- `ml/tests/conftest.py` : deux fixtures d'acces a la base, jamais interchangeables.
  `connexion_ml` annule sa transaction, `parc` valide ses ecritures parce que `run_scoring`
  ouvre sa propre connexion et ne verrait rien d'autre. Garde sur le nom de base, marque uuid
  sur chaque site, nettoyage dans l'ordre des cles etrangeres.
- `test_data_integration.py` : les neuf colonnes du contrat confrontees au schema Alembic
  reel, la borne `since`, l'ordre de tri dont dependent des lags positionnels, et le typage
  des colonnes entierement nulles.
- `test_score_integration.py` : les contraintes de `prediction` vues depuis le code qui
  ecrit, l'empilement volontaire de deux runs, et `run_scoring` de bout en bout sur un
  booster reel.
- `apps/backend/tests/test_chaine_ml_api.py` : lance les vrais binaires `enervision_ml.train`
  et `.score` en sous-processus, comme les DAGs, puis relit par `GET /api/v1/predictions`.
  Marqueur `chaine` distinct : le job `integration` du backend n'a pas l'environnement de ml/.
- `ml.yml` : job `integration`, seul du depot a reunir les deux environnements uv et une base.
  Ses `paths` incluent les migrations du backend, sans quoi le schema deriverait du SQL du
  pipeline sans que rien ne casse.
- Makefile : `migrate-test`, qui manquait (`enervision_test` n'a jamais recu de table),
  `ml-test-integration` et `test-chaine`.
2026-09-22 14:10:50 +02:00
Johan LEROY 5b5c97532d fix(ml): type is_working_hours pour que LightGBM accepte une lecture sans valeur
`reading.is_working_hours` est nullable et fait partie des features. Une seule lecture a
NULL dans la fenetre suffisait a rendre la colonne `object` au retour de `pd.read_sql`, et
LightGBM refuse alors de construire son Dataset : "pandas dtypes must be int, float or
bool". La panne n'arrivait qu'au scoring, sur la vraie base, jamais en test.

`_typer` coerce donc aussi cette colonne, comme les colonnes mesurees : NaN vaut valeur
manquante, que LightGBM gere nativement. Effet de bord voulu, les deux chargeurs rendent
enfin le meme dtype : `load_from_csv` faisait un `astype(bool)` qui ecrasait silencieusement
une valeur absente en `False`.
2026-09-22 14:10:35 +02:00
Johan LEROYandGitHub fb4235df3a Merge pull request #144 from ineszang/feat/terraform-provisionnement
feat(infra): Terraform provisionne la VM, GitHub Actions continue de déployer
2026-09-22 12:39:12 +02:00
Johan LEROYandClaude Opus 5 357cf996ed fix(infra): donne au runner un nom distinct de son label
Infra / Formatage et validation Terraform (push) Successful in 9s
--name reprenait runner_labels : un runner et son label portaient le meme
eni-g3. Deux machines qui reprendraient cette racine s'enregistreraient sous
le meme nom, ce que GitHub refuse. Le nom vaut desormais celui de l'hote,
resolu sur la machine, et runner_nom permet de le forcer.

Point 2 de la revue de la PR #144.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:36:38 +02:00
PhyriosandGitHub 4fb8b5a784 Merge pull request #143 from ineszang/fix/deploiement-airflow-3
fix(infra,ci): aligne le provisionnement et le déploiement sur Airflow 3
2026-09-22 11:44:16 +02:00
Johan LEROYandClaude Opus 5 c1f9dec5fe chore(git): retire le motif airflow-webserver.pid du .gitignore
Airflow 3 n'a plus de webserver : le fichier n'est plus produit. Dernier point
de la revue de la PR #143.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:42:25 +02:00
Johan LEROY fca36649f9 ci(infra): épingle setup-terraform sur un SHA de commit
Action tierce sur un workflow qui tourne avec les droits du dépôt : un tag
mobile se redéplace. Constat SonarCloud githubactions:S7637, seul écart de la
quality gate sur la PR.
2026-09-22 11:29:24 +02:00
Johan LEROY 0db69fd023 feat(infra): Terraform provisionne la VM, GitHub Actions continue de déployer
Le seul Terraform du dépôt installait un cluster k3s que rien ne consomme, et
sa racine ne passait même pas `terraform init` : depuis Terraform 1.x, un
provisioner `destroy` impose que tout le bloc `connection` ne lise que `self`,
et celui du module k3s lisait `var.*`. Il est corrigé en batissant la connexion
sur `triggers`, où ne figurent que l'adresse, le port, l'utilisateur et le
chemin de la clé : jamais la clé ni un mot de passe.

`environments/vm-eni` provisionne la machine qui porte la recette et la
production : Docker et le plugin Compose, `scripts/provision-host.sh`, puis
l'enregistrement du runner. Rien n'y construit d'image ni ne lance de
conteneur, et un `apply` n'interrompt pas la stack qui tourne. Le déploiement
continu ne bouge pas : `deploy.yml` reste le seul chemin de livraison.

`environments/dev` devient `k3s-cible`, parce qu'il provisionnait un cluster et
pas un environnement applicatif, et que `rec` et `prod` vivent sur la même
machine. `environments/prod`, dossier vide, disparaît.

`infra.yml` formate et valide chaque racine à chaque push : c'est l'absence
d'un tel job qui a laissé ce Terraform cassé sans que rien ne le dise. La
boucle parcourt `environments/*`, une racine ajoutée est couverte sans y
toucher.

ADR 0010 pour la frontière entre provisionnement et livraison, et la doc
d'architecture alignée dessus.
2026-09-22 11:27:21 +02:00
Johan LEROY be6be42947 fix(infra,ci): aligne le provisionnement et le déploiement sur Airflow 3
La migration Airflow 3 est arrivée sur `dev` après l'écriture du chemin de
déploiement, qui en a gardé quatre traces fausses, invisibles en CI puisque
aucun job ne joue ce chemin.

`provision-host.sh` substituait `AIRFLOW_WEBSERVER_SECRET_KEY`, clé disparue.
`AIRFLOW_API_SECRET_KEY` et `AIRFLOW_JWT_SECRET` restaient donc à `change_me`
dans le `.env` posé sur la machine, et le `:?` d'`airflow-init` ne voit pas
une valeur d'exemple : la stack aurait démarré avec un secret de session et un
secret JWT prévisibles. Le `.env` est maintenant écrit après contrôle, et le
script refuse de le poser s'il reste un `change_me` hors `APP_MOCK_API_*`.

`make services-up` démarrait `airflow-webserver`, service supprimé par la
migration ; seul `airflow-up` avait été aligné.

L'overlay posait `AIRFLOW__WEBSERVER__WORKERS`, sans effet en Airflow 3 où la
section est `[api]`. Le réglage disparaît plutôt que d'être renommé : le défaut
y vaut un worker, moins que les deux qu'on visait.

Le diagnostic d'échec de `deploy.yml` lisait les journaux sans l'overlay, donc
sans service `proxy` : il échouait avant d'imprimer quoi que ce soit.
2026-09-22 11:17:02 +02:00
Johan LEROY b5993f994e chore(deps): aligne le runner nginx du frontend sur 1.31-alpine
Même image que le proxy de docker-compose.prod.yml depuis #126 : un seul
nginx à suivre pour les deux conteneurs qui en embarquent un.
2026-09-22 09:13:03 +02:00
Johan LEROY 091ee344d1 refactor(etl): aligne la DAG historical_import sur le SDK Airflow 3
Même mouvement que pour ml_train, ml_score et alertes (#135) : DAG depuis
airflow.sdk, BashOperator depuis le provider standard, et la planification
lue sur dag.schedule puisque les timetables n'exposent plus summary. Les
anciens imports fonctionnaient encore, avec un avertissement de dépréciation.
2026-09-22 09:05:53 +02:00
Johan LEROY f81959c1a9 Merge branch 'main' into dev
Rapatrie #135 : migration vers Airflow 3.3.2 (api-server, dag-processor,
secret JWT, FabAuthManager, DAGs et tests sur le SDK). Conflit Makefile :
la cible airflow-up garde le prérequis db-ensure-airflow de dev et les
services renommés de main.
2026-09-22 09:05:50 +02:00
Johan LEROYandGitHub 5c361c6952 Merge pull request #135 from ineszang/dependabot/uv/etl/airflow/apache-airflow-3.3.0
chore(deps): bump apache-airflow from 2.10.4 to 3.3.0 in /etl/airflow
2026-09-22 09:04:19 +02:00
Johan LEROY a27e0c6535 Merge branch 'main' into dev
Rapatrie dans dev les mises à jour de dépendances mergées sur main :
#142 (SonarQube ignoré pour Dependabot), #129 checkout v7, #130 setup-uv v7,
#132 setup-node v7, #128 upload-artifact v7, #131 download-artifact v8,
#127 Node 26 (image et CI frontend), #126 nginx 1.31-alpine, #133 pandas 3.0.6
et ruff 0.16.8, #134 Angular 22.1.7, jsdom 30, prettier 3.9.8 (TypeScript 6 et
vitest 4 conservés, majeures ignorées côté Dependabot).

Conflit README.md : statuts « En place » de main, « Quatre DAGs » de dev (#138).
2026-09-22 09:03:17 +02:00
Johan LEROY 7a97a4d1ac docs(readme): le frontend construit sur Node 26
Suite à #127 : image node:26-alpine3.22 et CI frontend sur Node 26.
2026-09-22 09:03:12 +02:00
Johan LEROYandGitHub 20abcfee33 Merge pull request #134 from ineszang/dependabot/npm_and_yarn/apps/frontend/frontend-dependencies-e5a1f9e295
chore(deps): bump the frontend-dependencies group in /apps/frontend with 12 updates
2026-09-22 09:02:17 +02:00
Johan LEROY 3d6d2eb760 style(infra): reflow un commentaire du compose Airflow à 100 colonnes 2026-09-22 09:01:07 +02:00
Johan LEROY 154deca511 docs(architecture): décrit les composants Airflow 3
Quatre services au lieu de trois (api-server, dag-processor), le secret JWT
partagé et le choix du FabAuthManager. Le Python 3.12 d'etl/airflow est celui
de l'image retenue, plus une limite d'Airflow.
2026-09-22 09:01:07 +02:00
Johan LEROY a577356198 feat(infra): fait tourner Airflow 3 dans la stack Compose
Image apache/airflow:3.3.2-python3.12. Le webserver devient l'api-server
(healthcheck /api/v2/monitor/health) et le dag-processor est un service à
part : depuis Airflow 3 le scheduler ne parse plus les DAGs.

Les tâches passent par l'Execution API de l'api-server avec un jeton signé
par AIRFLOW_JWT_SECRET, nouveau secret du .env partagé entre conteneurs.
AIRFLOW_WEBSERVER_SECRET_KEY devient AIRFLOW_API_SECRET_KEY. airflow-init
refuse toujours de démarrer si l'un des secrets manque.

FabAuthManager explicite : le SimpleAuthManager par défaut ne sait pas créer
le compte admin que airflow-init pose via _AIRFLOW_WWW_USER_*. Pas de
triggerer, aucun opérateur déférable dans les DAGs.
2026-09-22 09:01:07 +02:00
Johan LEROY 19ff152445 refactor(etl): migre les DAGs et leurs tests vers le SDK Airflow 3
DAG et BaseOperator viennent d'airflow.sdk, BashOperator du provider
standard (airflow.operators.bash n'est plus qu'un alias déprécié). DagBag
s'importe depuis airflow.dag_processing et ne prend plus include_examples,
les exemples étant déjà coupés par la configuration de conftest.py.

Les timetables n'exposent plus summary : la planification se lit sur
dag.schedule (None) et timetable.expression (cron normalisé).
2026-09-22 09:01:07 +02:00
Johan LEROY bf3584182f chore(deps): passe apache-airflow à 3.3.2
Dernier correctif de la branche 3.3 (17 septembre 2026) plutôt que le 3.3.0
proposé par Dependabot. Le verrou est régénéré par uv lock ; le provider
standard et le Task SDK, requis par Airflow 3, y figurent déjà.
2026-09-22 09:01:07 +02:00
dependabot[bot]andJohan LEROY 28e8260216 chore(deps): bump apache-airflow from 2.10.4 to 3.3.0 in /etl/airflow
Bumps [apache-airflow](https://github.com/apache/airflow) from 2.10.4 to 3.3.0.
- [Release notes](https://github.com/apache/airflow/releases)
- [Changelog](https://github.com/apache/airflow/blob/main/docker-stack-docs/changelog.rst)
- [Commits](https://github.com/apache/airflow/compare/2.10.4...3.3.0)

---
updated-dependencies:
- dependency-name: apache-airflow
  dependency-version: 3.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 09:01:07 +02:00
Johan LEROYandGitHub e90e563a1c Merge pull request #133 from ineszang/dependabot/uv/apps/backend/backend-dependencies-b3e2c9fee8
chore(deps): bump the backend-dependencies group in /apps/backend with 2 updates
2026-09-22 08:59:17 +02:00
Johan LEROYandGitHub d7ca9fd8b0 Merge pull request #126 from ineszang/dependabot/docker_compose/nginx-1.31-alpine
chore(deps): bump nginx from 1.28-alpine to 1.31-alpine
2026-09-22 08:59:06 +02:00
Johan LEROYandGitHub 67204b5eb6 Merge pull request #127 from ineszang/dependabot/docker/apps/frontend/node-26-alpine3.22
chore(deps): bump node from 24-alpine3.22 to 26-alpine3.22 in /apps/frontend
2026-09-22 08:58:54 +02:00
Johan LEROY 985466e9cc ci(frontend): teste sur Node 26, comme l'image
Frontend / Audit des dépendances (push) Successful in 6s
SonarQube / test-ml (push) Failing after 5m46s
Frontend / build (push) Successful in 9m47s
SonarQube / build-front (push) Successful in 9m54s
SonarQube / build-back (push) Successful in 10m30s
Frontend / test (push) Failing after 5m3s
SonarQube / test-front (push) Failing after 5m8s
SonarQube / test-back (push) Failing after 5m22s
SonarQube / SonarQube (push) Skipped
L'image frontend passe sur node:26-alpine3.22 ; la CI restait sur Node 24
et ne validait donc plus l'interpréteur qui construit le SPA. Angular 22.1.8
accepte >=26.0.0 ; Node 26 devient LTS le 28 octobre 2026, Node 24 passe en
maintenance le 20.
2026-09-22 08:42:46 +02:00
dependabot[bot]andJohan LEROY c347212c93 chore(deps): bump node in /apps/frontend
Bumps node from 24-alpine3.22 to 26-alpine3.22.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-alpine3.22
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 08:42:46 +02:00
Johan LEROY c67c7802c1 Merge pull request #131 from ineszang/dependabot/github_actions/actions/download-artifact-8
chore(deps): bump actions/download-artifact from 4 to 8
2026-09-22 08:42:06 +02:00
Johan LEROY e824423889 Merge pull request #128 from ineszang/dependabot/github_actions/actions/upload-artifact-7
chore(deps): bump actions/upload-artifact from 4 to 7
2026-09-22 08:42:03 +02:00
Johan LEROY 436147c8cb Merge pull request #132 from ineszang/dependabot/github_actions/actions/setup-node-7
chore(deps): bump actions/setup-node from 6 to 7
2026-09-22 08:42:00 +02:00
Johan LEROYandGitHub 8fb7ab8680 Merge pull request #130 from ineszang/dependabot/github_actions/astral-sh/setup-uv-7
chore(deps): bump astral-sh/setup-uv from 5 to 7
2026-09-22 08:40:33 +02:00
Johan LEROY 65baf3fddb docs(infra): aligne le README du proxy sur nginx 1.31-alpine
Le tag de l'image y est cité deux fois (présentation, commande nginx -t) ;
il suit celui de docker-compose.prod.yml. Le runner du Dockerfile frontend
est une image durcie dhi.io, suivie séparément, et n'est pas concerné.
2026-09-22 08:38:03 +02:00
dependabot[bot]andGitHub 19f4ddc62f chore(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:24 +00:00
dependabot[bot]andGitHub 282472bae9 chore(deps): bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:23 +00:00
dependabot[bot]andGitHub 8d80cdc79d chore(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:23 +00:00
dependabot[bot]andGitHub b61287c8d2 chore(deps): bump astral-sh/setup-uv from 5 to 7
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 5 to 7.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v5...v7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:37:20 +00:00
Johan LEROYandGitHub a4be46c6e6 Merge pull request #129 from ineszang/dependabot/github_actions/actions/checkout-7
chore(deps): bump actions/checkout from 4 to 7
2026-09-22 08:36:32 +02:00
dependabot[bot]andGitHub 66a65991b5 chore(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:33:45 +00:00
Johan LEROYandGitHub 095cb4559b Merge pull request #142 from ineszang/chore/sonar-skip-dependabot
chore(ci): ignore le scan SonarQube pour les PR Dependabot
2026-09-22 08:31:48 +02:00
Johan LEROY d48b0eef4d chore(deps): ignore les majeures TypeScript et vitest côté Dependabot
Tant qu'Angular épingle typescript (>=6.0 <6.1) et vitest (^4), une majeure
de l'un ou l'autre casse npm ci. Dependabot arrête de les proposer ; à lever
quand Angular relâchera ces bornes.
2026-09-22 08:31:16 +02:00
Johan LEROY c8e8a44390 fix(frontend): garde vitest 4 et régénère le verrou pour TypeScript 6
@angular/build 22.1.8 exige typescript >=6.0 <6.1 et vitest ^4.0.8 : le
groupe Dependabot proposait TypeScript 7.0.2 et vitest 5.0.1, et npm ci
sortait en ERESOLVE. Le commit précédent avait ramené TypeScript à ~6.0.2
sans régénérer le verrou, qui pointait encore 7.0.2.

Verrou régénéré avec npm 11.19.0 (packageManager). Conservés : Angular
22.1.7, jsdom 30.1.0, prettier 3.9.8, @vitest/coverage-v8 aligné sur vitest.
2026-09-22 08:31:15 +02:00
Johan LEROY 401d5a3174 chore(ci): ignore le scan SonarQube pour les PR Dependabot
SonarQube / build-back (push) Successful in 1m20s
SonarQube / test-ml (push) Failing after 1m11s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 59s
SonarQube / test-front (push) Failing after 5m8s
SonarQube / SonarQube (push) Skipped
GitHub ne fournit pas les secrets du dépôt aux workflows déclenchés par
dependabot[bot] : SONAR_TOKEN arrive vide et le scan échoue sans rien
analyser, ce qui marque rouge toutes les PR de mise à jour de dépendances.
Les jobs de build et de tests du workflow restent joués sur ces PR.
2026-09-22 08:28:03 +02:00
Johan LEROYandGitHub 6a70443d52 Merge pull request #139 from ineszang/feat/deploy-rec-prod
feat(infra,ci): deux environnements rec et prod sur la VM ENI, déployés par un runner auto-hébergé
2026-09-22 08:27:08 +02:00
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
ValentinDeFariaandGitHub 1502eea036 Update package.json 2026-09-21 15:56:46 +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
PhyriosandGitHub cf50d3d36c Revise project milestones in README
Updated project milestones in the README to reflect new tasks and structure.
2026-09-21 14:42:41 +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
PhyriosandGitHub 4afcfb3ced Update project status in README.md 2026-09-21 14:28:56 +02:00
dependabot[bot]andGitHub 79b157d37e chore(deps): bump the frontend-dependencies group
Bumps the frontend-dependencies group in /apps/frontend with 12 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `22.1.6` | `22.1.7` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `22.1.6` | `22.1.7` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `22.1.6` | `22.1.7` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `22.1.6` | `22.1.7` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `22.1.6` | `22.1.7` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `22.1.6` | `22.1.7` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `22.1.6` | `22.1.7` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.11` | `5.0.1` |
| [jsdom](https://github.com/jsdom/jsdom) | `28.1.0` | `30.1.0` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.3` | `7.0.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.11` | `5.0.1` |


Updates `@angular/common` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/common)

Updates `@angular/compiler` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/compiler)

Updates `@angular/core` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/core)

Updates `@angular/forms` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/forms)

Updates `@angular/platform-browser` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/platform-browser)

Updates `@angular/router` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/router)

Updates `@angular/compiler-cli` from 22.1.6 to 22.1.7
- [Release notes](https://github.com/angular/angular/releases)
- [Commits](https://github.com/angular/angular/commits/v22.1.7/packages/compiler-cli)

Updates `@vitest/coverage-v8` from 4.1.11 to 5.0.1
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.1/packages/coverage-v8)

Updates `jsdom` from 28.1.0 to 30.1.0
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v28.1.0...v30.1.0)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.3...v7.0.2)

Updates `vitest` from 4.1.11 to 5.0.1
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.1/packages/vitest)

---
updated-dependencies:
- dependency-name: "@angular/common"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/compiler"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/core"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/forms"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/router"
  dependency-version: 22.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-version: 22.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: jsdom
  dependency-version: 30.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: vitest
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:19:44 +00:00
dependabot[bot]andGitHub 851e0279f6 chore(deps): bump the backend-dependencies group
Bumps the backend-dependencies group in /apps/backend with 2 updates: [pandas](https://github.com/pandas-dev/pandas) and [ruff](https://github.com/astral-sh/ruff).


Updates `pandas` from 3.0.5 to 3.0.6
- [Release notes](https://github.com/pandas-dev/pandas/releases)
- [Commits](https://github.com/pandas-dev/pandas/compare/v3.0.5...v3.0.6)

Updates `ruff` from 0.16.7 to 0.16.8
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.7...0.16.8)

---
updated-dependencies:
- dependency-name: pandas
  dependency-version: 3.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-dependencies
- dependency-name: ruff
  dependency-version: 0.16.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: backend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:19:00 +00:00
dependabot[bot]andGitHub 7f025f72ac chore(deps): bump nginx from 1.28-alpine to 1.31-alpine
Bumps nginx from 1.28-alpine to 1.31-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.31-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:17:47 +00:00
PhyriosandGitHub 1cd206adc8 Merge pull request #125 from ineszang/dev
Push dev to main
2026-09-21 14:17:01 +02:00
109 changed files with 6788 additions and 1524 deletions
+24 -3
View File
@@ -29,15 +29,18 @@ APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10 APP_MOCK_API_TIMEOUT_SECONDS=10
# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le # Airflow (api-server + scheduler + dag-processor, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus. # même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus.
AIRFLOW_PORT=8080 AIRFLOW_PORT=8080
# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre : # Chiffre les connexions/variables stockées par Airflow. Générer la vôtre :
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
AIRFLOW_FERNET_KEY=change_me AIRFLOW_FERNET_KEY=change_me
# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la # Clé de session de l'api-server Airflow, distincte de la précédente. Générer la
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" # vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_WEBSERVER_SECRET_KEY=change_me AIRFLOW_API_SECRET_KEY=change_me
# Secret des jetons JWT entre scheduler, tâches et api-server (conteneurs distincts, le secret
# doit être partagé). Même générateur que ci-dessus.
AIRFLOW_JWT_SECRET=change_me
AIRFLOW_ADMIN_USERNAME=admin AIRFLOW_ADMIN_USERNAME=admin
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les # Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
# comptes `app_user` d'EnerVision. # comptes `app_user` d'EnerVision.
@@ -53,3 +56,21 @@ AIRFLOW_APP_SECRET_KEY=change_me
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat. # PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
PUBLIC_HOST=enervision.local PUBLIC_HOST=enervision.local
ACME_EMAIL= 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. L'api-server Airflow 3 n'a rien à régler ici : son
# nombre de workers vaut 1 par défaut, contre 4 pour le webserver d'Airflow 2.
TS_TUNE_MEMORY=2GB
TS_TUNE_NUM_CPUS=2
+9
View File
@@ -10,6 +10,15 @@ updates:
frontend-dependencies: frontend-dependencies:
patterns: patterns:
- "*" - "*"
ignore:
# Pourquoi : @angular/build épingle typescript (>=6.0 <6.1) et vitest (^4). Une majeure
# de l'un ou l'autre casse `npm ci` tant qu'Angular ne suit pas.
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
- dependency-name: "vitest"
update-types: ["version-update:semver-major"]
- dependency-name: "@vitest/coverage-v8"
update-types: ["version-update:semver-major"]
# Backend — uv (lit pyproject.toml / uv.lock) # Backend — uv (lit pyproject.toml / uv.lock)
- package-ecosystem: "uv" - package-ecosystem: "uv"
+11 -9
View File
@@ -1,8 +1,9 @@
name: Airflow name: Airflow
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14 # Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de # (contrairement à backend.yml et ml.yml) : celui de l'image apache/airflow retenue, et les tests
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile). # doivent tourner sur le même interpréteur qu'elle. Le 3.14 de ml/ ne vit que dans l'image
# Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
# #
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une # Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans # modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans
@@ -47,10 +48,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: etl/airflow/uv.lock cache-dependency-glob: etl/airflow/uv.lock
@@ -78,7 +79,7 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/) - name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci . run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
@@ -91,11 +92,12 @@ jobs:
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help" 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 # `--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 # l'import des modules prouve que l'environnement /opt/backend est complet.
# commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui. # Les deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes.
- name: Vérifie que les deux commandes du DAG alertes s'importent sans réseau - name: Vérifie que les trois commandes backend s'importent sans réseau
run: > run: >
docker run --rm --network none enervision-airflow:ci docker run --rm --network none enervision-airflow:ci
bash -c "cd /opt/backend 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.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"
+8 -8
View File
@@ -30,10 +30,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -90,10 +90,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -126,10 +126,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -150,12 +150,12 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit. # aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des # Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod. # secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
+58
View File
@@ -0,0 +1,58 @@
# 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}"
compose="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
$compose ps
$compose logs --tail=50 backend proxy
exit 1
+10 -10
View File
@@ -17,10 +17,10 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
@@ -33,10 +33,10 @@ jobs:
name: Audit des dépendances name: Audit des dépendances
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison. # Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- run: npm audit --audit-level=high --package-lock-only - run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend working-directory: apps/frontend
@@ -45,10 +45,10 @@ jobs:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front) - name : Installation des dépendances (Front)
@@ -58,7 +58,7 @@ jobs:
run: npm test --watch=false --code-coverage --coverageReporters=lcov run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend working-directory: apps/frontend
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: frontend-coverage name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info path: apps/frontend/coverage/frontend/lcov.info
+52
View File
@@ -0,0 +1,52 @@
name: Infra
# Pourquoi : le Terraform du dépôt est resté cassé sans que rien ne le dise, faute de job qui le
# joue. Ce workflow n'applique rien : il vérifie le formatage et la validité de chaque racine.
# Piège : la boucle parcourt `environments/*`, pour qu'une racine ajoutée soit couverte sans
# toucher à ce fichier.
on:
push:
paths:
- "infra/terraform/**"
- ".github/workflows/infra.yml"
pull_request:
paths:
- "infra/terraform/**"
- ".github/workflows/infra.yml"
permissions:
contents: read
concurrency:
group: infra-${{ github.ref }}
cancel-in-progress: true
jobs:
terraform:
name: Formatage et validation Terraform
runs-on: ubuntu-latest
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
# Action tierce, donc epinglee sur un SHA de commit et pas sur un tag mobile : un tag se
# redeplace, et ce workflow tourne avec les droits du depot (regle Sonar githubactions:S7637).
- name: Installe Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.16.3
terraform_wrapper: false
- name: Vérifie le formatage
run: terraform fmt -check -recursive infra/terraform
- name: Valide chaque racine
run: |
for racine in infra/terraform/environments/*/; do
echo "::group::${racine}"
terraform -chdir="${racine}" init -backend=false -input=false
terraform -chdir="${racine}" validate
echo "::endgroup::"
done
+104 -6
View File
@@ -8,10 +8,28 @@ on:
paths: paths:
- "ml/**" - "ml/**"
- ".github/workflows/ml.yml" - ".github/workflows/ml.yml"
# Piege : `backend.yml` ne joue jamais `-m chaine`, ce job est le seul. Le test de chaine
# traverse tout `apps/backend/app` jusqu'a GET /predictions : restreindre le filtre aux
# migrations et aux modeles le laisserait muet sur la PR meme qui le casse. Meme raisonnement
# que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/.
- "apps/backend/alembic/**"
- "apps/backend/app/**"
- "apps/backend/tests/test_chaine_ml_api.py"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
pull_request: pull_request:
paths: paths:
- "ml/**" - "ml/**"
- ".github/workflows/ml.yml" - ".github/workflows/ml.yml"
# Piege : `backend.yml` ne joue jamais `-m chaine`, ce job est le seul. Le test de chaine
# traverse tout `apps/backend/app` jusqu'a GET /predictions : restreindre le filtre aux
# migrations et aux modeles le laisserait muet sur la PR meme qui le casse. Meme raisonnement
# que le filtre d'airflow.yml, qui inclut deja des chemins de ml/ et de apps/backend/.
- "apps/backend/alembic/**"
- "apps/backend/app/**"
- "apps/backend/tests/test_chaine_ml_api.py"
- "apps/backend/pyproject.toml"
- "apps/backend/uv.lock"
permissions: permissions:
contents: read contents: read
@@ -30,10 +48,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: ml/uv.lock cache-dependency-glob: ml/uv.lock
@@ -53,11 +71,91 @@ jobs:
- name: Typage - name: Typage
run: uv run mypy enervision_ml tests run: uv run mypy enervision_ml tests
# Aucun test ne touche PostgreSQL ni MLflow distant : tout tourne sur donnees # Les tests exigeant une base portent le marqueur `integration`, ecarte par defaut et
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py). # joue par le job `integration` ci-dessous.
- name: Tests - name: Tests
run: uv run pytest run: uv run pytest
# Le seul job du depot qui dispose a la fois des deux environnements uv et d'une base. Piege :
# le schema de la base ML est celui du backend (apps/backend/alembic, proprietaire du schema).
# Le reconstruire ici a la main rendrait ce job vert sur une base qui n'est pas la notre.
integration:
name: ML - DB et chaîne ML - DB - API
runs-on: ubuntu-latest
services:
db:
image: timescale/timescaledb-ha:pg17
env:
POSTGRES_USER: enervision
POSTGRES_PASSWORD: change_me
POSTGRES_DB: enervision_test
ports:
- "5433:5432"
options: >-
--health-cmd "pg_isready -U enervision -d enervision_test"
--health-interval 10s
--health-timeout 5s
--health-retries 12
--health-start-period 40s
env:
# Deux variables, deux dialectes : Alembic et l'API parlent asyncpg, le pipeline ML parle
# psycopg en synchrone. Cf. docs/ML-START.md, section 1.
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test
ML_DATABASE_URL: postgresql+psycopg://enervision:change_me@localhost:5433/enervision_test
APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur
PGPASSWORD: change_me
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: |
ml/uv.lock
apps/backend/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
working-directory: ml
run: uv python install
- name: Synchronise le pipeline ML sans dévier du verrou
working-directory: ml
run: uv sync --all-groups --frozen
# Le backend est installé ici parce qu'il porte les migrations, seule source du schéma, et
# le test de chaîne, qui interroge l'API.
- name: Synchronise le backend sans dévier du verrou
working-directory: apps/backend
run: uv sync --all-groups --frozen
# db/init/110-test-database.sql n'est pas monté ici, et sans l'extension la première
# révision Alembic refuse de s'appliquer.
- name: Active TimescaleDB sur la base de test
run: psql -h localhost -p 5433 -U enervision -d enervision_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
- name: Applique les migrations du backend, propriétaire du schéma
working-directory: apps/backend
run: uv run alembic upgrade head
# `-m` en ligne de commande écrase celui d'addopts. Couverture désactivée : ce job ne joue
# qu'une partie de la suite, son taux n'aurait pas de sens (même raison que backend.yml).
- name: Tests ML exigeant une base
working-directory: ml
run: uv run pytest -m integration --no-cov
# Lance les vrais binaires enervision_ml.train et .score en sous-processus, comme les DAGs
# ml_train et ml_score, puis relit le résultat par GET /api/v1/predictions.
- name: Chaîne complète ML vers DB vers API
working-directory: apps/backend
env:
ML_PYTHON: ${{ github.workspace }}/ml/.venv/bin/python
run: uv run pytest -m chaine --no-cov
sast: sast:
name: Analyse statique de sécurité name: Analyse statique de sécurité
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -67,12 +165,12 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit. # aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
- name: Analyse le code livré (bloquant à partir de MEDIUM) - name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
+22 -19
View File
@@ -24,10 +24,10 @@ jobs:
build-front: build-front:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
@@ -40,10 +40,10 @@ jobs:
needs: build-front needs: build-front
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
@@ -56,7 +56,7 @@ jobs:
working-directory: apps/frontend working-directory: apps/frontend
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: frontend-coverage name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info path: apps/frontend/coverage/frontend/lcov.info
@@ -64,9 +64,9 @@ jobs:
build-back: build-back:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -95,9 +95,9 @@ jobs:
needs: build-back needs: build-back
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -107,7 +107,7 @@ jobs:
working-directory: apps/backend working-directory: apps/backend
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: backend-coverage name: backend-coverage
path: apps/backend/coverage.xml path: apps/backend/coverage.xml
@@ -115,9 +115,9 @@ jobs:
test-ml: test-ml:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: ml/uv.lock cache-dependency-glob: ml/uv.lock
@@ -135,7 +135,7 @@ jobs:
working-directory: ml working-directory: ml
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ml-coverage name: ml-coverage
path: ml/coverage.xml path: ml/coverage.xml
@@ -143,23 +143,26 @@ jobs:
sonarqube: sonarqube:
needs: [build-front, build-back, test-front, test-back, test-ml] needs: [build-front, build-back, test-front, test-back, test-ml]
name: SonarQube name: SonarQube
# Pourquoi : GitHub ne fournit pas les secrets aux workflows lancés par dependabot[bot].
# Sans SONAR_TOKEN le scan échoue sans rien analyser ; build et tests restent joués.
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front) - name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: frontend-coverage name: frontend-coverage
path: apps/frontend/coverage/frontend path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back) - name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: backend-coverage name: backend-coverage
path: apps/backend path: apps/backend
- name: Téléchargement du rapport de couverture (ML) - name: Téléchargement du rapport de couverture (ML)
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: ml-coverage name: ml-coverage
path: ml path: ml
-1
View File
@@ -40,7 +40,6 @@ kubeconfig
# Airflow # Airflow
etl/airflow/logs/ etl/airflow/logs/
airflow.db airflow.db
airflow-webserver.pid
standalone_admin_password.txt standalone_admin_password.txt
# Environnement et secrets # Environnement et secrets
+86 -10
View File
@@ -13,11 +13,36 @@ ifdef ACME_EMAIL
export ACME_EMAIL export ACME_EMAIL
endif 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
# Piege : la base des tests d'integration n'est pas la base de developpement. Ces tests ecrivent
# et suppriment des lignes, et leurs fixtures refusent de demarrer ailleurs que sur
# `enervision_test` (garde sur le nom, cf. ml/tests/conftest.py).
PG_TEST_DB ?= enervision_test
TEST_DATABASE_URL ?= postgresql+asyncpg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB)
ML_TEST_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_TEST_DB)
# 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 .DEFAULT_GOAL := help
.PHONY: help install install-backend install-frontend install-ml install-airflow \ .PHONY: help install install-backend install-frontend install-ml install-airflow \
dev dev-backend dev-frontend \ dev dev-backend dev-frontend \
lint format typecheck test test-cov test-integration check \ lint format typecheck test test-cov test-integration ml-test-integration \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \ test-chaine check \
openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \
migrate migrate-test bootstrap-admin services-up demo-data demo-data-force \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \ 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 \ airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
@@ -39,12 +64,19 @@ install-ml: ## Installe les dépendances du pipeline ML
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
cd $(AIRFLOW) && uv sync --all-groups 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; \ @trap 'kill 0' EXIT INT TERM; \
$(MAKE) --no-print-directory dev-backend & \ $(MAKE) --no-print-directory dev-backend & \
$(MAKE) --no-print-directory dev-frontend & \ $(MAKE) --no-print-directory dev-frontend & \
wait 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-apiserver airflow-scheduler airflow-dag-processor
dev-backend: ## Lance l'API seule en rechargement à chaud dev-backend: ## Lance l'API seule en rechargement à chaud
@echo "backend -> http://localhost:8000 (docs sur /docs)" @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 cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
@@ -88,11 +120,21 @@ ml-test: ## Exécute les tests du pipeline ML (donnees synthetiques, sans base n
ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du pipeline ML
# La cible surcharge ML_DATABASE_URL, que ce Makefile exporte vers la base de développement : la
# garde du conftest ferait échouer la cible sans cette surcharge.
ml-test-integration: ML_DATABASE_URL := $(ML_TEST_DATABASE_URL)
ml-test-integration: ## Tests ML exigeant une base migrée. Faire `make db-up migrate-test` avant
cd $(ML) && uv run pytest -m integration --no-cov
test-chaine: ## Chaîne ML -> DB -> API, vrais binaires. Exige les deux environnements uv
cd $(BACKEND) && DATABASE_URL=$(TEST_DATABASE_URL) ML_PYTHON=$(CURDIR)/$(ML)/.venv/bin/python \
uv run pytest -m chaine --no-cov
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL 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),) 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 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),) 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 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),) cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
@@ -108,12 +150,12 @@ 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-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 (api-server + scheduler + dag-processor, LocalExecutor). db-up requis avant.
docker compose up -d airflow-init airflow-webserver airflow-scheduler docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}" @echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
airflow-down: ## Arrête le webserver et le scheduler Airflow airflow-down: ## Arrête l'api-server, le scheduler et le dag-processor Airflow
docker compose stop airflow-webserver airflow-scheduler docker compose stop airflow-apiserver airflow-scheduler airflow-dag-processor
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor) airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
docker compose logs -f airflow-scheduler docker compose logs -f airflow-scheduler
@@ -124,12 +166,15 @@ docker-build: ## Construit l'image du backend
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
./scripts/tls-selfsigned.sh $(if $(FORCE),--force,) ./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 \ @test -f infra/proxy/tls/fullchain.pem \
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; } || { 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 \ @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; } || { 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) up -d --build
$(COMPOSE_PROD) exec -T backend alembic upgrade head
stack-down: ## Arrête la stack complète en conservant les données stack-down: ## Arrête la stack complète en conservant les données
$(COMPOSE_PROD) stop $(COMPOSE_PROD) stop
@@ -165,8 +210,39 @@ db-logs: ## Suit les journaux de la base
db-psql: ## Ouvre une session psql sur la base applicative db-psql: ## Ouvre une session psql sur la base applicative
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision} 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 migrate: ## Applique les migrations Alembic
cd $(BACKEND) && uv run alembic upgrade head cd $(BACKEND) && uv run alembic upgrade head
migrate-test: ## Applique les migrations sur enervision_test, la base des tests d'intégration
cd $(BACKEND) && DATABASE_URL=$(TEST_DATABASE_URL) uv run alembic upgrade head
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier 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} 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
+55 -19
View File
@@ -9,8 +9,10 @@ series temporelles energetiques, deployee sur une machine on-premise.
|-------|----------------------------------------------------------| |-------|----------------------------------------------------------|
| J1 | Valider la préparation de l'environnement et du repo | | J1 | Valider la préparation de l'environnement et du repo |
| J2 | Valider le périmètre retenu et les choix technologiques | | J2 | Valider le périmètre retenu et les choix technologiques |
| J3 | Valider l'architecture et la gestion de la sécurité | | J3 | Ingestion & backend |
| J4 | Valider la robustesse et assurer les livrables | | J4 | Architecture, sécurité & frontend |
| J5 | Valider la robustesse et assurer les livrables |
| J6 | Amélioration possible |
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md). Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
@@ -18,15 +20,15 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Domaine | Technologie | Emplacement | Etat | | Domaine | Technologie | Emplacement | Etat |
|------------|-------------------------------------|---------------------|---------------| |------------|-------------------------------------|---------------------|---------------|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | | Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord | | Frontend | Angular 22, Node 26 | `apps/frontend` | En place |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
| ETL | Apache Airflow | `etl/airflow` | Trois DAGs | | ETL | Apache Airflow | `etl/airflow` | Cinq DAGs |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place | | CI/CD | GitHub Actions | `.github/workflows` | En place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
| ML | LightGBM, MLflow | `ml` | Entrainement initialise | | ML | LightGBM, MLflow | `ml` | En place |
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
@@ -48,7 +50,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
│ ├── migrations/ Migrations SQL versionnees │ ├── migrations/ Migrations SQL versionnees
│ └── seeds/ Jeux de donnees de reference │ └── seeds/ Jeux de donnees de reference
├── etl/airflow/ ├── etl/airflow/
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes) │ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import, dérive)
│ ├── plugins/ Operateurs et hooks maison │ ├── plugins/ Operateurs et hooks maison
│ ├── include/ Requetes SQL et ressources des DAGs │ ├── include/ Requetes SQL et ressources des DAGs
│ └── tests/ Tests d'integrite des DAGs │ └── tests/ Tests d'integrite des DAGs
@@ -75,25 +77,53 @@ Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Pyth
cp .env.example .env # variables de docker-compose cp .env.example .env # variables de docker-compose
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur 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, du frontend, du ML et des DAGs
make install # dependances du backend et du frontend make dev # toute la stack, voir ci-dessous
make migrate # applique les migrations Alembic
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
make check # lint + typage + tests 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_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et
`AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`,
`airflow-scheduler` et `airflow-dag-processor` 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. `make help` liste les cibles disponibles.
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`, 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 `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. 5432, souvent deja pris par une autre base.
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en Le backend et le frontend tournent sur le poste, lances ensemble par `make dev` (logs
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par entrelaces dans le meme terminal, Ctrl+C arrete les deux) ; la base, Mailpit et Airflow tournent
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend` en conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend` et n'embarque pas le source, donc toute modification y demande un
du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc `docker compose up -d --build backend`.
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 : Verifier que la base repond et que l'extension est chargee :
@@ -116,6 +146,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 [`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). [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 ## Conventions
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. - Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
@@ -0,0 +1,81 @@
"""rapports de derive du modele de prevision
Revision ID: d3f1a2b7c904
Revises: c0adab96238c
Create Date: 2026-09-22 14:40:00.000000
`site_id` est nullable, et c'est le coeur du schema : une ligne par site, plus une ligne
globale tous sites confondus, que `NULL` designe. Un seul site qui derive est invisible dans
une moyenne d'ensemble, et une derive d'ensemble sans rupture par site signale un changement
de modele ou de saison, pas une panne.
L'unicite passe par un index a `coalesce` et non par une `UniqueConstraint` : deux lignes
globales successives ont toutes deux `site_id` a NULL, et NULL n'est egal a aucune valeur, pas
meme a lui-meme. Meme forme que `uq_reading_source`.
Les trois `CHECK` sont portees par la base, comme `ck_prediction_status` : un verdict sans
motif, ou un statut inconnu, ne doit pas dependre de la vigilance de l'appelant.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "d3f1a2b7c904"
down_revision: str | Sequence[str] | None = "c0adab96238c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"drift_report",
sa.Column("drift_report_id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column(
"computed_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("site_id", sa.Text(), nullable=True),
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False),
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
sa.Column("reference_start", sa.DateTime(timezone=True), nullable=True),
sa.Column("reference_end", sa.DateTime(timezone=True), nullable=True),
sa.Column("n_observations", sa.Integer(), nullable=False),
sa.Column("mae", sa.Double(), nullable=True),
sa.Column("mape", sa.Double(), nullable=True),
sa.Column("bias", sa.Double(), nullable=True),
sa.Column("reference_mae", sa.Double(), nullable=True),
sa.Column("coverage_ratio", sa.Double(), nullable=True),
sa.Column("insufficient_data_ratio", sa.Double(), nullable=True),
sa.Column("model_references", postgresql.ARRAY(sa.Text()), nullable=False),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.CheckConstraint(
"status IN ('stable', 'derive', 'indetermine')", name="ck_drift_report_status"
),
sa.CheckConstraint(
"status = 'stable' OR reason IS NOT NULL", name="ck_drift_report_reason"
),
sa.CheckConstraint("n_observations >= 0", name="ck_drift_report_observations"),
sa.ForeignKeyConstraint(
["site_id"], ["site.site_id"], name="fk_drift_report_site", ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("drift_report_id"),
)
op.create_index(
"ix_drift_report_site_computed", "drift_report", ["site_id", "computed_at"], unique=False
)
op.create_index(
"uq_drift_report_window",
"drift_report",
["window_end", sa.literal_column("coalesce(site_id, '')")],
unique=True,
)
def downgrade() -> None:
op.drop_table("drift_report")
+9
View File
@@ -24,6 +24,7 @@ from app.core.security import decode_access_token as decode_token
from app.db.session import get_session from app.db.session import get_session
from app.repositories.alert import AlertRepository from app.repositories.alert import AlertRepository
from app.repositories.audit_log import AuditLogRepository from app.repositories.audit_log import AuditLogRepository
from app.repositories.drift import DriftRepository
from app.repositories.login_attempt import LoginAttemptRepository from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.password_reset_attempt import PasswordResetAttemptRepository from app.repositories.password_reset_attempt import PasswordResetAttemptRepository
from app.repositories.password_reset_token import PasswordResetTokenRepository from app.repositories.password_reset_token import PasswordResetTokenRepository
@@ -35,6 +36,7 @@ from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository from app.repositories.user import UserRepository
from app.services.alert import AlertService from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy from app.services.auth import AuthService, LoginPolicy, PasswordResetPolicy
from app.services.drift import DriftService
from app.services.prediction import PredictionService from app.services.prediction import PredictionService
from app.services.reading import ReadingService from app.services.reading import ReadingService
from app.services.recommendation import RecommendationService from app.services.recommendation import RecommendationService
@@ -232,6 +234,13 @@ def get_prediction_service(session: SessionDep) -> PredictionService:
PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)] PredictionServiceDep = Annotated[PredictionService, Depends(get_prediction_service)]
def get_drift_service(session: SessionDep) -> DriftService:
return DriftService(DriftRepository(session))
DriftServiceDep = Annotated[DriftService, Depends(get_drift_service)]
async def get_current_principal( async def get_current_principal(
credentials: CredentialsDep, credentials: CredentialsDep,
session: SessionDep, session: SessionDep,
+19
View File
@@ -90,6 +90,14 @@ TAGS: Final[list[dict[str, Any]]] = [
"de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." "de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
), ),
}, },
{
"name": "monitoring",
"description": (
"Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et "
"les lectures réellement arrivées, par site et tous sites confondus. Réservé à "
"partir du rôle `operateur`, qui agit sur un pipeline dégradé."
),
},
] ]
cookie_de_rafraichissement = APIKeyCookie( cookie_de_rafraichissement = APIKeyCookie(
@@ -153,6 +161,17 @@ REPONSES_ADMIN: Final[Reponses] = {
}, },
} }
REPONSES_OPERATEUR: Final[Reponses] = {
**REPONSES_AUTHENTIFIEES,
403: {
"model": ErrorResponse,
"description": (
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
"`password_change_required`."
),
},
}
# `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits # `lecteur` est le rôle minimum : `require_role` n'y refuse jamais un 403 pour droits
# insuffisants, seulement pour le mot de passe provisoire. # insuffisants, seulement pour le mot de passe provisoire.
REPONSES_LECTEUR: Final[Reponses] = { REPONSES_LECTEUR: Final[Reponses] = {
@@ -0,0 +1,20 @@
from fastapi import APIRouter
from app.api.deps import DriftServiceDep, OperateurDep
from app.api.openapi import REPONSE_VALIDATION
from app.schemas.drift import DriftReportResponse
router = APIRouter()
@router.get(
"/drift",
response_model=list[DriftReportResponse],
summary="Dernier rapport de dérive par site, plus la ligne globale",
responses=REPONSE_VALIDATION,
)
async def get_drift(
_: OperateurDep, service: DriftServiceDep, site_id: str | None = None
) -> list[DriftReportResponse]:
rapports = await service.derniers(site_id=site_id)
return [DriftReportResponse.model_validate(rapport) for rapport in rapports]
+10 -1
View File
@@ -1,10 +1,16 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR from app.api.openapi import (
REPONSE_SERVEUR,
REPONSES_ADMIN,
REPONSES_LECTEUR,
REPONSES_OPERATEUR,
)
from app.api.v1.endpoints import ( from app.api.v1.endpoints import (
alerts, alerts,
auth, auth,
health, health,
monitoring,
predictions, predictions,
readings, readings,
recommendations, recommendations,
@@ -38,3 +44,6 @@ api_router.include_router(
api_router.include_router( api_router.include_router(
predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR predictions.router, prefix="/predictions", tags=["predictions"], responses=REPONSES_LECTEUR
) )
api_router.include_router(
monitoring.router, prefix="/monitoring", tags=["monitoring"], responses=REPONSES_OPERATEUR
)
+10 -1
View File
@@ -2,7 +2,15 @@
# --autogenerate`, qui générerait alors un drop de sa table. # --autogenerate`, qui générerait alors un drop de sa table.
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.energy import (
Alert,
Dataset,
DriftReport,
Prediction,
Reading,
Recommendation,
Site,
)
from app.models.login_attempt import LoginAttempt from app.models.login_attempt import LoginAttempt
from app.models.password_reset_attempt import PasswordResetAttempt from app.models.password_reset_attempt import PasswordResetAttempt
from app.models.password_reset_token import PasswordResetToken from app.models.password_reset_token import PasswordResetToken
@@ -14,6 +22,7 @@ __all__ = [
"AppUser", "AppUser",
"AuditLog", "AuditLog",
"Dataset", "Dataset",
"DriftReport",
"LoginAttempt", "LoginAttempt",
"PasswordResetAttempt", "PasswordResetAttempt",
"PasswordResetToken", "PasswordResetToken",
+46
View File
@@ -208,3 +208,49 @@ class Recommendation(Base):
explanation: Mapped[str] = mapped_column(Text) explanation: Mapped[str] = mapped_column(Text)
rule_reference: Mapped[str] = mapped_column(Text) rule_reference: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class DriftReport(Base):
__tablename__ = "drift_report"
__table_args__ = (
CheckConstraint(
"status IN ('stable', 'derive', 'indetermine')", name="ck_drift_report_status"
),
CheckConstraint("status = 'stable' OR reason IS NOT NULL", name="ck_drift_report_reason"),
CheckConstraint("n_observations >= 0", name="ck_drift_report_observations"),
Index("ix_drift_report_site_computed", "site_id", "computed_at"),
)
drift_report_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
computed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# `NULL` porte la ligne globale, tous sites confondus : une derive d'ensemble et la derive
# d'un seul site ne se lisent pas dans le meme chiffre.
site_id: Mapped[str | None] = mapped_column(
Text, ForeignKey("site.site_id", name="fk_drift_report_site", ondelete="RESTRICT")
)
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True))
window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True))
reference_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
reference_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
n_observations: Mapped[int] = mapped_column(Integer)
mae: Mapped[float | None] = mapped_column(Double)
mape: Mapped[float | None] = mapped_column(Double)
bias: Mapped[float | None] = mapped_column(Double)
reference_mae: Mapped[float | None] = mapped_column(Double)
coverage_ratio: Mapped[float | None] = mapped_column(Double)
insufficient_data_ratio: Mapped[float | None] = mapped_column(Double)
model_references: Mapped[list[str]] = mapped_column(ARRAY(Text))
status: Mapped[str] = mapped_column(Text)
reason: Mapped[str | None] = mapped_column(Text)
# Piège : une `UniqueConstraint` ne dédoublonnerait pas les lignes globales, dont `site_id` est
# NULL et qu'aucune n'est égale à une autre. Même forme que `uq_reading_source`.
Index(
"uq_drift_report_window",
DriftReport.window_end,
func.coalesce(DriftReport.site_id, text("''")),
unique=True,
)
+105
View File
@@ -0,0 +1,105 @@
# Surveillance de dérive du modèle de prévision (EC06, issue #45) : même gabarit que
# `app.detection.internal_alerts`, ordonnancé par le DAG `derive`.
from __future__ import annotations
import argparse
import asyncio
import sys
from datetime import UTC, datetime, timedelta
from app.core.config import get_settings
from app.db.session import get_session_factory
from app.repositories.drift import DriftRepository, NouveauRapportDerive
from app.services.drift import STATUT_DERIVE, DriftService, Seuils
async def run_drift(
*, now: datetime | None = None, site_id: str | None = None, seuils: Seuils | None = None
) -> list[NouveauRapportDerive]:
"""Calcule les rapports de la fenêtre et les enregistre. Rend ce qui a été calculé, que la
ligne ait été écrite ou ignorée par l'index d'idempotence."""
async with get_session_factory()() as session:
depot = DriftRepository(session)
rapports = await DriftService(depot, seuils=seuils).evaluate(now=now, site_id=site_id)
await depot.enregistre(rapports)
await session.commit()
return rapports
def _parse_instant(valeur: str) -> datetime:
instant = datetime.fromisoformat(valeur)
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
defauts = Seuils()
parser = argparse.ArgumentParser(
prog="python -m app.monitoring.drift",
description="Surveillance de dérive du modèle de prévision EnerVision",
)
parser.add_argument("--site-id", default=None, help="Limite le calcul à un seul site.")
parser.add_argument(
"--now",
type=_parse_instant,
default=None,
help=(
"Instant de référence (ISO 8601, UTC si le fuseau est omis). Défaut : l'heure courante."
),
)
parser.add_argument(
"--window-hours",
type=int,
default=int(defauts.fenetre.total_seconds() // 3600),
help="Durée de la fenêtre récente, et de la fenêtre de référence qui la précède.",
)
parser.add_argument(
"--grace-hours",
type=int,
default=int(defauts.grace.total_seconds() // 3600),
help="Délai laissé à l'ingestion avant qu'une prévision soit jugée vérifiable.",
)
parser.add_argument(
"--min-observations",
type=int,
default=defauts.min_observations,
help="En deçà, le verdict est `indetermine` plutôt qu'un chiffre trompeur.",
)
parser.add_argument(
"--fail-on-drift",
action="store_true",
help="Sort en code non nul si une dérive est constatée, pour que la tâche rougisse.",
)
return parser.parse_args(argv)
def seuils_depuis(args: argparse.Namespace) -> Seuils:
return Seuils(
fenetre=timedelta(hours=args.window_hours),
grace=timedelta(hours=args.grace_hours),
min_observations=args.min_observations,
)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
# Échoue tôt si `APP_SECRET_KEY`/`DATABASE_URL` manquent, avant toute requête à la base.
get_settings()
rapports = asyncio.run(
run_drift(now=args.now, site_id=args.site_id, seuils=seuils_depuis(args))
)
for rapport in rapports:
cible = rapport.site_id or "TOUS SITES"
mae = f"{rapport.mae:.2f}" if rapport.mae is not None else "-"
print(
f"{cible} : {rapport.status}, MAE {mae} kWh sur {rapport.n_observations} prévision(s)"
f"{' : ' + rapport.reason if rapport.reason else ''}"
)
derive = any(rapport.status == STATUT_DERIVE for rapport in rapports)
return 1 if derive and args.fail_on_drift else 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+11 -2
View File
@@ -6,6 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert 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: class AlertRepository:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
@@ -44,12 +48,17 @@ class AlertRepository:
} }
for alerte in alerts for alerte in alerts
] ]
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 = ( requete = (
insert(Alert) insert(Alert)
.values(valeurs) .values(valeurs[debut : debut + TAILLE_DE_LOT])
.on_conflict_do_nothing(constraint="uq_alert_source_reference") .on_conflict_do_nothing(constraint="uq_alert_source_reference")
.returning(Alert) .returning(Alert)
) )
resultat = await self._session.execute(requete) resultat = await self._session.execute(requete)
creees.extend(resultat.scalars().all())
await self._session.flush() await self._session.flush()
return resultat.scalars().all() return creees
+188
View File
@@ -0,0 +1,188 @@
"""Piège : deux dédoublonnages, pas un - DriftRepository.paires()
`prediction` n'a pas d'unicité sur `(site_id, target_at)` : chaque run de scoring empile une
ligne de plus. `uq_reading_source` autorise de son côté deux lectures au même instant quand la
`source` diffère. Joindre les deux tables sans `DISTINCT ON` des deux côtés compterait donc la
même heure plusieurs fois, et la moyenne d'erreur pèserait ces sites en double.
On retient la prédiction du run le plus récent, celle que sert `GET /api/v1/predictions`, avec
`prediction_id` en départage : `created_at` vaut l'heure de début de transaction et ne
distingue pas deux lignes du même run.
Côté lectures, le départage est `reading_id` décroissant, la règle même de
`GET /api/v1/sites/{site_id}/current`. La dérive se mesure donc contre le réalisé que l'API
affiche, et non contre une source élue ici et nulle part ailleurs.
"""
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from datetime import datetime
from sqlalchemy import Subquery, func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import DriftReport, Prediction, Reading
TARGET_METRIC = "consumption_kwh"
STATUT_DISPONIBLE = "available"
@dataclass(frozen=True, slots=True)
class PaireDerive:
site_id: str
target_at: datetime
predicted_value: float
actual_value: float
model_reference: str
@dataclass(frozen=True, slots=True)
class NouveauRapportDerive:
site_id: str | None
window_start: datetime
window_end: datetime
reference_start: datetime | None
reference_end: datetime | None
n_observations: int
mae: float | None
mape: float | None
bias: float | None
reference_mae: float | None
coverage_ratio: float | None
insufficient_data_ratio: float | None
model_references: list[str]
status: str
reason: str | None
@dataclass(frozen=True, slots=True)
class ComptageStatut:
site_id: str
status: str
nombre: int
def _predictions_retenues(*, debut: datetime, fin: datetime, site_id: str | None) -> Subquery:
requete = (
select(
Prediction.site_id,
Prediction.target_at,
Prediction.predicted_value,
Prediction.model_reference,
Prediction.status,
)
.distinct(Prediction.site_id, Prediction.target_at)
.where(
Prediction.target_metric == TARGET_METRIC,
Prediction.target_at >= debut,
Prediction.target_at < fin,
)
.order_by(Prediction.site_id, Prediction.target_at, Prediction.prediction_id.desc())
)
if site_id is not None:
requete = requete.where(Prediction.site_id == site_id)
return requete.subquery()
def _lectures_retenues(*, debut: datetime, fin: datetime, site_id: str | None) -> Subquery:
requete = (
select(Reading.site_id, Reading.timestamp, Reading.consumption_kwh)
.distinct(Reading.site_id, Reading.timestamp)
.where(
Reading.timestamp >= debut,
Reading.timestamp < fin,
Reading.consumption_kwh.is_not(None),
)
.order_by(Reading.site_id, Reading.timestamp, Reading.reading_id.desc())
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
return requete.subquery()
class DriftRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def paires(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[PaireDerive]:
predictions = _predictions_retenues(debut=debut, fin=fin, site_id=site_id)
lectures = _lectures_retenues(debut=debut, fin=fin, site_id=site_id)
requete = (
select(
predictions.c.site_id,
predictions.c.target_at,
predictions.c.predicted_value,
lectures.c.consumption_kwh,
predictions.c.model_reference,
)
.select_from(predictions)
.join(
lectures,
(lectures.c.site_id == predictions.c.site_id)
& (lectures.c.timestamp == predictions.c.target_at),
)
.where(predictions.c.status == STATUT_DISPONIBLE)
.order_by(predictions.c.site_id, predictions.c.target_at)
)
lignes = await self._session.execute(requete)
return [
PaireDerive(
site_id=ligne[0],
target_at=ligne[1],
predicted_value=ligne[2],
actual_value=ligne[3],
model_reference=ligne[4],
)
for ligne in lignes
]
async def comptages(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[ComptageStatut]:
predictions = _predictions_retenues(debut=debut, fin=fin, site_id=site_id)
requete = (
select(predictions.c.site_id, predictions.c.status, func.count())
.select_from(predictions)
.group_by(predictions.c.site_id, predictions.c.status)
)
lignes = await self._session.execute(requete)
return [
ComptageStatut(site_id=ligne[0], status=ligne[1], nombre=ligne[2]) for ligne in lignes
]
# Pourquoi : l'idempotence est déléguée à `uq_drift_report_window` plutôt qu'à une lecture
# préalable, comme pour les recommandations. Rejouer la commande sur la même fenêtre ne
# duplique donc rien.
async def enregistre(self, rapports: Sequence[NouveauRapportDerive]) -> int:
if not rapports:
return 0
valeurs = [asdict(rapport) for rapport in rapports]
requete = (
insert(DriftReport)
.values(valeurs)
.on_conflict_do_nothing(
index_elements=[DriftReport.window_end, func.coalesce(DriftReport.site_id, "")]
)
.returning(DriftReport.drift_report_id)
)
return len((await self._session.scalars(requete)).all())
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
requete = (
select(DriftReport)
.distinct(DriftReport.site_id)
.order_by(
DriftReport.site_id,
DriftReport.computed_at.desc(),
DriftReport.drift_report_id.desc(),
)
)
if site_id is not None:
requete = requete.where(DriftReport.site_id == site_id)
return (await self._session.scalars(requete)).all()
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict
class DriftStatus(StrEnum):
STABLE = "stable"
DERIVE = "derive"
INDETERMINE = "indetermine"
class DriftReportResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str | None
computed_at: datetime
window_start: datetime
window_end: datetime
reference_start: datetime | None
reference_end: datetime | None
n_observations: int
mae: float | None
mape: float | None
bias: float | None
reference_mae: float | None
coverage_ratio: float | None
insufficient_data_ratio: float | None
model_references: list[str]
status: DriftStatus
reason: str | None
+245
View File
@@ -0,0 +1,245 @@
"""Contrainte : la dérive se mesure sur ce qui a déjà eu lieu - DriftService.evaluate()
Une prévision ne devient vérifiable que quand la lecture de son instant cible est ingérée. La
fenêtre est donc fermée à droite par un délai de grâce : sans lui, la dernière heure ferait
chuter le taux de couverture à chaque exécution, et le verdict dirait « dérive » alors que
seule l'ingestion n'avait pas fini son tour.
La comparaison se fait entre deux fenêtres vives de même durée, pas contre la métrique de
référence du modèle journalisée à l'entraînement. Ce ne sont pas les mêmes grandeurs :
l'entraînement mesure un backtest où la météo de l'heure cible est connue, le scoring prévoit
une heure future dont la météo ne l'est pas. Les comparer classerait le modèle « en dérive »
dès le premier jour, ce qui ne prouverait rien.
"""
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
from app.models.energy import DriftReport
from app.repositories.drift import (
ComptageStatut,
DriftRepository,
NouveauRapportDerive,
PaireDerive,
)
STATUT_STABLE = "stable"
STATUT_DERIVE = "derive"
STATUT_INDETERMINE = "indetermine"
STATUT_INSUFFISANT = "insufficient_data"
STATUT_DISPONIBLE = "available"
@dataclass(frozen=True, slots=True)
class Seuils:
# 168 h, la saisonnalité hebdomadaire que le modèle apprend par son lag principal : une
# fenêtre plus courte comparerait un week-end à une semaine ouvrée.
fenetre: timedelta = timedelta(hours=168)
grace: timedelta = timedelta(hours=2)
min_observations: int = 24
ratio_derive: float = 1.25
mae_plancher: float = 0.0
seuil_biais: float = 0.0
seuil_couverture: float = 0.8
@dataclass(frozen=True, slots=True)
class Metriques:
n_observations: int
mae: float | None
mape: float | None
bias: float | None
model_references: list[str]
def mesure(paires: Sequence[PaireDerive]) -> Metriques:
if not paires:
return Metriques(n_observations=0, mae=None, mape=None, bias=None, model_references=[])
ecarts = [paire.predicted_value - paire.actual_value for paire in paires]
# Le MAPE diverge sur une consommation nulle : les sites à l'arrêt sortent de ce seul
# rapport, jamais des autres métriques.
ratios = [
abs(ecart / paire.actual_value)
for ecart, paire in zip(ecarts, paires, strict=True)
if paire.actual_value != 0
]
return Metriques(
n_observations=len(paires),
mae=sum(abs(ecart) for ecart in ecarts) / len(ecarts),
mape=(sum(ratios) / len(ratios) * 100) if ratios else None,
bias=sum(ecarts) / len(ecarts),
model_references=sorted({paire.model_reference for paire in paires}),
)
@dataclass(frozen=True, slots=True)
class Verdict:
status: str
reason: str | None
class DriftService:
def __init__(self, depot: DriftRepository, *, seuils: Seuils | None = None) -> None:
self._depot = depot
self._seuils = seuils or Seuils()
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
"""Ce que sert l'API : le dernier rapport de chaque site, plus la ligne globale."""
return await self._depot.derniers(site_id=site_id)
async def evaluate(
self, *, now: datetime | None = None, site_id: str | None = None
) -> list[NouveauRapportDerive]:
"""Une ligne par site, plus une ligne globale dont le `site_id` est nul."""
# Piège : `window_end` est la clé d'idempotence de `uq_drift_report_window`. Sans
# troncature à l'heure, deux exécutions ne collident jamais, aux microsecondes près.
instant = (now or datetime.now(UTC)).replace(minute=0, second=0, microsecond=0)
fin = instant - self._seuils.grace
debut = fin - self._seuils.fenetre
reference_fin = debut
reference_debut = reference_fin - self._seuils.fenetre
recentes = await self._depot.paires(debut=debut, fin=fin, site_id=site_id)
anciennes = await self._depot.paires(
debut=reference_debut, fin=reference_fin, site_id=site_id
)
comptages = await self._depot.comptages(debut=debut, fin=fin, site_id=site_id)
gabarit = NouveauRapportDerive(
site_id=None,
window_start=debut,
window_end=fin,
reference_start=reference_debut,
reference_end=reference_fin,
n_observations=0,
mae=None,
mape=None,
bias=None,
reference_mae=None,
coverage_ratio=None,
insufficient_data_ratio=None,
model_references=[],
status=STATUT_INDETERMINE,
reason=None,
)
rapports = [
self._rapport(
gabarit,
site=site,
recentes=[p for p in recentes if p.site_id == site],
anciennes=[p for p in anciennes if p.site_id == site],
comptages=[c for c in comptages if c.site_id == site],
)
# Piège : un site présent dans la référence et absent de la fenêtre récente a cessé
# d'être scoré. C'est la panne à crier, pas une ligne à omettre.
for site in sorted(
{paire.site_id for paire in recentes}
| {paire.site_id for paire in anciennes}
| {c.site_id for c in comptages}
)
]
rapports.append(
self._rapport(
gabarit, site=None, recentes=recentes, anciennes=anciennes, comptages=comptages
)
)
return rapports
def _rapport(
self,
gabarit: NouveauRapportDerive,
*,
site: str | None,
recentes: Sequence[PaireDerive],
anciennes: Sequence[PaireDerive],
comptages: Sequence[ComptageStatut],
) -> NouveauRapportDerive:
metriques = mesure(recentes)
reference = mesure(anciennes)
couverture = _couverture(len(recentes), comptages)
verdict = self._verdict(metriques, reference_mae=reference.mae, couverture=couverture)
return replace(
gabarit,
site_id=site,
n_observations=metriques.n_observations,
mae=metriques.mae,
mape=metriques.mape,
bias=metriques.bias,
reference_mae=reference.mae,
coverage_ratio=couverture,
insufficient_data_ratio=_part_insuffisante(comptages),
model_references=metriques.model_references,
status=verdict.status,
reason=verdict.reason,
)
def _verdict(
self, metriques: Metriques, *, reference_mae: float | None, couverture: float | None
) -> Verdict:
seuils = self._seuils
if metriques.n_observations < seuils.min_observations:
return Verdict(
STATUT_INDETERMINE,
f"{metriques.n_observations} prévision(s) vérifiée(s) sur la fenêtre, "
f"minimum {seuils.min_observations}.",
)
if couverture is not None and couverture < seuils.seuil_couverture:
return Verdict(
STATUT_DERIVE,
f"Couverture de {couverture:.0%}, sous le seuil de {seuils.seuil_couverture:.0%} : "
"le pipeline, pas le modèle.",
)
plafond = _plafond(reference_mae, ratio=seuils.ratio_derive, plancher=seuils.mae_plancher)
if plafond is None:
return Verdict(
STATUT_INDETERMINE,
"Fenêtre de référence sans paire vérifiée, et aucun plancher de MAE : "
"rien à quoi comparer cette fenêtre.",
)
if metriques.mae is not None and metriques.mae > plafond:
return Verdict(
STATUT_DERIVE,
f"MAE de {metriques.mae:.2f} kWh au-delà de {plafond:.2f} kWh, "
"seuil dérivé de la fenêtre de référence.",
)
if (
seuils.seuil_biais > 0
and metriques.bias is not None
and abs(metriques.bias) > seuils.seuil_biais
):
return Verdict(
STATUT_DERIVE,
f"Biais de {metriques.bias:+.2f} kWh : le modèle se trompe toujours du même côté.",
)
return Verdict(STATUT_STABLE, None)
def _plafond(reference_mae: float | None, *, ratio: float, plancher: float) -> float | None:
if reference_mae is None:
return plancher or None
return max(plancher, reference_mae * ratio)
def _couverture(apparie: int, comptages: Sequence[ComptageStatut]) -> float | None:
"""Part des prévisions disponibles qui ont trouvé leur réalisé. Mesure l'ingestion et
l'ordonnancement, pas la qualité du modèle."""
disponibles = sum(c.nombre for c in comptages if c.status == STATUT_DISPONIBLE)
return apparie / disponibles if disponibles else None
def _part_insuffisante(comptages: Sequence[ComptageStatut]) -> float | None:
total = sum(c.nombre for c in comptages)
if not total:
return None
return sum(c.nombre for c in comptages if c.status == STATUT_INSUFFISANT) / total
+266
View File
@@ -1859,6 +1859,94 @@
} }
] ]
} }
},
"/api/v1/monitoring/drift": {
"get": {
"tags": [
"monitoring"
],
"summary": "Dernier rapport de dérive par site, plus la ligne globale",
"operationId": "get_drift_api_v1_monitoring_drift_get",
"security": [
{
"Jeton d'accès": []
}
],
"parameters": [
{
"name": "site_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DriftReportResponse"
},
"title": "Response Get Drift Api V1 Monitoring Drift Get"
}
}
}
},
"500": {
"description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InternalErrorResponse"
}
}
}
},
"401": {
"description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationErrorResponse"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
@@ -1977,6 +2065,180 @@
], ],
"title": "AlertType" "title": "AlertType"
}, },
"DriftReportResponse": {
"properties": {
"site_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
},
"computed_at": {
"type": "string",
"format": "date-time",
"title": "Computed At"
},
"window_start": {
"type": "string",
"format": "date-time",
"title": "Window Start"
},
"window_end": {
"type": "string",
"format": "date-time",
"title": "Window End"
},
"reference_start": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Reference Start"
},
"reference_end": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Reference End"
},
"n_observations": {
"type": "integer",
"title": "N Observations"
},
"mae": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Mae"
},
"mape": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Mape"
},
"bias": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Bias"
},
"reference_mae": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Reference Mae"
},
"coverage_ratio": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Coverage Ratio"
},
"insufficient_data_ratio": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Insufficient Data Ratio"
},
"model_references": {
"items": {
"type": "string"
},
"type": "array",
"title": "Model References"
},
"status": {
"$ref": "#/components/schemas/DriftStatus"
},
"reason": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Reason"
}
},
"type": "object",
"required": [
"site_id",
"computed_at",
"window_start",
"window_end",
"reference_start",
"reference_end",
"n_observations",
"mae",
"mape",
"bias",
"reference_mae",
"coverage_ratio",
"insufficient_data_ratio",
"model_references",
"status",
"reason"
],
"title": "DriftReportResponse"
},
"DriftStatus": {
"type": "string",
"enum": [
"stable",
"derive",
"indetermine"
],
"title": "DriftStatus"
},
"ErrorResponse": { "ErrorResponse": {
"properties": { "properties": {
"detail": { "detail": {
@@ -3277,6 +3539,10 @@
{ {
"name": "predictions", "name": "predictions",
"description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`." "description": "Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`) et simplement lue ici. Accessible à partir du rôle `lecteur`."
},
{
"name": "monitoring",
"description": "Surveillance de la dérive du modèle : écart entre les prévisions déjà écrites et les lectures réellement arrivées, par site et tous sites confondus. Réservé à partir du rôle `operateur`, qui agit sur un pipeline dégradé."
} }
] ]
} }
+5 -2
View File
@@ -87,8 +87,11 @@ disallow_untyped_defs = false
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"
addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing" addopts = "-q --strict-markers -m 'not integration and not chaine' --cov=app --cov-report=term-missing"
markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"] markers = [
"integration: requiert une base PostgreSQL joignable, hors `make test`",
"chaine: requiert en plus l'environnement uv de ml/, hors `make test` et hors `-m integration`",
]
[tool.coverage.run] [tool.coverage.run]
source = ["app"] source = ["app"]
+1
View File
@@ -56,6 +56,7 @@ ROLE_MINIMUM: Final[dict[Route, Role]] = {
("GET", "/api/v1/readings"): Role.LECTEUR, ("GET", "/api/v1/readings"): Role.LECTEUR,
("GET", "/api/v1/predictions"): Role.LECTEUR, ("GET", "/api/v1/predictions"): Role.LECTEUR,
("GET", "/api/v1/sensors/status"): Role.ADMIN, ("GET", "/api/v1/sensors/status"): Role.ADMIN,
("GET", "/api/v1/monitoring/drift"): Role.OPERATEUR,
("GET", "/api/v1/users"): Role.ADMIN, ("GET", "/api/v1/users"): Role.ADMIN,
("POST", "/api/v1/users"): Role.ADMIN, ("POST", "/api/v1/users"): Role.ADMIN,
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN, ("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
+132
View File
@@ -0,0 +1,132 @@
"""Piège : ces fixtures valident leurs écritures, contrairement à celles de tests/repositories.
Un endpoint ouvre sa propre session par `get_session` : il ne verrait pas une ligne semée dans
une transaction en cours. Lui passer la session de la fixture par `dependency_overrides`
supprimerait justement ce que ces tests prouvent, et `RecommendationService.generate` valide de
toute façon lui-même. L'isolation vient donc de la marque portée par chaque `site_id`, et le
nettoyage est explicite, dans l'ordre imposé par les clés étrangères `RESTRICT`.
Contrainte : toutes ces fixtures sont à portée fonction. `engine_per_test` vide le cache du
moteur après chaque test ; une fixture de module verrait un moteur déjà fermé à son démontage,
et ses lignes resteraient en base.
"""
from collections.abc import AsyncIterator, Callable, Iterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from fastapi import FastAPI
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_principal
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.db.session import get_session_factory
from app.models.energy import Alert, DriftReport, Prediction, Reading, Recommendation, Site
from tests.repositories.test_alert import creer_alerte
from tests.repositories.test_prediction import creer_prediction
from tests.repositories.test_reading import creer_lecture
from tests.repositories.test_site import creer as creer_site
INSTANT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
@dataclass(frozen=True)
class JeuMetier:
"""Identifiants seuls, jamais d'instance ORM : un attribut relu sur une session fermée
déclenche un `MissingGreenlet`."""
site_id: str
site_voisin: str
alert_id: int
prediction_id: int
instant: datetime
async def _supprime(session: AsyncSession, sites: list[str]) -> None:
# La suppression des recommandations est inconditionnelle : `POST /generate` en cree hors du
# controle de la fixture, et `alert` les retient par une cle etrangere `RESTRICT`.
alertes = select(Alert.alert_id).where(Alert.site_id.in_(sites))
await session.execute(delete(Recommendation).where(Recommendation.alert_id.in_(alertes)))
await session.execute(delete(Alert).where(Alert.site_id.in_(sites)))
await session.execute(delete(DriftReport).where(DriftReport.site_id.in_(sites)))
await session.execute(delete(Prediction).where(Prediction.site_id.in_(sites)))
await session.execute(delete(Reading).where(Reading.site_id.in_(sites)))
await session.execute(delete(Site).where(Site.site_id.in_(sites)))
await session.commit()
@pytest.fixture
def marque() -> str:
return uuid4().hex[:12]
@pytest.fixture
async def jeu_metier(marque: str) -> AsyncIterator[JeuMetier]:
"""Un site instrumenté, un site voisin, trois lectures horaires, une prédiction, une alerte.
Le voisin existe pour que les tests de filtre prouvent qu'ils écartent quelque chose.
"""
site_id = f"SITE-{marque}"
voisin = f"SITE-{marque}-VOISIN"
async with get_session_factory()() as session:
await creer_site(session, site_id=site_id, capacity_kw=100.0)
await creer_site(session, site_id=voisin, capacity_kw=100.0)
for decalage in range(3):
await creer_lecture(
session,
site_id=site_id,
timestamp=INSTANT - timedelta(hours=decalage),
consumption_kw=10.0 + decalage,
)
prediction = await creer_prediction(session, site_id=site_id, target_at=INSTANT)
alerte = await creer_alerte(session, site_id=site_id, timestamp=INSTANT)
jeu = JeuMetier(
site_id=site_id,
site_voisin=voisin,
alert_id=alerte.alert_id,
prediction_id=prediction.prediction_id,
instant=INSTANT,
)
await session.commit()
try:
yield jeu
finally:
async with get_session_factory()() as session:
await _supprime(session, [site_id, voisin])
@pytest.fixture
async def site_nu(marque: str) -> AsyncIterator[str]:
"""Un site sans lecture ni prédiction : le cas que seul un vrai `LEFT JOIN` distingue."""
site_id = f"SITE-{marque}-NU"
async with get_session_factory()() as session:
await creer_site(session, site_id=site_id, capacity_kw=100.0)
await session.commit()
try:
yield site_id
finally:
async with get_session_factory()() as session:
await _supprime(session, [site_id])
@pytest.fixture
def principal_injecte(app: FastAPI) -> Iterator[Callable[[Role], None]]:
def installe(role: Role = Role.LECTEUR) -> None:
app.dependency_overrides[get_current_principal] = lambda: Principal(
id=uuid4(),
email="parcours@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
yield installe
app.dependency_overrides.pop(get_current_principal, None)
+105
View File
@@ -0,0 +1,105 @@
from collections.abc import Iterator, Sequence
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_drift_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import DriftReport
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
def operateur() -> Principal:
return Principal(
id=uuid4(),
email="operateur@enervision.fr",
role=Role.OPERATEUR,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def rapport(*, site_id: str | None) -> DriftReport:
return DriftReport(
drift_report_id=1,
computed_at=INSTANT,
site_id=site_id,
window_start=INSTANT - timedelta(hours=168),
window_end=INSTANT,
reference_start=None,
reference_end=None,
n_observations=48,
mae=1.5,
mape=12.0,
bias=0.3,
reference_mae=1.2,
coverage_ratio=0.95,
insufficient_data_ratio=0.0,
model_references=["lightgbm-aaa"],
status="stable",
reason=None,
)
class FauxService:
def __init__(self, rapports: Sequence[DriftReport]) -> None:
self.rapports = list(rapports)
self.site_demande: str | None = None
async def derniers(self, *, site_id: str | None = None) -> Sequence[DriftReport]:
self.site_demande = site_id
return self.rapports
@pytest.fixture
def servi(app: FastAPI) -> Iterator[list[DriftReport]]:
rapports = [rapport(site_id="SITE001"), rapport(site_id=None)]
service = FauxService(rapports)
app.dependency_overrides[get_current_principal] = operateur
app.dependency_overrides[get_drift_service] = lambda: service
yield rapports
app.dependency_overrides.pop(get_current_principal, None)
app.dependency_overrides.pop(get_drift_service, None)
async def test_drift_returns_the_latest_report_of_every_site(
servi: list[DriftReport], client: AsyncClient
) -> None:
reponse = await client.get("/api/v1/monitoring/drift")
assert reponse.status_code == 200
assert [ligne["site_id"] for ligne in reponse.json()] == ["SITE001", None]
async def test_drift_exposes_the_metrics_of_the_stored_report(
servi: list[DriftReport], client: AsyncClient
) -> None:
reponse = await client.get("/api/v1/monitoring/drift")
premier = reponse.json()[0]
assert premier["status"] == "stable"
assert premier["mae"] == 1.5
assert premier["model_references"] == ["lightgbm-aaa"]
@pytest.fixture
def sans_rapport(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = operateur
app.dependency_overrides[get_drift_service] = lambda: FauxService([])
yield
app.dependency_overrides.pop(get_current_principal, None)
app.dependency_overrides.pop(get_drift_service, None)
async def test_drift_returns_an_empty_list_when_no_report_exists(
sans_rapport: None, client: AsyncClient
) -> None:
reponse = await client.get("/api/v1/monitoring/drift")
assert reponse.status_code == 200
assert reponse.json() == []
@@ -0,0 +1,92 @@
from collections.abc import Callable
import pytest
from httpx import AsyncClient
from app.core.roles import Role
from tests.api.conftest import JeuMetier
pytestmark = pytest.mark.integration
async def genere(client: AsyncClient, site_id: str) -> dict[str, int]:
# Toujours borne a un site : sans `site_id`, le service examine toutes les alertes de la
# base, y compris celles d'un autre test, et le rapport cesse d'etre deterministe.
reponse = await client.post(f"/api/v1/recommendations/generate?site_id={site_id}")
assert reponse.status_code == 200
return dict(reponse.json())
async def test_generate_creates_a_recommendation_for_the_alert_of_the_requested_site(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
rapport = await genere(client, jeu_metier.site_id)
assert rapport["alerts_examined"] == 1
assert rapport["recommendations_created"] >= 1
assert rapport["already_present"] == 0
async def test_generate_creates_nothing_more_when_it_runs_twice_on_the_same_alerts(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
premier = await genere(client, jeu_metier.site_id)
second = await genere(client, jeu_metier.site_id)
assert second["recommendations_created"] == 0
assert second["already_present"] == premier["recommendations_created"]
async def test_generate_examines_no_alert_when_the_requested_site_has_none(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
rapport = await genere(client, jeu_metier.site_voisin)
assert rapport["alerts_examined"] == 0
assert rapport["recommendations_created"] == 0
async def test_list_recommendations_returns_what_generate_persisted_in_another_session(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
await genere(client, jeu_metier.site_id)
reponse = await client.get("/api/v1/recommendations")
assert reponse.status_code == 200
miennes = [r for r in reponse.json() if r["alert_id"] == jeu_metier.alert_id]
assert miennes != []
assert all(r["rule_reference"] for r in miennes)
async def test_get_recommendation_returns_the_row_created_by_generate(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.ADMIN)
await genere(client, jeu_metier.site_id)
liste = await client.get("/api/v1/recommendations")
creee = next(r for r in liste.json() if r["alert_id"] == jeu_metier.alert_id)
reponse = await client.get(f"/api/v1/recommendations/{creee['recommendation_id']}")
assert reponse.status_code == 200
assert reponse.json() == creee
async def test_get_recommendation_returns_404_when_the_identifier_is_unknown(
principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get("/api/v1/recommendations/9999999")
assert reponse.status_code == 404
assert reponse.json()["detail"] == "Recommandation introuvable"
@@ -0,0 +1,80 @@
from collections.abc import Callable
import pytest
from httpx import AsyncClient
from app.core.roles import Role
from app.db.session import get_session_factory
from tests.api.conftest import JeuMetier
from tests.repositories.test_reading import creer_lecture
pytestmark = pytest.mark.integration
async def test_list_sites_returns_the_seeded_site_with_its_stored_attributes(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get("/api/v1/sites")
assert reponse.status_code == 200
mien = next(site for site in reponse.json() if site["site_id"] == jeu_metier.site_id)
assert mien["capacity_kw"] == 100.0
assert mien["site_name"] == "Site de test"
async def test_get_site_returns_404_when_the_identifier_is_absent_from_the_database(
principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get("/api/v1/sites/SITE-JAMAIS-INSERE")
assert reponse.status_code == 404
assert reponse.json()["detail"] == "Site introuvable"
async def test_get_current_returns_the_most_recent_reading_when_several_hours_are_stored(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
assert reponse.status_code == 200
corps = reponse.json()
assert corps["consumption_kw"] == 10.0
assert corps["timestamp"].startswith("2026-09-16T12:00")
async def test_get_current_keeps_the_highest_reading_id_when_two_sources_share_the_timestamp(
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
async with get_session_factory()() as session:
await creer_lecture(
session,
site_id=jeu_metier.site_id,
timestamp=jeu_metier.instant,
source="api_history",
consumption_kw=999.0,
)
await session.commit()
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
assert reponse.json()["consumption_kw"] == 999.0
async def test_get_current_reports_a_critical_quality_when_the_site_has_no_reading(
site_nu: str, principal_injecte: Callable[[Role], None], client: AsyncClient
) -> None:
principal_injecte(Role.LECTEUR)
reponse = await client.get(f"/api/v1/sites/{site_nu}/current")
assert reponse.status_code == 200
corps = reponse.json()
assert corps["timestamp"] is None
assert corps["data_quality"] == "critical"
+69 -2
View File
@@ -1,5 +1,5 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -9,7 +9,15 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings from app.core.config import get_settings
from app.models.energy import Alert, Dataset, Prediction, Reading, Recommendation, Site from app.models.energy import (
Alert,
Dataset,
DriftReport,
Prediction,
Reading,
Recommendation,
Site,
)
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
MOMENT = datetime(2024, 1, 1, tzinfo=UTC) MOMENT = datetime(2024, 1, 1, tzinfo=UTC)
@@ -269,3 +277,62 @@ async def test_recommendation_is_unique_when_alert_and_rule_match(
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
async with savepoint: async with savepoint:
await data_connection.execute(statement) await data_connection.execute(statement)
def _rapport(**remplacements: object) -> dict[str, object]:
defauts: dict[str, object] = {
"site_id": None,
"window_start": MOMENT,
"window_end": MOMENT,
"n_observations": 12,
"model_references": ["lightgbm-aaa"],
"status": "stable",
"reason": None,
}
return {**defauts, **remplacements}
async def test_drift_report_rejects_an_unknown_status(data_connection: AsyncConnection) -> None:
statement = insert(DriftReport).values(**_rapport(status="douteux", reason="x"))
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
async def test_drift_report_rejects_a_drift_without_a_reason(
data_connection: AsyncConnection,
) -> None:
statement = insert(DriftReport).values(**_rapport(status="derive"))
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
async def test_drift_report_accepts_one_global_row_without_a_site(
data_connection: AsyncConnection,
) -> None:
identifiant = (
await data_connection.execute(
insert(DriftReport).values(**_rapport()).returning(DriftReport.drift_report_id)
)
).scalar_one()
assert identifiant is not None
async def test_drift_report_is_unique_when_window_and_site_match(
data_connection: AsyncConnection,
) -> None:
fenetre = MOMENT + timedelta(days=1)
statement = insert(DriftReport).values(**_rapport(window_end=fenetre))
await data_connection.execute(statement)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with savepoint:
await data_connection.execute(statement)
@@ -5,6 +5,7 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert from app.models.energy import Alert
from app.repositories import alert as module_alert
from app.repositories.alert import AlertRepository from app.repositories.alert import AlertRepository
from app.schemas.alert import AlertSeverity from app.schemas.alert import AlertSeverity
from tests.repositories.test_site import creer as creer_site 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([]) creees = await depot.create_many([])
assert creees == [] 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
@@ -0,0 +1,187 @@
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import ClauseElement
from app.repositories.drift import (
DriftRepository,
NouveauRapportDerive,
_lectures_retenues,
_predictions_retenues,
)
from tests.repositories.test_prediction import creer_prediction
from tests.repositories.test_reading import creer_lecture
from tests.repositories.test_site import creer as creer_site
DEBUT = datetime(2026, 9, 15, tzinfo=UTC)
FIN = datetime(2026, 9, 22, tzinfo=UTC)
CIBLE = datetime(2026, 9, 16, 12, tzinfo=UTC)
def sql(requete: ClauseElement) -> str:
return str(requete.compile(dialect=postgresql.dialect())) # type: ignore[no-untyped-call]
def rapport(**remplacements: object) -> NouveauRapportDerive:
defauts: dict[str, object] = {
"site_id": None,
"window_start": DEBUT,
"window_end": FIN,
"reference_start": None,
"reference_end": None,
"n_observations": 10,
"mae": 1.0,
"mape": 5.0,
"bias": 0.1,
"reference_mae": None,
"coverage_ratio": 1.0,
"insufficient_data_ratio": 0.0,
"model_references": ["lightgbm-aaa"],
"status": "stable",
"reason": None,
}
return NouveauRapportDerive(**{**defauts, **remplacements}) # type: ignore[arg-type]
def test_predictions_keep_one_row_per_site_and_target_in_sql() -> None:
requete = sql(_predictions_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
assert "DISTINCT ON (prediction.site_id, prediction.target_at)" in requete
assert "prediction.prediction_id DESC" in requete
def test_readings_keep_one_row_per_site_and_instant_in_sql() -> None:
requete = sql(_lectures_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
assert "DISTINCT ON (reading.site_id, reading.timestamp)" in requete
assert "reading.reading_id DESC" in requete
def test_predictions_restrict_themselves_to_the_requested_site_in_sql() -> None:
requete = sql(_predictions_retenues(debut=DEBUT, fin=FIN, site_id="SITE001").element)
assert requete.count("prediction.site_id = ") == 1
def test_readings_ignore_a_missing_consumption_in_sql() -> None:
requete = sql(_lectures_retenues(debut=DEBUT, fin=FIN, site_id=None).element)
assert "reading.consumption_kwh IS NOT NULL" in requete
@pytest.mark.integration
async def test_repository_pairs_a_prediction_with_the_reading_of_the_same_instant(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert [(p.predicted_value, p.actual_value) for p in paires] == [(12.0, 10.0)]
@pytest.mark.integration
async def test_repository_keeps_the_latest_run_when_several_predictions_share_a_target(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=99.0)
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert [p.predicted_value for p in paires] == [99.0]
@pytest.mark.integration
async def test_repository_keeps_one_reading_per_instant_when_two_sources_wrote_the_same_hour(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(session, site_id=site.site_id, target_at=CIBLE, predicted_value=12.0)
await creer_lecture(
session, site_id=site.site_id, timestamp=CIBLE, source="api_current", consumption_kwh=10.0
)
await creer_lecture(
session, site_id=site.site_id, timestamp=CIBLE, source="api_history", consumption_kwh=20.0
)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert [p.actual_value for p in paires] == [20.0]
@pytest.mark.integration
async def test_repository_excludes_an_insufficient_data_prediction_from_the_pairs(
session: AsyncSession,
) -> None:
site = await creer_site(session)
await creer_prediction(
session,
site_id=site.site_id,
target_at=CIBLE,
predicted_value=None,
status="insufficient_data",
failure_reason="historique trop court",
)
await creer_lecture(session, site_id=site.site_id, timestamp=CIBLE, consumption_kwh=10.0)
depot = DriftRepository(session)
paires = await depot.paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
comptages = await depot.comptages(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert paires == []
assert [(c.status, c.nombre) for c in comptages] == [("insufficient_data", 1)]
@pytest.mark.integration
async def test_repository_excludes_a_target_outside_the_window(session: AsyncSession) -> None:
site = await creer_site(session)
hors_fenetre = FIN + timedelta(hours=1)
await creer_prediction(
session, site_id=site.site_id, target_at=hors_fenetre, predicted_value=12.0
)
await creer_lecture(session, site_id=site.site_id, timestamp=hors_fenetre, consumption_kwh=10.0)
paires = await DriftRepository(session).paires(debut=DEBUT, fin=FIN, site_id=site.site_id)
await session.rollback()
assert paires == []
@pytest.mark.integration
async def test_repository_reads_back_the_global_report_it_wrote(session: AsyncSession) -> None:
depot = DriftRepository(session)
fenetre = datetime(2035, 3, 1, tzinfo=UTC)
ecrites = await depot.enregistre([rapport(window_end=fenetre)])
derniers = await depot.derniers()
globaux = [r for r in derniers if r.site_id is None and r.window_end == fenetre]
await session.rollback()
assert ecrites == 1
assert len(globaux) == 1
@pytest.mark.integration
async def test_repository_ignores_a_second_report_for_the_same_window_and_site(
session: AsyncSession,
) -> None:
depot = DriftRepository(session)
fenetre = datetime(2035, 4, 1, tzinfo=UTC)
premiere = await depot.enregistre([rapport(window_end=fenetre)])
seconde = await depot.enregistre([rapport(window_end=fenetre, status="derive", reason="x")])
await session.rollback()
assert premiere == 1
assert seconde == 0
+260
View File
@@ -0,0 +1,260 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
import pytest
from app.repositories.drift import ComptageStatut, PaireDerive
from app.services.drift import (
STATUT_DERIVE,
STATUT_INDETERMINE,
STATUT_STABLE,
DriftService,
Seuils,
mesure,
)
INSTANT = datetime(2026, 9, 22, 12, 0, tzinfo=UTC)
def paire(
*, site_id: str = "SITE001", prevu: float, reel: float, reference: str = "lightgbm-aaa"
) -> PaireDerive:
return PaireDerive(
site_id=site_id,
target_at=INSTANT,
predicted_value=prevu,
actual_value=reel,
model_reference=reference,
)
def paires(
*, site_id: str = "SITE001", nombre: int, prevu: float, reel: float
) -> list[PaireDerive]:
return [paire(site_id=site_id, prevu=prevu, reel=reel) for _ in range(nombre)]
class FauxDepot:
def __init__(
self,
*,
recentes: Sequence[PaireDerive] = (),
anciennes: Sequence[PaireDerive] = (),
comptages: Sequence[ComptageStatut] = (),
) -> None:
self.recentes = list(recentes)
self.anciennes = list(anciennes)
self._comptages = list(comptages)
self.fenetres: list[tuple[datetime, datetime]] = []
async def paires(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[PaireDerive]:
self.fenetres.append((debut, fin))
return self.recentes if len(self.fenetres) == 1 else self.anciennes
async def comptages(
self, *, debut: datetime, fin: datetime, site_id: str | None = None
) -> Sequence[ComptageStatut]:
return self._comptages
def service(depot: FauxDepot, **surcharges: object) -> DriftService:
return DriftService(depot, seuils=Seuils(**surcharges)) # type: ignore[arg-type]
def test_drift_averages_the_absolute_gap_between_forecast_and_actual() -> None:
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=8.0, reel=10.0)])
assert metriques.mae == 2.0
assert metriques.n_observations == 2
def test_drift_computes_a_signed_bias_when_the_model_overforecasts() -> None:
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=14.0, reel=10.0)])
assert metriques.bias == 3.0
def test_drift_computes_a_negative_bias_when_the_model_underforecasts() -> None:
metriques = mesure([paire(prevu=8.0, reel=10.0), paire(prevu=6.0, reel=10.0)])
assert metriques.bias == -3.0
def test_drift_excludes_a_zero_actual_from_the_mape_only() -> None:
metriques = mesure([paire(prevu=11.0, reel=10.0), paire(prevu=5.0, reel=0.0)])
assert metriques.mape == 10.0
assert metriques.n_observations == 2
assert metriques.mae == 3.0
def test_drift_reports_no_mape_when_every_actual_is_zero() -> None:
metriques = mesure([paire(prevu=1.0, reel=0.0)])
assert metriques.mape is None
def test_drift_lists_every_model_reference_seen_in_the_window() -> None:
metriques = mesure(
[paire(prevu=10.0, reel=10.0, reference="lightgbm-bbb"), paire(prevu=10.0, reel=10.0)]
)
assert metriques.model_references == ["lightgbm-aaa", "lightgbm-bbb"]
async def test_drift_reports_indetermine_when_the_window_holds_too_few_observations() -> None:
depot = FauxDepot(recentes=paires(nombre=3, prevu=10.0, reel=10.0))
rapports = await service(depot, min_observations=24).evaluate(now=INSTANT)
assert {rapport.status for rapport in rapports} == {STATUT_INDETERMINE}
assert all(rapport.reason for rapport in rapports)
async def test_drift_reports_derive_when_the_recent_mae_exceeds_the_reference_ratio() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=14.0, reel=10.0),
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_DERIVE
assert global_.mae == 4.0
assert global_.reference_mae == 1.0
async def test_drift_reports_stable_when_the_recent_mae_stays_close_to_the_reference() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=11.0, reel=10.0),
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_STABLE
assert global_.reason is None
async def test_drift_reports_indetermine_when_the_reference_window_is_empty() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=14.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_INDETERMINE
assert global_.reference_mae is None
async def test_drift_reports_derive_when_the_coverage_ratio_falls_under_the_threshold() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=100)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
assert global_.status == STATUT_DERIVE
assert global_.coverage_ratio == 0.3
async def test_drift_reports_one_line_per_site_and_one_global_line() -> None:
depot = FauxDepot(
recentes=[
*paires(site_id="SITE001", nombre=12, prevu=10.0, reel=10.0),
*paires(site_id="SITE002", nombre=12, prevu=10.0, reel=10.0),
],
comptages=[
ComptageStatut(site_id="SITE001", status="available", nombre=12),
ComptageStatut(site_id="SITE002", status="available", nombre=12),
],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
assert [rapport.site_id for rapport in rapports] == ["SITE001", "SITE002", None]
assert next(r for r in rapports if r.site_id is None).n_observations == 24
async def test_drift_still_reports_a_site_that_stopped_being_scored() -> None:
depot = FauxDepot(
recentes=paires(site_id="SITE001", nombre=30, prevu=10.0, reel=10.0),
anciennes=[
*paires(site_id="SITE001", nombre=30, prevu=10.0, reel=10.0),
*paires(site_id="SITE002", nombre=30, prevu=10.0, reel=10.0),
],
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
disparu = next(rapport for rapport in rapports if rapport.site_id == "SITE002")
assert disparu.status == STATUT_INDETERMINE
assert disparu.n_observations == 0
async def test_drift_measures_the_share_of_sites_left_without_enough_history() -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
comptages=[
ComptageStatut(site_id="SITE001", status="available", nombre=30),
ComptageStatut(site_id="SITE001", status="insufficient_data", nombre=10),
],
)
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
assert next(r for r in rapports if r.site_id is None).insufficient_data_ratio == 0.25
async def test_drift_closes_the_window_before_the_grace_delay() -> None:
depot = FauxDepot()
await service(depot, grace=timedelta(hours=2), fenetre=timedelta(hours=168)).evaluate(
now=INSTANT
)
recente, reference = depot.fenetres
assert recente[1] == INSTANT - timedelta(hours=2)
assert recente[0] == INSTANT - timedelta(hours=170)
assert reference[1] == recente[0]
async def test_drift_truncates_the_reference_instant_to_the_hour() -> None:
premier = FauxDepot()
second = FauxDepot()
await service(premier).evaluate(now=INSTANT + timedelta(minutes=17, microseconds=3))
await service(second).evaluate(now=INSTANT + timedelta(minutes=48))
assert premier.fenetres[0] == second.fenetres[0]
@pytest.mark.parametrize(
("prevu", "attendu"),
[(10.0, STATUT_STABLE), (30.0, STATUT_DERIVE)],
ids=["mae_stable", "mae_triplee"],
)
async def test_drift_compares_the_recent_window_to_the_reference_one(
prevu: float, attendu: str
) -> None:
depot = FauxDepot(
recentes=paires(nombre=30, prevu=prevu, reel=10.0),
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
)
rapports = await service(depot, min_observations=10, mae_plancher=1.0).evaluate(now=INSTANT)
assert next(r for r in rapports if r.site_id is None).status == attendu
+223
View File
@@ -0,0 +1,223 @@
"""Piege : ce fichier porte le marqueur `chaine`, pas `integration` - test_the_ml_binaries...()
Il lance les vrais binaires `enervision_ml.train` et `enervision_ml.score` dans l'environnement
uv de `ml/`, que le job `integration` de `backend.yml` n'installe pas. Un marqueur distinct evite
que ce job, et `make test`, ne le selectionnent et n'echouent faute de `ml/.venv`.
"""
import math
import os
import subprocess
from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from functools import partial
from pathlib import Path
from typing import Any
from uuid import uuid4
import anyio
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from sqlalchemy import delete, insert, make_url
from app.api.deps import get_current_principal
from app.core.config import get_settings
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.db.session import get_session_factory
from app.models.energy import Prediction, Reading, Site
pytestmark = pytest.mark.chaine
RACINE = Path(__file__).resolve().parents[3]
ML = RACINE / "ml"
PYTHON_ML = Path(os.environ.get("ML_PYTHON", ML / ".venv" / "bin" / "python"))
HEURES_COMPLETES = 400
HEURES_INSUFFISANTES = 100
def lecteur() -> Principal:
return Principal(
id=uuid4(),
email="lecteur@enervision.fr",
role=Role.LECTEUR,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def url_ml() -> str:
"""Derive la chaine du pipeline de celle du backend plutot que de la recopier : les deux
cotes visent ainsi la meme base, dans leur dialecte respectif."""
return (
make_url(get_settings().database_url)
.set(drivername="postgresql+psycopg")
.render_as_string(hide_password=False)
)
def lance_ml(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
if not PYTHON_ML.exists():
pytest.fail(
f"Environnement ml/ absent ({PYTHON_ML}). Lancer `cd ml && uv sync --all-groups`."
)
return subprocess.run( # noqa: S603 -- argv en liste, sans shell, binaire resolu dans le depot
[str(PYTHON_ML), "-m", module, *arguments],
cwd=ML,
text=True,
capture_output=True,
timeout=600,
check=False,
env={
**os.environ,
"ML_DATABASE_URL": url_ml(),
"MLFLOW_TRACKING_URI": f"sqlite:///{journal}/mlflow.db",
},
)
async def executer(module: str, *arguments: str, journal: Path) -> subprocess.CompletedProcess[str]:
resultat = await anyio.to_thread.run_sync(
partial(lance_ml, module, *arguments, journal=journal)
)
assert resultat.returncode == 0, resultat.stderr
return resultat
@dataclass
class Parc:
sites: list[str] = field(default_factory=list)
def lignes_horaires(site_id: str, *, heures: int, fin: datetime) -> list[dict[str, Any]]:
return [
{
"site_id": site_id,
"timestamp": fin - timedelta(hours=decalage),
"source": "api_history",
"consumption_kwh": 50.0 + math.sin(decalage / 12.0) * 10.0,
"temperature_celsius": 15.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"is_working_hours": True,
"raw_data": {},
}
for decalage in reversed(range(heures))
]
@pytest.fixture
async def parc() -> AsyncIterator[Parc]:
"""Deux sites dotes d'un historique complet, un troisieme qui n'atteint pas le lag de 168 h.
Les ecritures sont validees : les binaires ML ouvrent leur propre connexion et ne verraient
pas une transaction en cours.
"""
fin = datetime.now(UTC).replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
marque = uuid4().hex[:12]
complets = [f"TEST-{marque}-A", f"TEST-{marque}-B"]
partiel = f"TEST-{marque}-C"
parc = Parc(sites=[*complets, partiel])
async with get_session_factory()() as session:
await session.execute(
insert(Site),
[
{
"site_id": site_id,
"site_name": f"Site {site_id}",
"site_type": "office",
"capacity_kw": 100.0,
}
for site_id in parc.sites
],
)
for site_id in complets:
await session.execute(
insert(Reading), lignes_horaires(site_id, heures=HEURES_COMPLETES, fin=fin)
)
await session.execute(
insert(Reading), lignes_horaires(partiel, heures=HEURES_INSUFFISANTES, fin=fin)
)
await session.commit()
try:
yield parc
finally:
async with get_session_factory()() as session:
await session.execute(delete(Prediction).where(Prediction.site_id.in_(parc.sites)))
await session.execute(delete(Reading).where(Reading.site_id.in_(parc.sites)))
await session.execute(delete(Site).where(Site.site_id.in_(parc.sites)))
await session.commit()
@pytest.fixture
def principal_lecteur(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lecteur
yield
app.dependency_overrides.pop(get_current_principal, None)
async def resume_du_site(client: AsyncClient, site_id: str) -> dict[str, Any]:
reponse = await client.get("/api/v1/predictions")
assert reponse.status_code == 200
sites = reponse.json()["sites"]
return next(site for site in sites if site["site_id"] == site_id)
async def entraine_et_score(parc: Parc, tmp_path: Path, *arguments: str) -> Path:
modele = tmp_path / "lightgbm-consumption.txt"
await executer(
"enervision_ml.train",
"--model-output",
str(modele),
"--mlflow-tracking-uri",
f"sqlite:///{tmp_path}/mlflow.db",
journal=tmp_path,
)
await executer("enervision_ml.score", "--model", str(modele), *arguments, journal=tmp_path)
return modele
async def test_the_ml_binaries_produce_a_prediction_that_the_api_serves(
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
) -> None:
await entraine_et_score(parc, tmp_path)
servi = await resume_du_site(client, parc.sites[0])
assert servi["prediction"]["status"] == "available"
assert servi["prediction"]["predicted_value"] is not None
assert servi["prediction"]["target_metric"] == "consumption_kwh"
async def test_the_api_exposes_the_failure_reason_of_a_site_without_enough_history(
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
) -> None:
await entraine_et_score(parc, tmp_path)
servi = await resume_du_site(client, parc.sites[-1])
assert servi["prediction"]["status"] == "insufficient_data"
assert servi["prediction"]["predicted_value"] is None
assert servi["prediction"]["failure_reason"] is not None
async def test_the_api_serves_the_latest_run_when_the_score_cli_runs_twice(
parc: Parc, tmp_path: Path, client: AsyncClient, principal_lecteur: None
) -> None:
modele = await entraine_et_score(parc, tmp_path)
premier = await resume_du_site(client, parc.sites[0])
await executer("enervision_ml.score", "--model", str(modele), journal=tmp_path)
second = await resume_du_site(client, parc.sites[0])
assert second["prediction"]["created_at"] > premier["prediction"]["created_at"]
assert second["prediction"]["model_reference"] == premier["prediction"]["model_reference"]
+108
View File
@@ -0,0 +1,108 @@
from datetime import UTC, datetime, timedelta
import pytest
from app.monitoring import drift as cli
from app.repositories.drift import NouveauRapportDerive
from app.services.drift import STATUT_DERIVE, STATUT_STABLE, Seuils
INSTANT = datetime(2026, 9, 22, 12, tzinfo=UTC)
def rapport(*, site_id: str | None, status: str, reason: str | None = None) -> NouveauRapportDerive:
return NouveauRapportDerive(
site_id=site_id,
window_start=INSTANT - timedelta(hours=168),
window_end=INSTANT,
reference_start=None,
reference_end=None,
n_observations=48,
mae=1.5,
mape=12.0,
bias=0.3,
reference_mae=1.2,
coverage_ratio=1.0,
insufficient_data_ratio=0.0,
model_references=["lightgbm-aaa"],
status=status,
reason=reason,
)
def installe(monkeypatch: pytest.MonkeyPatch, rapports: list[NouveauRapportDerive]) -> None:
async def fausse_execution(
*, now: datetime | None, site_id: str | None, seuils: Seuils | None
) -> list[NouveauRapportDerive]:
return rapports
monkeypatch.setattr(cli, "run_drift", fausse_execution)
def test_parse_args_defaults_to_the_standard_window() -> None:
arguments = cli.parse_args([])
assert arguments.window_hours == 168
assert arguments.grace_hours == 2
assert arguments.fail_on_drift is False
def test_parse_args_reads_the_site_id() -> None:
assert cli.parse_args(["--site-id", "SITE001"]).site_id == "SITE001"
def test_parse_args_parses_the_instant_option() -> None:
arguments = cli.parse_args(["--now", "2026-09-22T12:00:00+00:00"])
assert arguments.now == INSTANT
def test_parse_instant_treats_a_naive_datetime_as_utc() -> None:
assert cli._parse_instant("2026-09-22T12:00:00") == INSTANT
def test_seuils_depuis_translates_the_hour_options_into_durations() -> None:
seuils = cli.seuils_depuis(cli.parse_args(["--window-hours", "24", "--grace-hours", "1"]))
assert seuils.fenetre == timedelta(hours=24)
assert seuils.grace == timedelta(hours=1)
def test_main_prints_the_verdict_of_every_line(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
installe(
monkeypatch,
[
rapport(site_id="SITE001", status=STATUT_STABLE),
rapport(site_id=None, status=STATUT_STABLE),
],
)
code = cli.main([])
sortie = capsys.readouterr().out
assert code == 0
assert "SITE001" in sortie
assert "TOUS SITES" in sortie
def test_main_exits_non_zero_when_drift_is_detected_and_the_flag_is_set(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
code = cli.main(["--fail-on-drift"])
assert code == 1
assert "MAE doublée" in capsys.readouterr().out
def test_main_exits_zero_when_drift_is_detected_without_the_flag(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
installe(monkeypatch, [rapport(site_id=None, status=STATUT_DERIVE, reason="MAE doublée")])
code = cli.main([])
assert code == 0
assert capsys.readouterr().out != ""
+37 -37
View File
@@ -655,31 +655,31 @@ wheels = [
[[package]] [[package]]
name = "pandas" name = "pandas"
version = "3.0.5" version = "3.0.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, { url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" },
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, { url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, { url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, { url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" },
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, { url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" },
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, { url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" },
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, { url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" },
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, { url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, { url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" },
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, { url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" },
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, { url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" },
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, { url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, { url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" },
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, { url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" },
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, { url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, { url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" },
] ]
[[package]] [[package]]
@@ -936,27 +936,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.7" version = "0.16.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
] ]
[[package]] [[package]]
+2 -2
View File
@@ -3,7 +3,7 @@
# ================== # ==================
# Image pour frontend # Image pour frontend
FROM node:24-alpine3.22 AS builder FROM node:26-alpine3.22 AS builder
WORKDIR /app WORKDIR /app
@@ -23,7 +23,7 @@ RUN npm run build
# ================== # ==================
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner FROM nginx:1.31-alpine AS runner
# Copie de la configuration de nginx # Copie de la configuration de nginx
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
+94 -173
View File
@@ -8,12 +8,12 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@angular/common": "^22.1.0", "@angular/common": "^22.1.7",
"@angular/compiler": "^22.1.0", "@angular/compiler": "^22.1.7",
"@angular/core": "^22.1.0", "@angular/core": "^22.1.7",
"@angular/forms": "^22.1.0", "@angular/forms": "^22.1.7",
"@angular/platform-browser": "^22.1.0", "@angular/platform-browser": "^22.1.7",
"@angular/router": "^22.1.0", "@angular/router": "^22.1.7",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -21,21 +21,14 @@
"devDependencies": { "devDependencies": {
"@angular/build": "^22.1.8", "@angular/build": "^22.1.8",
"@angular/cli": "^22.1.8", "@angular/cli": "^22.1.8",
"@angular/compiler-cli": "^22.1.0", "@angular/compiler-cli": "^22.1.7",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.11",
"jsdom": "^28.0.0", "jsdom": "^30.1.0",
"prettier": "^3.8.1", "prettier": "^3.9.8",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vitest": "^4.0.8" "vitest": "^4.1.11"
} }
}, },
"node_modules/@acemir/cssom": {
"version": "0.9.31",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
"dev": true,
"license": "MIT"
},
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -255,9 +248,9 @@
} }
}, },
"node_modules/@angular/common": { "node_modules/@angular/common": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.7.tgz",
"integrity": "sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==", "integrity": "sha512-SLW4AdH2Y8G9SM/iFRNpCQtkmvq55RcIQWsADT9ypZ7FxxcnJRsr4Q/WnbUBbuCslsCkrgzU1i9WDiW0m7gcnw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -266,14 +259,14 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/core": "22.1.6", "@angular/core": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/compiler": { "node_modules/@angular/compiler": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.7.tgz",
"integrity": "sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==", "integrity": "sha512-qhoHAByh805nT4Xy4Wd2kE5FvkTc7wcg8Qps+EH6+z4KRfwGiL5cwdgooGWjTAldFCeqTgJ0BO/0dB6GHZjdkQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -283,9 +276,9 @@
} }
}, },
"node_modules/@angular/compiler-cli": { "node_modules/@angular/compiler-cli": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.7.tgz",
"integrity": "sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==", "integrity": "sha512-VGg8Kyt0YymQfnUUFk79vFaM+x5xmIMHy+BZh3P2Lh/uxqCXTWhJJH8kOHKtIGGH5/wKhBEmSy2CezdcIuM9xg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -306,7 +299,7 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/compiler": "22.1.6", "@angular/compiler": "22.1.7",
"typescript": ">=6.0 <6.1" "typescript": ">=6.0 <6.1"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -316,9 +309,9 @@
} }
}, },
"node_modules/@angular/core": { "node_modules/@angular/core": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.7.tgz",
"integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==", "integrity": "sha512-T9bt1PIgoN7n+TxGHTUgu0EsomlGMQ84YMXbMB+N6HhD8C1qZxn0uCbnAtCUjcecg1am8ioKxNTO0eII9ouioQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -327,7 +320,7 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/compiler": "22.1.6", "@angular/compiler": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0", "rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0 || ~0.16.0" "zone.js": "~0.15.0 || ~0.16.0"
}, },
@@ -341,9 +334,9 @@
} }
}, },
"node_modules/@angular/forms": { "node_modules/@angular/forms": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.7.tgz",
"integrity": "sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==", "integrity": "sha512-oc0DT39C3ZboJpDx8xKUCltgn56LHi0kbv+ThNK/dCSZLgjF2nd+muMMRYF+amdljQp2q7+hm2ORmhON3CNFog==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
@@ -354,16 +347,16 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "22.1.6", "@angular/common": "22.1.7",
"@angular/core": "22.1.6", "@angular/core": "22.1.7",
"@angular/platform-browser": "22.1.6", "@angular/platform-browser": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/platform-browser": { "node_modules/@angular/platform-browser": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.7.tgz",
"integrity": "sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==", "integrity": "sha512-4dT3qGw3ZHVQUcHKALzMx9MTo+pxAMzHIPxc8HUESklqXfq6GRaIIfFMvE0RsquI9iqu6admD6lOAEzLWS8DxA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -372,9 +365,9 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/animations": "22.1.6", "@angular/animations": "22.1.7",
"@angular/common": "22.1.6", "@angular/common": "22.1.7",
"@angular/core": "22.1.6" "@angular/core": "22.1.7"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@angular/animations": { "@angular/animations": {
@@ -383,9 +376,9 @@
} }
}, },
"node_modules/@angular/router": { "node_modules/@angular/router": {
"version": "22.1.6", "version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.6.tgz", "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.7.tgz",
"integrity": "sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==", "integrity": "sha512-CxYa2Ym5L0WmqToHU2I0Z4VEpocdryLlvx5svLfo9M3uOaP3/9icB/gjS/vXPbbQxV8/GZ59E/7/LF14anIoRg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -394,60 +387,45 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0" "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": "22.1.6", "@angular/common": "22.1.7",
"@angular/core": "22.1.6", "@angular/core": "22.1.7",
"@angular/platform-browser": "22.1.6", "@angular/platform-browser": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
"version": "5.1.11", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.3.0",
"@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0" "@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
} }
}, },
"node_modules/@asamuzakjp/dom-selector": { "node_modules/@asamuzakjp/dom-selector": {
"version": "6.8.1", "version": "9.1.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.1.0",
"bidi-js": "^1.0.3", "css-tree": "^3.2.1",
"css-tree": "^3.1.0",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.6" "lru-cache": "^11.5.2"
}
}, },
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
} }
}, },
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz",
@@ -854,9 +832,9 @@
} }
}, },
"node_modules/@csstools/css-syntax-patches-for-csstree": { "node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.13", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -879,9 +857,9 @@
} }
}, },
"node_modules/@csstools/css-tokenizer": { "node_modules/@csstools/css-tokenizer": {
"version": "4.0.0", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4490,22 +4468,6 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/cssstyle": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
"css-tree": "^3.1.0",
"lru-cache": "^11.2.6"
},
"engines": {
"node": ">=20"
}
},
"node_modules/data-urls": { "node_modules/data-urls": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5283,30 +5245,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/http-proxy-agent/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
@@ -5529,39 +5467,38 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "28.1.0", "version": "30.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.31", "@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^6.8.1", "@asamuzakjp/dom-selector": "^9.1.2",
"@bramus/specificity": "^2.4.2", "@bramus/specificity": "^2.4.2",
"@exodus/bytes": "^1.11.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"cssstyle": "^6.0.1", "@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0", "data-urls": "^7.0.0",
"decimal.js": "^10.6.0", "decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0", "html-encoding-sniffer": "^6.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"parse5": "^8.0.0", "lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0", "saxes": "^6.0.0",
"symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2",
"tough-cookie": "^6.0.0", "undici": "^8.10.2",
"undici": "^7.21.0",
"w3c-xmlserializer": "^5.0.0", "w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1", "webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0", "whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0", "whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0" "xml-name-validator": "^5.0.0"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"canvas": "^3.0.0" "canvas": "^3.2.3"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"canvas": { "canvas": {
@@ -5569,28 +5506,19 @@
} }
} }
}, },
"node_modules/jsdom/node_modules/agent-base": { "node_modules/jsdom/node_modules/whatwg-url": {
"version": "7.1.4", "version": "17.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/jsdom/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "@exodus/bytes": "^1.15.1",
"debug": "4" "tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
}, },
"engines": { "engines": {
"node": ">= 14" "node": "^22.14.0 || >=24.0.0"
} }
}, },
"node_modules/jsesc": { "node_modules/jsesc": {
@@ -6708,9 +6636,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.9.6", "version": "3.9.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -7308,13 +7236,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -7469,13 +7390,13 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.29.1", "version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=22.19.0"
} }
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
+10 -10
View File
@@ -12,12 +12,12 @@
"private": true, "private": true,
"packageManager": "npm@11.19.0", "packageManager": "npm@11.19.0",
"dependencies": { "dependencies": {
"@angular/common": "^22.1.0", "@angular/common": "^22.1.7",
"@angular/compiler": "^22.1.0", "@angular/compiler": "^22.1.7",
"@angular/core": "^22.1.0", "@angular/core": "^22.1.7",
"@angular/forms": "^22.1.0", "@angular/forms": "^22.1.7",
"@angular/platform-browser": "^22.1.0", "@angular/platform-browser": "^22.1.7",
"@angular/router": "^22.1.0", "@angular/router": "^22.1.7",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -25,11 +25,11 @@
"devDependencies": { "devDependencies": {
"@angular/build": "^22.1.8", "@angular/build": "^22.1.8",
"@angular/cli": "^22.1.8", "@angular/cli": "^22.1.8",
"@angular/compiler-cli": "^22.1.0", "@angular/compiler-cli": "^22.1.7",
"@vitest/coverage-v8": "^4.1.11", "@vitest/coverage-v8": "^4.1.11",
"jsdom": "^28.0.0", "jsdom": "^30.1.0",
"prettier": "^3.8.1", "prettier": "^3.9.8",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vitest": "^4.0.8" "vitest": "^4.1.11"
} }
} }
@@ -8,8 +8,10 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
describe('mockApiInterceptor', () => { describe('mockApiInterceptor', () => {
let http: HttpClient; let http: HttpClient;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
let useMockFixturesInitial: boolean;
beforeEach(() => { beforeEach(() => {
useMockFixturesInitial = environment.useMockFixtures;
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideHttpClient(withInterceptors([mockApiInterceptor])), provideHttpClient(withInterceptors([mockApiInterceptor])),
@@ -21,7 +23,7 @@ describe('mockApiInterceptor', () => {
}); });
afterEach(() => { afterEach(() => {
environment.useMockFixtures = true; environment.useMockFixtures = useMockFixturesInitial;
httpMock.verify(); httpMock.verify();
}); });
@@ -28,87 +28,120 @@
@if (statsError(); as message) { @if (statsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <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) { @if (predictionsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
@if (stats(); as s) { @if (stats(); as s) {
<section class="overview"> <p class="dashboard__status">
<ev-card class="card card--gauge"> <span class="dashboard__pulse" aria-hidden="true"></span>
<span class="card__label">Consommation vs capacité</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 <app-consumption-gauge
[consumption]="s.total_consumption_kw" [consumption]="s.total_consumption_kw"
[capacity]="s.total_capacity_kw" [capacity]="s.total_capacity_kw"
/> />
<span class="card__value" <span class="kpi__value">
>{{ s.total_consumption_kw | number: '1.0-1' }} / {{ s.total_consumption_kw | number: '1.0-1' }}
{{ s.total_capacity_kw | number }} kW</span <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> <div
class="progress-bar"
<ev-card class="card"> role="progressbar"
<span class="card__label">Charge moyenne du parc</span> aria-valuemin="0"
<span class="card__value">{{ s.average_load_percent }} %</span> aria-valuemax="100"
<div class="progress-bar"> [attr.aria-valuenow]="s.average_load_percent"
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div> >
<div
class="progress-bar__fill"
[class]="'progress-bar__fill--' + loadTone(s.average_load_percent)"
[style.width.%]="s.average_load_percent"
></div>
</div> </div>
<span class="kpi__hint">{{ loadHint(s.average_load_percent) }}</span>
</ev-card> </ev-card>
<ev-card class="card"> <ev-card class="kpi">
<span class="card__label">Sites suivis</span> <span class="kpi__label">Sites suivis</span>
<span class="card__value">{{ s.total_sites }}</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> </ev-card>
</section> </section>
}
<div class="dashboard__grid">
<div class="dashboard__main">
@if (stats(); as s) {
<section class="chart-section"> <section class="chart-section">
<h2>Charge et alerte visuelle par site</h2> <h2>Charge par site</h2>
<ev-card class="chart-card">
<app-site-load-chart [sites]="s.sites" /> <app-site-load-chart [sites]="s.sites" />
</ev-card>
</section> </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>
}
</ul>
</section>
}
@if (predictions().length > 0) {
<section class="predictions-section"> <section class="predictions-section">
<h2>Prévisions de consommation</h2> <h2>Prévisions de consommation</h2>
<ul class="predictions-list"> @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) { @for (site of predictions(); track site.site_id) {
<li class="prediction-item"> <tr>
<span class="prediction-item__site">{{ site.site_name }}</span> <td>{{ site.site_name }}</td>
@if (site.prediction; as prediction) { @if (site.prediction; as prediction) {
@if (prediction.status === 'available') { @if (prediction.status === 'available') {
<span class="prediction-item__value"> <td class="ev-table__number">
{{ prediction.predicted_value | number: '1.0-1' }} kWh {{ prediction.predicted_value | number: '1.0-1' }} kWh
<span class="prediction-item__target" </td>
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span <td>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</td>
>
</span>
} @else { } @else {
<td>
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{ <ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur' prediction.status === 'insufficient_data'
? 'Historique insuffisant'
: 'Erreur'
}}</ev-badge> }}</ev-badge>
</td>
<td class="ev-table__muted">-</td>
} }
} @else { } @else {
<ev-badge tone="neutral">Pas encore de prévision</ev-badge> <td><ev-badge tone="neutral">Pas encore de prévision</ev-badge></td>
<td class="ev-table__muted">-</td>
} }
</li> <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>
<aside class="dashboard__side" aria-label="Alertes actives">
<app-alert-feed />
</aside>
</div>
</div> </div>
@@ -8,9 +8,11 @@
.dashboard__header { .dashboard__header {
display: flex; display: flex;
flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
margin-bottom: 2rem; gap: var(--space-3);
margin-bottom: var(--space-3);
} }
.dashboard__brand { .dashboard__brand {
@@ -20,8 +22,9 @@
h1 { h1 {
margin: 0; margin: 0;
font-size: 1.75rem; font-size: var(--font-size-xl);
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em;
} }
} }
@@ -31,19 +34,69 @@
.dashboard__subtitle { .dashboard__subtitle {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.dashboard__actions { .dashboard__actions {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; 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 { h2 {
font-size: 1.1rem; margin: 0 0 var(--space-3);
font-size: var(--font-size-lg);
font-weight: 600; font-weight: 600;
margin: 0 0 1rem;
} }
.banner-error { .banner-error {
@@ -54,116 +107,124 @@ h2 {
.overview { .overview {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem; gap: var(--space-3);
margin-bottom: 2.5rem; margin-bottom: var(--space-5);
} }
.card { .kpi {
padding: 1.25rem; position: relative;
gap: 0.35rem; 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);
} }
.card--gauge { &:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-1px);
}
}
.kpi--gauge {
align-items: center; align-items: center;
text-align: center; text-align: center;
} }
.card--link { .kpi__label {
cursor: pointer; font-size: var(--font-size-xs);
transition: border-color 0.15s ease; font-weight: 600;
&:hover {
border-color: var(--color-primary);
}
}
.card__label {
font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.02em; letter-spacing: 0.06em;
} }
.card__value { .kpi__value {
font-size: 1.6rem; font-size: var(--font-size-2xl);
font-weight: 700; 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);
}
}
.kpi__hint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.kpi__link {
margin-top: auto;
font-size: var(--font-size-sm);
} }
.progress-bar { .progress-bar {
height: 6px; height: 8px;
margin: var(--space-1) 0;
background: var(--color-border-light); background: var(--color-border-light);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
overflow: hidden; overflow: hidden;
margin-top: 0.25rem;
} }
.progress-bar__fill { .progress-bar__fill {
height: 100%; height: 100%;
background: var(--color-primary);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--color-success);
transition: width 0.3s ease; 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 { .chart-section {
margin-bottom: 2.5rem; margin-bottom: var(--space-5);
} }
.alerts-list { .chart-card {
list-style: none; padding: var(--space-3);
}
.dashboard__empty {
margin: 0; margin: 0;
padding: 0; font-size: var(--font-size-sm);
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;
color: var(--color-text-muted); 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 { TestBed } from '@angular/core/testing';
import { vi } from 'vitest'; 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 { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service'; import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service'; import { AlertsService } from '../../core/services/alerts.service';
import { SitesService } from '../../core/services/sites.service';
import { PredictionsService } from '../../core/services/predictions.service'; import { PredictionsService } from '../../core/services/predictions.service';
import { AuthService } from '../../core/services/auth.service'; import { AuthService } from '../../core/services/auth.service';
import {Router, provideRouter} from '@angular/router';
vi.mock('chart.js', () => { vi.mock('chart.js', () => {
class ChartMock { class ChartMock {
@@ -18,66 +19,73 @@ vi.mock('chart.js', () => {
return { Chart: ChartMock, registerables: [] }; return { Chart: ChartMock, registerables: [] };
}); });
const STATS = { total_sites: 7, sites: [] };
function predictionsMock(sites: unknown[] = []) { 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', () => { describe('Dashboard', () => {
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
it('charge les stats, les alertes et les prévisions au démarrage', async () => { it('charge les stats et les prévisions au démarrage', async () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const { fixture, statsMock, predictions } = setup({
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]),
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([]),
],
}); });
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
// laisse le timer(0, ...) se déclencher avant de vérifier
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges(); fixture.detectChanges();
expect(statsMock.getSummary).toHaveBeenCalled(); expect(statsMock.getSummary).toHaveBeenCalled();
expect(alertsMock.getAlerts).toHaveBeenCalled();
expect(predictions.getPredictions).toHaveBeenCalled(); expect(predictions.getPredictions).toHaveBeenCalled();
expect(fixture.componentInstance.alerts().length).toBe(1);
expect(fixture.componentInstance.predictions().length).toBe(1); expect(fixture.componentInstance.predictions().length).toBe(1);
expect(fixture.componentInstance.statsError()).toBeNull(); expect(fixture.componentInstance.statsError()).toBeNull();
expect(fixture.componentInstance.alertsError()).toBeNull();
expect(fixture.componentInstance.predictionsError()).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", () => { it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const statsMock = { const { fixture, statsMock } = setup({
getSummary: vi stats: throwError(() => new Error('API injoignable')),
.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([]),
],
}); });
statsMock.getSummary
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
.mockReturnValue(of(STATS));
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
vi.advanceTimersByTime(1); vi.advanceTimersByTime(1);
@@ -91,45 +99,11 @@ describe('Dashboard', () => {
expect(fixture.componentInstance.statsError()).toBeNull(); 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", () => { 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 { fixture } = setup({
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
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 = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.componentInstance.predictions().length).toBe(0); 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", () => { it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const { fixture } = setup({
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
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 = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.componentInstance.predictionsError()).not.toBeNull(); expect(fixture.componentInstance.predictionsError()).not.toBeNull();
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit // 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', () => { 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 = { const authMock = {
logout: vi.fn().mockReturnValue(of(undefined)), logout: vi.fn().mockReturnValue(of(undefined)),
clearSession: vi.fn(), clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }), principal: vi.fn().mockReturnValue({ role: 'admin' }),
}; };
TestBed.configureTestingModule({ const { fixture } = setup({ auth: authMock });
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(); fixture.detectChanges();
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button'); fixture.nativeElement.querySelector('.logout-button').click();
button.click();
expect(authMock.logout).toHaveBeenCalled(); expect(authMock.logout).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/login']); expect(navigateSpy).toHaveBeenCalledWith(['/login']);
}); });
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { 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 = { const authMock = {
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(), clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }), principal: vi.fn().mockReturnValue({ role: 'admin' }),
}; };
TestBed.configureTestingModule({ const { fixture } = setup({ auth: authMock });
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(); fixture.detectChanges();
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const button = fixture.nativeElement.querySelector('.logout-button'); fixture.nativeElement.querySelector('.logout-button').click();
button.click();
expect(authMock.clearSession).toHaveBeenCalled(); expect(authMock.clearSession).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/login']); expect(navigateSpy).toHaveBeenCalledWith(['/login']);
}); });
it('distingue le ton des sévérités high et critical', () => { it('affiche l’heure du dernier relevé et les indicateurs du parc', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; vi.useFakeTimers();
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; const { fixture } = setup({
stats: of({
TestBed.configureTestingModule({ timestamp: '2026-09-18T09:00:00Z',
imports: [Dashboard], total_sites: 7,
providers: [ total_consumption_kw: 1234.5,
{ provide: StatsService, useValue: statsMock }, total_capacity_kw: 5000,
{ provide: AlertsService, useValue: alertsMock }, average_load_percent: 24.7,
{ provide: PredictionsService, useValue: predictionsMock() }, sites: [],
provideRouter([]), }),
],
}); });
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; const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForSeverity('low')).toBe('success'); expect(dashboard.loadTone(69.9)).toBe('success');
expect(dashboard.badgeToneForSeverity('medium')).toBe('warning'); expect(dashboard.loadTone(70)).toBe('warning');
expect(dashboard.badgeToneForSeverity('high')).toBe('danger'); expect(dashboard.loadTone(89.9)).toBe('warning');
expect(dashboard.badgeToneForSeverity('critical')).toBe('critical'); expect(dashboard.loadTone(90)).toBe('danger');
expect(dashboard.badgeToneForSeverity('high')).not.toBe( expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc');
dashboard.badgeToneForSeverity('critical'),
);
}); });
it('distingue le ton des statuts de prévision', () => { it('distingue le ton des statuts de prévision', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const { fixture } = setup();
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 dashboard = fixture.componentInstance; const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success'); expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
@@ -6,11 +6,10 @@ import { Router, RouterLink } from '@angular/router';
import { StatsService } from '../../core/services/stats.service'; import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; 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 { PredictionsService } from '../../core/services/predictions.service';
import { AuthService } from '../../core/services/auth.service'; import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model'; import { StatsSummary } from '../../shared/models/stats.model';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model'; import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
import { Card } from '../../shared/components/ui/card/card'; import { Card } from '../../shared/components/ui/card/card';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
@@ -22,22 +21,19 @@ const REFRESH_INTERVAL_MS = 10000;
const UNAVAILABLE_MESSAGE = const UNAVAILABLE_MESSAGE =
'Données indisponibles, les valeurs affichées datent du dernier relevé.'; 'Données indisponibles, les valeurs affichées datent du dernier relevé.';
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = { // `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` :
low: 'success', // mieux vaut un ton défini que `undefined` le jour où ce statut apparaît.
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.
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = { const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
available: 'success', available: 'success',
insufficient_data: 'warning', insufficient_data: 'warning',
error: 'danger', error: 'danger',
}; };
const SEUIL_CHARGE_SOUTENUE = 70;
const SEUIL_CHARGE_CRITIQUE = 90;
export type LoadTone = 'success' | 'warning' | 'danger';
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
standalone: true, standalone: true,
@@ -47,6 +43,7 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
RouterLink, RouterLink,
ConsumptionGauge, ConsumptionGauge,
SiteLoadChart, SiteLoadChart,
AlertFeed,
Card, Card,
EvAlert, EvAlert,
Badge, Badge,
@@ -58,32 +55,20 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
}) })
export class Dashboard implements OnInit { export class Dashboard implements OnInit {
private statsService = inject(StatsService); private statsService = inject(StatsService);
private alertsService = inject(AlertsService);
public auth = inject(AuthService); public auth = inject(AuthService);
private predictionsService = inject(PredictionsService); private predictionsService = inject(PredictionsService);
private router = inject(Router); private router = inject(Router);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
stats = signal<StatsSummary | null>(null); stats = signal<StatsSummary | null>(null);
alerts = signal<Alert[]>([]);
predictions = signal<SitePredictionSummary[]>([]); predictions = signal<SitePredictionSummary[]>([]);
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats) // Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus, // silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur.
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
statsError = signal<string | null>(null); statsError = signal<string | null>(null);
alertsError = signal<string | null>(null);
predictionsError = signal<string | null>(null); predictionsError = signal<string | null>(null);
ngOnInit(): void { 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 // 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`. // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
this.predictionsService this.predictionsService
@@ -99,7 +84,9 @@ export class Dashboard implements OnInit {
timer(0, REFRESH_INTERVAL_MS) timer(0, REFRESH_INTERVAL_MS)
.pipe( .pipe(
switchMap(() => switchMap(() =>
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))), this.statsService
.getSummary()
.pipe(catchError(() => this.reportUnavailable(this.statsError))),
), ),
takeUntilDestroyed(this.destroyRef), 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 { badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
return TON_PAR_STATUT_PREDICTION[status]; 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 { onLogout(): void {
this.auth.logout().subscribe({ this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']), next: () => this.router.navigate(['/login']),
@@ -17,8 +17,8 @@
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
<ev-card class="table-card"> <ev-card class="ev-table-card">
<table class="sites-table"> <table class="ev-table">
<thead> <thead>
<tr> <tr>
<th>Nom</th> <th>Nom</th>
@@ -36,7 +36,9 @@
<td>{{ site.site_type }}</td> <td>{{ site.site_type }}</td>
<td>{{ site.location || '-' }}</td> <td>{{ site.location || '-' }}</td>
<td>{{ site.capacity_kw ?? '-' }}</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> <td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
</tr> </tr>
} }
@@ -32,32 +32,3 @@
display: block; display: block;
margin: 0 0 1.5rem; 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/forms';
@use 'styles/auth-page'; @use 'styles/auth-page';
@use 'styles/links'; @use 'styles/links';
@use 'styles/tables';
body { body {
margin: 0; 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 // Typo, rayons, ombre
--font-family: 'Segoe UI', system-ui, sans-serif; --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-sm: 8px;
--radius-md: 12px; --radius-md: 12px;
--radius-pill: 999px; --radius-pill: 999px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06); --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-card-hover: 0 6px 16px rgba(0, 0, 0, 0.08);
// Espacements // Espacements
--space-1: 0.35rem; --space-1: 0.35rem;
+1 -1
View File
@@ -1,4 +1,4 @@
-- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base -- Base de metadonnees Airflow (api-server + scheduler + dag-processor, LocalExecutor). Separee de la base
-- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire -- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire
-- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un -- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un
-- service dedie, pour ne pas ajouter un conteneur de plus (issue #115). -- service dedie, pour ne pas ajouter un conteneur de plus (issue #115).
+11 -6
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. # 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 # 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. # `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 name: enervision
@@ -12,12 +14,15 @@ services:
db: db:
ports: !override ports: !override
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432" - "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: mailpit:
ports: !override ports: !override
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025" - "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
airflow-webserver: airflow-apiserver:
ports: !override ports: !override
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080" - "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
@@ -37,22 +42,22 @@ services:
APP_ENV: prod APP_ENV: prod
APP_DEBUG: "false" APP_DEBUG: "false"
APP_TRUST_PROXY_HEADERS: "true" APP_TRUST_PROXY_HEADERS: "true"
APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local} APP_CORS_ORIGINS: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}
APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password APP_FRONTEND_RESET_PASSWORD_URL: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}/reset-password
frontend: frontend:
ports: !reset null ports: !reset null
proxy: proxy:
image: nginx:1.28-alpine image: nginx:1.31-alpine
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
frontend: frontend:
condition: service_started condition: service_started
ports: ports:
- "80:80" - "${PROXY_HTTP_PORT:-80}:80"
- "443:443" - "${PROXY_HTTPS_PORT:-443}:443"
volumes: volumes:
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro - ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro - ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
+32 -13
View File
@@ -5,9 +5,9 @@
name: enervision name: enervision
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du # Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais de
# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte # l'api-server. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte
# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce # que sur `airflow-scheduler` en pratique, mais reste partage avec l'api-server pour que ce
# dernier puisse au besoin l'inspecter sans en devenir dependant. # dernier puisse au besoin l'inspecter sans en devenir dependant.
x-airflow-common: &airflow-common x-airflow-common: &airflow-common
build: build:
@@ -19,9 +19,16 @@ x-airflow-common: &airflow-common
# Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de # Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de
# filtrer les services : une variable requise manquante casserait aussi `make db-up`, # filtrer les services : une variable requise manquante casserait aussi `make db-up`,
# `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par # `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par
# `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent. # `airflow-init` (ci-dessous), dont `api-server`, `dag-processor` et `scheduler` dependent.
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-} AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-} AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-}
# Signe les jetons entre scheduler, tâches et api-server. Conteneurs distincts : un secret
# généré au démarrage ne serait pas partagé, il doit venir du .env.
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-}
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
# FabAuthManager plutôt que le SimpleAuthManager par défaut d'Airflow 3 : seul le provider
# FAB sait créer le compte admin que `airflow-init` pose via `_AIRFLOW_WWW_USER_*`.
AIRFLOW__CORE__AUTH_MANAGER: airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) : # Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) :
# memes identifiants que le backend en attendant. # memes identifiants que le backend en attendant.
@@ -36,6 +43,7 @@ x-airflow-common: &airflow-common
volumes: volumes:
- ./etl/airflow/dags:/opt/airflow/dags - ./etl/airflow/dags:/opt/airflow/dags
- ./etl/airflow/plugins:/opt/airflow/plugins - ./etl/airflow/plugins:/opt/airflow/plugins
- ./data/raw:/opt/data/raw:ro
- airflow_logs:/opt/airflow/logs - airflow_logs:/opt/airflow/logs
- airflow_ml_state:/opt/ml/state - airflow_ml_state:/opt/ml/state
restart: unless-stopped restart: unless-stopped
@@ -110,11 +118,11 @@ services:
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont # Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
# portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte # portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte
# aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume # aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume
# `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son # `pgdata` deja peuple) fait echouer ce service, et api-server, dag-processor et scheduler,
# succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement, # qui attendent son succes, ne demarrent pas sur une base non migree. Le mot de passe passe
# jamais par `argv` (ni `ps`, ni `docker compose config`). # par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
# Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous # Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous
# refuse en plus les deux cles de chiffrement vides. # refuse en plus les cles et secrets vides.
airflow-init: airflow-init:
<<: *airflow-common <<: *airflow-common
restart: "no" restart: "no"
@@ -134,13 +142,14 @@ services:
- | - |
set -euo pipefail set -euo pipefail
: "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}" : "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}"
: "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}" : "$${AIRFLOW__API__SECRET_KEY:?AIRFLOW_API_SECRET_KEY manquant dans .env}"
: "$${AIRFLOW__API_AUTH__JWT_SECRET:?AIRFLOW_JWT_SECRET manquant dans .env}"
: "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}" : "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}"
exec airflow version exec airflow version
airflow-webserver: airflow-apiserver:
<<: *airflow-common <<: *airflow-common
command: webserver command: api-server
ports: ports:
- "${AIRFLOW_PORT:-8080}:8080" - "${AIRFLOW_PORT:-8080}:8080"
depends_on: depends_on:
@@ -149,7 +158,7 @@ services:
airflow-init: airflow-init:
condition: service_completed_successfully condition: service_completed_successfully
healthcheck: healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"] test: ["CMD", "curl", "--fail", "http://localhost:8080/api/v2/monitor/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@@ -164,6 +173,16 @@ services:
airflow-init: airflow-init:
condition: service_completed_successfully condition: service_completed_successfully
# Obligatoire depuis Airflow 3 : le scheduler ne parse plus les fichiers de dags/ lui-même.
airflow-dag-processor:
<<: *airflow-common
command: dag-processor
depends_on:
db:
condition: service_healthy
airflow-init:
condition: service_completed_successfully
volumes: volumes:
pgdata: pgdata:
airflow_logs: airflow_logs:
+26 -6
View File
@@ -90,14 +90,25 @@ consommation prévue de **l'heure suivant sa dernière lecture connue**, et écr
### Ce que le run écrit, et ce qu'il n'écrase pas ### Ce que le run écrit, et ce qu'il n'écrase pas
La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run
insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rend
possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui possible la comparaison prévision contre réalisé. La surveillance de dérive s'en sert : elle
n'existe pas encore. retient, pour chaque `(site_id, target_at)`, la ligne du run le plus récent, celle-là même que
sert `GET /api/v1/predictions`. Voir l'[ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md).
Trois contraintes de cohérence sont portées par la base et non par le code applicatif : Trois contraintes de cohérence sont portées par la base et non par le code applicatif :
`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ; `status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ;
`insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à `insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à
`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. `consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`. Elles
sont vérifiées depuis le code qui écrit par `ml/tests/test_score_integration.py`, sur une vraie
base : un double ne prouverait rien d'une contrainte SQL.
**`--now` borne la fenêtre des deux côtés.** `load_recent_from_database` exige un `until` autant
qu'un `since`, et le scoring lui passe l'instant de référence. Sans cette borne haute,
`build_scoring_frame` repartait de la dernière lecture de toute la table quelle que soit la valeur
demandée : `target_at` valait toujours « fin du jeu + 1 h », et l'âge de la dernière lecture
devenait négatif sans franchir le seuil de péremption. Rejouer le scoring sur des instants passés
produit désormais des prévisions dont le réalisé existe déjà, ce dont la surveillance de dérive a
besoin pour se démontrer sur un jeu figé.
### `model_reference` est un hachage, pas un nom de fichier ### `model_reference` est un hachage, pas un nom de fichier
@@ -142,6 +153,10 @@ flowchart LR
train -- "models/*.txt + run MLflow" --> score train -- "models/*.txt + run MLflow" --> score
score -- "INSERT" --> prediction score -- "INSERT" --> prediction
prediction -- "lecture seule" --> route prediction -- "lecture seule" --> route
prediction -- "prévu" --> derive["app.monitoring.drift<br/>écart prévu / réalisé"]
reading -- "réalisé" --> derive
derive -- "INSERT" --> rapport[("drift_report")]
rapport -- "lecture seule" --> monitoring["GET /api/v1/monitoring/drift"]
``` ```
**La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.** **La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.**
@@ -163,8 +178,12 @@ flowchart LR
Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est
celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly` celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly`
(issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme (issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme
l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la l'entraînement, dont le DAG `ml_train` n'a pas de planification.
surveillance de dérive, portée par les issues #44 et #45.
La surveillance de dérive traverse cette frontière **dans le sens de la table vers le backend**,
sans la percer : elle relit `prediction` et `reading` en SQL, ne charge aucun modèle, et n'appelle
pas MLflow. Son calcul, son seuil et son refus de comparer à la métrique d'entraînement sont dans
l'[ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md).
--- ---
@@ -175,3 +194,4 @@ surveillance de dérive, portée par les issues #44 et #45.
- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions - [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions
- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions` - [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions`
- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données - [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données
- [ADR 0011](adr/0011-surveillance-de-derive-dans-le-backend.md) : la surveillance de dérive
+3
View File
@@ -15,3 +15,6 @@
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` | | [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 | | [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 | | [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é |
| [0010](adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
| [0011](adr/0011-surveillance-de-derive-dans-le-backend.md) | La surveillance de dérive vit dans le backend et écrit sa propre table |
@@ -0,0 +1,82 @@
# 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. Airflow 3 n'a rien à régler de ce côté : son api-server lance un seul worker par défaut,
là où le webserver d'Airflow 2 en lançait quatre. 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. Cette racine existe depuis l'[ADR 0010](0010-terraform-provisionne-github-actions-deploie.md),
sous le nom `environments/vm-eni`, et `environments/prod` a disparu avec elle.
- 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.
@@ -0,0 +1,77 @@
# 0010 - Terraform provisionne la machine, GitHub Actions déploie l'application
- Statut : accepté
- Date : 2026-09-22
## Contexte
L'[ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md) a posé la livraison : deux
projets Compose sur la VM ENI, alignés sur `dev` et sur `main` par un runner auto-hébergé. Elle
ne dit pas qui prépare la machine. C'est `scripts/provision-host.sh`, lancé à la main en SSH.
La grille d'évaluation attend en C22 que l'infrastructure soit « provisionnée via du code
(Terraform, Ansible…) ». Le seul Terraform du dépôt installe un cluster k3s que rien ne
consomme, qui n'a jamais été appliqué, et dont la racine ne passait même pas `terraform init`
depuis que Terraform refuse les provisioners `destroy` dont la connexion lit autre chose que
`self`. Sa racine s'appelait `environments/dev`, nom qui laissait croire à un environnement
applicatif alors que les deux environnements réels sont `rec` et `prod`, sur la même machine.
La branche `feat/deploy` (PR #141) proposait la réponse inverse : Terraform construit les
images, lance les conteneurs et copie les sources par SSH. La revue a relevé deux racines sur
trois qui ne passent pas `terraform validate`, le mot de passe SSH écrit en clair dans le state,
un backend lancé sans base ni variables d'environnement, et trois architectures différentes pour
trois environnements.
## Décision
**Terraform provisionne la machine, GitHub Actions déploie l'application.** La frontière est
nette et vérifiable : `infra/terraform/environments/vm-eni` installe Docker et le plugin
Compose, exécute `scripts/provision-host.sh`, enregistre le runner. Il ne construit aucune
image, ne lance aucun conteneur, et un `apply` n'interrompt pas la stack qui tourne.
**Le déploiement continu ne change pas.** `deploy.yml` reste le seul chemin de livraison : push
sur `dev` ou `main`, alignement du clone, `make stack-up`, sonde `/api/v1/health/ready`.
**Le Bash reste la mécanique, Terraform devient le point d'entrée.** `provision-host.sh` connaît
les deux environnements, leurs ports décalés, leurs secrets et leurs certificats. Le réécrire en
HCL créerait une seconde source de vérité qui divergerait au premier changement de port.
**Aucun secret dans le state.** Authentification SSH par clé seulement, pas de variable de mot
de passe. Le jeton d'enregistrement du runner est une variable `sensitive` fournie à l'`apply`,
jamais un `trigger` : les `triggers` sont la seule partie d'un `null_resource` que Terraform
persiste.
**Les racines portent le nom de ce qu'elles provisionnent**, pas d'un environnement applicatif :
`vm-eni` pour la machine, `k3s-cible` pour le cluster resté en cible. `environments/prod`,
dossier vide, disparaît.
## Alternatives écartées
- **Ansible à la place du Bash** : plus idiomatique pour de la configuration de machine, et le
jury le reconnaîtrait immédiatement comme de l'IaC. Mais c'est un outil de plus à installer et
à faire tourner, pour réécrire un script qui fonctionne, à trois jours du gel technique.
- **Provisioners applicatifs de `feat/deploy`** : voir la revue sur #141. Terraform y devenait un
orchestrateur concurrent de Compose, sans base de données ni migrations.
- **Terraform appelle aussi `make stack-up`** : le premier démarrage serait plus court d'une
commande, mais Terraform se mettrait à porter la livraison, que le runner rejoue à chaque
push. Deux chemins pour le même acte, c'est précisément ce que #141 montre qu'il ne faut pas.
- **k3s tout de suite** : le cluster serait vide, sans manifeste, sans registre et sans stockage
persistant. Le module reste, documenté comme cible.
- **State Terraform distant** : un seul opérateur, pas d'exécution concurrente. Le backend local
suffit, comme pour `k3s-cible`.
## Conséquences
- Le premier `apply` exige un jeton d'enregistrement du runner, valable une heure et pour une
seule inscription, que seul un administrateur du dépôt peut créer. L'`apply` n'est donc pas
rejouable sans intervention humaine, ce qui est acceptable : il ne se joue qu'à l'installation.
- L'utilisateur propriétaire de `/srv/enervision` doit exister sur la machine avant l'`apply`.
Terraform vérifie et échoue tôt plutôt que de le créer : décider d'un compte système est une
décision d'administration, pas un effet de bord de déploiement.
- Terraform ne sait rien de l'état de la stack. `terraform plan` ne dira jamais que la recette
est tombée ; c'est la sonde de `deploy.yml` qui le dit.
- Pas de provisioner `destroy` sur le runner : il imposerait de mettre le chemin de la clé SSH
dans le state, et `svc.sh uninstall` ne désinscrit pas le runner côté GitHub. Le retrait reste
manuel, depuis les paramètres du dépôt.
- C22 cesse de reposer sur `docker-compose.prod.yml` seul. C23 reste porté par les scripts, que
Terraform appelle désormais au lieu de les remplacer.
@@ -0,0 +1,120 @@
# 0011 - La surveillance de dérive vit dans le backend et écrit sa propre table
- Statut : accepté
- Date : 2026-09-22
## Contexte
L'issue #45 demande des tests d'intégration API ↔ DB ↔ ML. Trois documents du dépôt annoncent
par ailleurs, depuis le jalon J3, une surveillance de dérive qui n'existe nulle part :
`docs/architecture/00-vue-ensemble.md` (« Surveillance de dérive (EC06, #44/#45) pas encore
construite »), `docs/ML-START.md` (« la dette qui subsiste est la surveillance de dérive »), et
le docstring de `write_predictions()` dans `ml/enervision_ml/score.py`, qui justifie l'absence
d'unicité sur `(site_id, target_at)` par la comparaison future entre prévu et réalisé.
La matière première est en base : `prediction` porte ce que le modèle a annoncé, `reading` ce
qui est réellement arrivé. Restaient trois questions : où vit le calcul, à quoi on compare, et
où atterrit le résultat.
## Décision
**Le calcul vit dans `apps/backend`** : `repositories/drift.py` pour le SQL, `services/drift.py`
pour la logique, `monitoring/drift.py` pour la CLI, `api/v1/endpoints/monitoring.py` pour la
lecture. Le dossier `ml/` ne gagne pas une ligne.
**Le résultat est persisté** dans une table `drift_report`, une ligne par site plus une ligne
globale que `site_id` à NULL désigne.
**La comparaison oppose deux fenêtres vives de 168 h**, la récente et celle qui la précède, et
le verdict a trois valeurs : `stable`, `derive`, `indetermine`.
### Pourquoi le backend, alors que le sujet est le modèle
- **`prediction` n'est pas dans le périmètre de `ML_DATABASE_URL`.** `enervision_ml/config.py`,
`docs/ML-START.md` et l'[ADR 0003](0003-autorisation-rbac-a-trois-roles.md) désignent pour
cette variable un rôle PostgreSQL restreint **en lecture sur `reading` et `site`**. Mettre la
dérive dans `ml/` obligerait à élargir ce rôle à `prediction`, et à l'écriture : ce serait
contredire par le code la dette de moindre privilège que ces trois documents ont posée par
écrit.
- **L'alignement prévu contre réalisé existe déjà ici, une fois.** `AlertService._detect_anomaly`
croise `reading` et `prediction` sur le même instant, et `PredictionRepository.list_since`
porte déjà le piège des runs empilés. Le réécrire en SQL brut dans `ml/` créerait une seconde
source de vérité sur « quelle prédiction correspond à quelle lecture », ce que
l'[ADR 0006](0006-moteur-de-regles-dans-le-backend.md) a déjà refusé pour les règles.
- **La frontière de `docs/ML-START.md` tient.** FastAPI ne fait toujours pas tourner LightGBM :
la dérive lit deux tables et compare des nombres, elle n'évalue aucun modèle.
**Conséquence assumée** : `enervision_ml.metrics.regression_metrics` n'est pas réutilisable, le
backend n'important pas `enervision_ml`. MAE, MAPE et biais sont donc réécrits, une quinzaine de
lignes. Cette duplication n'est pas celle que `build_features` interdit : une divergence de
features est silencieuse et ruine les prévisions sans erreur, une divergence sur une moyenne
d'écarts absolus est attrapée par le premier test à valeurs connues.
### Ce qu'on mesure, et les deux dédoublonnages obligatoires
La paire est `prediction ⋈ reading` sur `(site_id, target_at = timestamp)`, restreinte aux
prédictions `available`. Elle exige un `DISTINCT ON` **des deux côtés** :
- `prediction` n'a pas d'unicité sur `(site_id, target_at)`, chaque run de scoring empile une
ligne. On retient la plus récente, celle que sert `GET /api/v1/predictions`, départagée par
`prediction_id` : `created_at` vaut l'heure de début de transaction et ne distingue pas deux
lignes du même run.
- `uq_reading_source` autorise deux lectures au même instant quand la `source` diffère. Sans
dédoublonnage, la jointure compterait cette heure deux fois et pondérerait doublement le site.
La fenêtre est **fermée à droite par un délai de grâce de 2 h** : le réalisé de la dernière
heure n'est pas encore ingéré, et l'inclure ferait chuter le taux de couverture à chaque
exécution, pour une raison qui n'a rien à voir avec le modèle.
Métriques retenues : `mae` (la métrique même qu'optimise LightGBM), **`bias` signé** (une MAE qui
monte dit « moins bon », un biais qui s'éloigne de zéro dit « le modèle se trompe toujours du
même côté », signature d'un décalage de distribution), `mape`, `n_observations`,
`coverage_ratio` et `insufficient_data_ratio` (qui mesurent le pipeline, pas le modèle), et la
liste des `model_references` vus dans la fenêtre : une MAE qui saute à l'instant exact où le
modèle change n'est pas une dérive, c'est une régression de réentraînement.
## Alternatives écartées
| Écartée | Raison |
|---|---|
| Comparer à la métrique MLflow de l'entraînement | Ce ne sont pas les mêmes grandeurs : `train.py` mesure un backtest où la météo de l'heure cible est connue, le scoring prévoit une heure future dont la météo est `NaN` et dont `is_working_hours` est recopié. Le verdict serait « dérive » dès le premier jour. Et le backend devrait importer `mlflow`, ce que la frontière de ML-START interdit. |
| Écrire le résultat dans `alert` | `ck_alert_source` et `ck_alert_type` bornent les valeurs autorisées, `alert.site_id` est `NOT NULL` et n'accueillerait donc pas la ligne globale, et toute alerte est ensuite relue par le moteur de recommandations, qui devrait apprendre une règle qui ne le concerne pas (ADR 0006). |
| Une jauge Prometheus | `monitoring/` ne contient que des `.gitkeep` et aucun collecteur ne lit `/metrics` : une jauge que personne ne scrute n'est pas une preuve. Le calcul est de surcroît un traitement par lot, pas le processus qui sert l'API : la jauge disparaîtrait avec lui. |
| Ne rien persister, journaliser seulement | La question posée à un jury est « comment savez-vous que le modèle se dégrade ? ». La réponse est une série dans le temps, pas une ligne de journal perdue avec le conteneur. Sans ligne écrite, l'endpoint n'a rien à lire et le test d'intégration rien à vérifier. |
| Une tâche de plus dans le DAG `alertes` | La fenêtre fait 168 h : la recalculer chaque heure écrirait vingt-quatre lignes par jour pour un verdict qui ne bouge pas à cette cadence. Surtout, un échec de dérive ferait rougir `alertes` et laisserait croire que la détection a échoué. |
## Conséquences
- Une migration ajoute `drift_report`. Son idempotence passe par un **index unique à
`coalesce(site_id, '')`** et non par une `UniqueConstraint` : deux lignes globales ont toutes
deux `site_id` à NULL, et NULL n'est égal à rien, pas même à lui-même. Même forme que
`uq_reading_source`. Cet index n'a de sens que parce que `evaluate()` **tronque son instant de
référence à l'heure** : avec les microsecondes de `now()`, deux exécutions ne porteraient jamais
la même clé et l'index ne dédoublonnerait rien.
- **Sans fenêtre de référence, le verdict est `indetermine`, pas `stable`.** Au premier
lancement, et après tout trou d'ingestion de plus de 168 h, il n'y a rien à quoi comparer :
annoncer `stable` serait affirmer ce que la donnée ne dit pas.
- **Un site présent dans la fenêtre de référence et absent de la récente reçoit sa ligne**, à
zéro observation. Un site qui cesse d'être scoré est exactement la panne que cette surveillance
existe pour dire : le taire en ne produisant aucune ligne serait l'inverse du besoin.
- `GET /api/v1/monitoring/drift` est réservé à partir du rôle `operateur` : c'est l'opérateur
qui agit sur un pipeline dégradé, pas l'administrateur de comptes. La route est classée dans
`tests/api/acces.py`, donc couverte gratuitement par la matrice de rôles rejouée avec de vrais
jetons.
- Un DAG `derive` quotidien l'ordonnance, sans reprise : rejouer une dérive la redéclarerait à
l'identique.
- La CLI sort en code non nul sous `--fail-on-drift` seulement. Par défaut, constater une dérive
n'est pas un échec d'exécution.
## Effet de bord assumé sur le pipeline
La dérive n'a de matière que si des paires prévu/réalisé existent. Or `enervision_ml.score --now`
ne rejouait pas l'historique : `load_recent_from_database` n'avait pas de borne haute et
`build_scoring_frame` repartait de la dernière lecture connue, si bien que `target_at` valait
toujours « fin du jeu + 1 h » et que l'âge de la dernière lecture devenait négatif sans franchir
le seuil de péremption. Sur le jeu historique, figé au 31/12/2024, aucune boucle de rattrapage
n'aurait donc rien produit de vérifiable.
`until` est devenu obligatoire sur ce chargeur, et le scoring lui passe son instant de référence.
Le comportement en exploitation ne change pas, aucune lecture n'étant postérieure à l'heure
courante ; seul le rattrapage sur données passées devient possible.
+12 -8
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 intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
[30-frontend.md](30-frontend.md). [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 : cinq 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 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 génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset
reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit. 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 Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
collecteur ne vient le lire. collecteur ne vient le lire.
@@ -85,16 +86,16 @@ collecteur ne vient le lire.
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) | | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
| 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 | | 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 livrée côté backend (`app.monitoring.drift`, table `drift_report`, `GET /monitoring/drift`, DAG `derive`), voir [ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md) |
| 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 | | 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)). Provisionnement de la VM par Terraform, qui installe Docker, prépare les deux environnements et enregistre le runner, jamais appliqué ([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.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 | | 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 | | ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Cinq DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes`, `historical_import` et `derive` (quotidien, surveillance de dérive). 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` | 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) | | CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 7 workflows, 19 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, formatage et validation du Terraform. 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 ## Flux bout en bout
Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin
d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`, d'ingestion dessiné ci-dessous n'existe pas** : les DAGs livrés (`ml_train`, `ml_score`,
issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas
l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16). l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16).
@@ -186,3 +187,6 @@ Elles vivent dans `../adr/`, pas ici.
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM | | [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/` | | [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 | | [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é |
| [0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) | Terraform provisionne la machine, GitHub Actions déploie l'application |
+91 -17
View File
@@ -1,12 +1,14 @@
# Infrastructure # Infrastructure
Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans Plusieurs topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles. dans quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.
| Topologie | Sert à | Statut | | Topologie | Sert à | Statut |
|---|---|---| |---|---|---|
| Docker Compose | Développer et recetter sur le poste | `Fait` | | Docker Compose | Développer et recetter sur le poste | `Fait` |
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `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` |
| Provisionnement Terraform de la VM | Préparer la machine et enregistrer le runner | `En cours` |
| k3s single-node | Cible à terme | `En cours` | | k3s single-node | Cible à terme | `En cours` |
## Poste de développement ## Poste de développement
@@ -48,20 +50,28 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin
- `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de - `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
le compose. Voir [`db/README.md`](../../db/README.md). le compose. Voir [`db/README.md`](../../db/README.md).
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver : - `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais de l'api-server :
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow). 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 Quatre services (Airflow 3.3), `docker compose profiles` non utilisés (démarrage explicite via `make
airflow-up`, pas dans `make dev`) : airflow-up`, pas dans `make dev`) :
| Service | Rôle | Points notables | | Service | Rôle | Points notables |
|---|---|---| |---|---|---|
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès | | `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `api-server`, `dag-processor` et `scheduler` attendent qu'il se termine avec succès |
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même | | `airflow-apiserver` | UI et API REST (`/api/v2`), port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même. Sert aussi l'Execution API que les tâches appellent, d'où le secret JWT partagé |
| `airflow-dag-processor` | Parse `dags/` et publie les DAGs sérialisés | Composant à part entière depuis Airflow 3 : le scheduler ne lit plus les fichiers de DAG |
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` | | `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` |
Airflow 3 impose deux choses que le compose reflète : les tâches ne touchent plus la base de
métadonnées et passent par l'Execution API de l'`api-server`, avec un jeton signé par
`AIRFLOW_JWT_SECRET` (secret partagé entre conteneurs, jamais celui généré au démarrage) ; et
l'authentification par défaut (`SimpleAuthManager`) ne sait pas créer de compte, d'où le
`FabAuthManager` qui garde le compte admin posé par `airflow-init`. Pas de `triggerer` : aucun
opérateur déférable dans les DAGs.
Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) : Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) :
l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser
deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à
@@ -75,6 +85,13 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` | | `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` |
| `ml_score` | `0 * * * *` | `enervision_ml.score`, 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` | | `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` |
| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère |
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 **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 `prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
@@ -95,14 +112,14 @@ rend contraignant.
`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`,
`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une `_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une
migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et
`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin `api-server`, `dag-processor` et `scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`). passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier
entier avant de filtrer les services, une variable requise manquante casserait `make db-up`, entier avant de filtrer les services, une variable requise manquante casserait `make db-up`,
`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est `make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est
`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou `airflow-init` qui refuse de démarrer (clé Fernet, clé de session de l'API, secret JWT, mot de
`AIRFLOW_APP_SECRET_KEY` vides). passe ou `AIRFLOW_APP_SECRET_KEY` vides).
Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en
dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est
@@ -169,10 +186,63 @@ 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 - `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. 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`.
### Provisionnement de la machine
Statut : `En cours`. Décision et frontière dans
l'[ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) : **Terraform
provisionne la machine, GitHub Actions déploie l'application**. La racine
`infra/terraform/environments/vm-eni/` fait trois choses, et rien d'autre.
```mermaid
sequenceDiagram
participant TF as terraform apply
participant VM as VM eadl-2025-nantes-g3
participant GH as GitHub
TF->>VM: SSH, get.docker.com puis docker compose version
TF->>VM: copie et exécute scripts/provision-host.sh
VM->>VM: deux clones, deux .env, deux certificats
TF->>VM: installe actions-runner, config.sh, svc.sh
VM->>GH: le runner s'enregistre avec le label eni-g3
```
Aucune image n'y est construite, aucun conteneur lancé : un `apply` n'interrompt pas la stack qui
tourne. Le premier démarrage reste manuel, `make stack-up` dans chaque dossier ; les suivants
sont joués par le runner à chaque push. Terraform ne sait rien de l'état de la stack, c'est la
sonde de `deploy.yml` qui le dit.
Le jeton d'enregistrement du runner est valable une heure et ne vaut que pour une inscription :
l'`apply` n'est pas rejouable sans qu'un administrateur du dépôt en crée un nouveau.
## Cible à terme, k3s ## Cible à terme, k3s
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la
été appliqué. racine `infra/terraform/environments/k3s-cible/`. Il n'a jamais été appliqué.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -220,13 +290,18 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| `k3s_version` obligatoire, valeur vide refusée | Sans épinglage, `get.k3s.io` installe la dernière version à chaque exécution : le déploiement cesse d'être reproductible | `validation` dans `modules/k3s/variables.tf` | | `k3s_version` obligatoire, valeur vide refusée | Sans épinglage, `get.k3s.io` installe la dernière version à chaque exécution : le déploiement cesse d'être reproductible | `validation` dans `modules/k3s/variables.tf` |
| Traefik désactivé | Le choix d'ingress reste ouvert, on ne veut pas en subir un par défaut | `k3s_disable_components`, défaut `["traefik"]` | | Traefik désactivé | Le choix d'ingress reste ouvert, on ne veut pas en subir un par défaut | `k3s_disable_components`, défaut `["traefik"]` |
| Kubeconfig laissé en `600/root`, lu par `sudo` | `--write-kubeconfig-mode 644` exposerait `cluster-admin` à tout utilisateur local de la machine | Commentaire et `fetch_kubeconfig` dans `modules/k3s/main.tf` | | Kubeconfig laissé en `600/root`, lu par `sudo` | `--write-kubeconfig-mode 644` exposerait `cluster-admin` à tout utilisateur local de la machine | Commentaire et `fetch_kubeconfig` dans `modules/k3s/main.tf` |
| State Terraform en backend `local` | Un seul opérateur, pas d'exécution concurrente, pas de dépendance à un stockage distant | `environments/dev/versions.tf` | | State Terraform en backend `local` | Un seul opérateur, pas d'exécution concurrente, pas de dépendance à un stockage distant | `versions.tf` de chaque racine |
| `.terraform.lock.hcl` versionné | Fige les versions de provider entre contributeurs et future CI | Commentaire dans `.gitignore` | | `.terraform.lock.hcl` versionné | Fige les versions de provider entre contributeurs et future CI | Commentaire dans `.gitignore` |
| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` | | `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` |
| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` | | Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` |
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` | | Une racine Terraform par machine provisionnée, nommée d'après elle | `environments/dev` laissait croire à un environnement applicatif, alors que `rec` et `prod` vivent sur la même machine et ne sont pas provisionnés par Terraform | `environments/vm-eni`, `environments/k3s-cible` |
| Terraform provisionne, GitHub Actions déploie | Deux chemins pour le même acte de livraison, c'est ce que la revue de #141 relève sur la VM | [ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md) |
| Connexion SSH par clé, jamais par mot de passe | Une variable de mot de passe finit en clair dans le state, ou dans les `triggers` qui y sont persistés | `environments/vm-eni/variables.tf`, `modules/k3s/main.tf` |
| 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) | | 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` | | 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 ## Ports et noms
@@ -237,12 +312,12 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| API | `8000` | Identique en conteneur et hors conteneur | | 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, `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 | | 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 | | SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
| Base applicative | `enervision` | Variable `POSTGRES_DB` | | 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` | | Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` | | Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre | | API server Airflow | `8080` | `make airflow-up`. Api-server, scheduler et dag-processor ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
## Le trou vers k3s ## Le trou vers k3s
@@ -261,4 +336,3 @@ question à trancher, avant toute ressource Kubernetes.
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le - **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
cluster ou à côté. cluster ou à côté.
- **Quelle stratégie de sauvegarde et de restauration** des données de mesure. - **Quelle stratégie de sauvegarde et de restauration** des données de mesure.
- **Que devient `environments/prod/`**, aujourd'hui réduit à un `.gitkeep`.
+33 -4
View File
@@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid ```mermaid
flowchart TB flowchart TB
ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors, predictions"] ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors,<br/>predictions, monitoring"]
sc["schemas<br/>Pydantic"] sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, ReadingService, SensorService, PredictionService"] sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, ReadingService, SensorService,<br/>PredictionService, DriftService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading, prediction"] rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading,<br/>prediction, drift"]
md["models<br/>10 tables"] md["models<br/>13 tables"]
db[("PostgreSQL")] db[("PostgreSQL")]
ep --> sc ep --> sc
@@ -151,6 +151,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 | | GET | `/api/v1/readings` | Historique des lectures, filtrable par `site_id`, fenêtre `start`/`end` (24h par défaut, 90 jours maximum) et paginé par `limit`/`offset`. `lecteur` | 400, 401, 403, 422, 500 |
| GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 | | GET | `/api/v1/sensors/status` | État de santé des capteurs par site, dérivé de la dernière lecture. `admin` | 401, 403, 500 |
| GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 | | GET | `/api/v1/predictions` | Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (`ml/`). `lecteur` | 401, 403, 500 |
| GET | `/api/v1/monitoring/drift` | Dernier rapport de dérive par site, plus la ligne globale. `operateur` | 401, 403, 422, 500 |
| GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | | GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | |
| GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | | GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | |
@@ -223,6 +224,34 @@ par exemple `limit` hors bornes). Un datetime sans fuseau dans `start`/`end` est
l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait l'UTC plutôt que rejeté : le comparer tel quel à `reading.timestamp` (`timestamptz`) échouerait
côté pilote, en `500` plutôt qu'un refus propre. côté pilote, en `500` plutôt qu'un refus propre.
### Surveillance de dérive
`DriftService.evaluate()` joint `prediction` et `reading` sur `(site_id, target_at = timestamp)`
et compare deux fenêtres vives de 168 h, la récente et celle qui la précède. Il rend une ligne par
site plus une ligne globale, que `DriftRepository.enregistre()` écrit dans `drift_report` avec
`ON CONFLICT DO NOTHING` sur `uq_drift_report_window`. L'instant de référence est tronqué à
l'heure, ce qui est la condition pour que cet index serve : rejouer la commande dans la même
heure n'ajoute rien.
| Métrique | Ce qu'elle dit |
|---|---|
| `mae` | Erreur moyenne en kWh, la métrique même qu'optimise LightGBM |
| `bias` | Erreur moyenne **signée** : c'est elle qui distingue un modèle plus bruyant d'un modèle qui se trompe systématiquement du même côté |
| `mape` | Comparable entre sites de tailles différentes, hors réalisés nuls |
| `coverage_ratio` | Part des prévisions disponibles qui ont trouvé leur réalisé : mesure le pipeline, pas le modèle |
| `insufficient_data_ratio` | Part des sites privés d'historique suffisant |
| `model_references` | Les modèles vus dans la fenêtre : une MAE qui saute à l'instant où le modèle change est une régression de réentraînement, pas une dérive |
Le verdict a trois valeurs, `stable`, `derive` et `indetermine` : sous un nombre minimal
d'observations, ou faute de fenêtre de référence à laquelle comparer, le service dit qu'il ne
sait pas plutôt que de rendre un chiffre trompeur. Un site qui figure dans la fenêtre de
référence mais plus dans la récente reçoit sa ligne à zéro observation : cesser d'être scoré est
la panne que cette surveillance existe pour dire. La
fenêtre est fermée à droite par un délai de grâce de 2 h, le temps que l'ingestion livre le
réalisé de la dernière heure. `python -m app.monitoring.drift` l'exécute, le DAG `derive`
l'ordonnance, et `GET /api/v1/monitoring/drift` sert le dernier rapport de chaque site. Les
arbitrages sont dans l'[ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md).
### Détection d'alertes internes ### Détection d'alertes internes
`AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour `AlertService` n'est plus lecture seule : `AlertService.detect()` compare les `reading` (et, pour
+31 -23
View File
@@ -4,39 +4,47 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
## État actuel ## État actuel
Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, la
par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API. supervision des capteurs (admin) et le flux des alertes actives, tous branchés sur l'API réelle.
Ce qui est en place : Ce qui est en place :
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. - Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
`provideHttpClient(withInterceptors([mockApiInterceptor]))`. `provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`.
- Une route `/dashboard` en composant différé, et une redirection depuis la racine. - Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`,
- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et `/monitoring/sensors` réservée au rôle `admin`) et une redirection depuis la racine.
`AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification - `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses
(jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`, filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`,
`features/dashboard` la page principale, `shared/components` la jauge de consommation et le `SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur
graphique de charge par site, tous deux construits sur Chart.js. 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, - 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). [31-contrat-authentification.md](31-contrat-authentification.md).
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`, - 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 `ev-badge`, `ev-brand`, `ev-icon`, tokens CSS dans `styles/_tokens.scss`, classes globales de
réutiliser plutôt que redéfinir ses propres styles. Détail : 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). [32-design-systeme-frontend.md](32-design-systeme-frontend.md).
- L'état vit dans des signaux, sans bibliothèque dédiée. - 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. - Vitest via le builder `@angular/build:unit-test`, couverture activée.
- Prettier configuré, parser `angular` pour les gabarits HTML. - Prettier configuré, parser `angular` pour les gabarits HTML.
Ce qui n'existe pas encore : Ce qui n'existe pas encore :
- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` - **Le mode fixtures est inactif.** `useMockFixtures` vaut `false` dans `environment.ts` comme dans
sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien `environment.development.ts` : `mockApiInterceptor` ne sert `/stats/summary` et `/alerts` que
ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement. dans son propre spec. En développement, toutes les pages exigent un backend joignable et un jeton
`GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette valide.
PR (voir plus bas). - Un état de chargement généralisé : seul `app-alert-feed` en a un, les autres pages restent vides
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. tant que la première réponse n'est pas arrivée.
- Aucun lint : ESLint n'est pas installé. - Aucun lint : ESLint n'est pas installé.
## Arborescence ## Arborescence
@@ -87,11 +95,11 @@ sequenceDiagram
``` ```
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si `mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en `environment.useMockFixtures` est vrai. Le drapeau vaut `false` dans les deux fichiers
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. d'environnement : en pratique toutes les requêtes suivent le chemin réel et l'intercepteur n'est
`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il exercé que par son spec. `/predictions` et `/auth/*` ne sont de toute façon jamais mockés. En
suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide développement, un jeton valide et un backend joignable sont donc nécessaires pour que le tableau de
et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche. bord s'affiche.
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce 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 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-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) | | `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) |
| `--font-family` | Police unique de l'application | | `--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) | | `--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) | | `--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`) 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 ```html
<ev-brand /> <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 ## Logo
+12 -3
View File
@@ -48,7 +48,8 @@ Statut : `Fait`.
et refuse de s'appliquer si l'extension TimescaleDB manque. et refuse de s'appliquer si l'extension TimescaleDB manque.
- Les révisions suivantes créent les tables liées à l'authentification : - Les révisions suivantes créent les tables liées à l'authentification :
`app_user`, `login_attempt`, `audit_log` et `refresh_token`. `app_user`, `login_attempt`, `audit_log` et `refresh_token`.
- La révision `e6d2026091501` crée les six tables Data et déclare l'hypertable `reading`. - La révision `e6d2026091501` crée six des sept tables Data et déclare l'hypertable `reading`.
- La révision `d3f1a2b7c904` ajoute `drift_report`, la septième.
- La révision `c0adab96238c` ajoute les tables `password_reset_attempt` - La révision `c0adab96238c` ajoute les tables `password_reset_attempt`
et `password_reset_token`. et `password_reset_token`.
@@ -261,8 +262,8 @@ Cette modélisation prend en compte :
- leurs métadonnées JSON ; - leurs métadonnées JSON ;
- les données de l'API Mock. - les données de l'API Mock.
Elle comprend six tables Data, depuis le stockage des mesures jusqu'aux recommandations proposées Elle comprend sept tables Data, depuis le stockage des mesures jusqu'aux recommandations
à l'utilisateur. proposées à l'utilisateur, et jusqu'au suivi de la dérive du modèle.
### Schéma de données ### Schéma de données
@@ -286,11 +287,18 @@ Chaque table remplit un rôle précis dans le traitement et l'exploitation des d
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision | | `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision |
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision | | `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision | | `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision |
| `drift_report` | Suivre l'écart entre prévisions et réalisé, par site et tous sites confondus | Surveillance de dérive d'EnerVision |
Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`. Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`.
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles. Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
Les lignes de `drift_report` sont écrites par `app.monitoring.drift`, ordonnancé par le DAG
`derive`. Une ligne dont le `site_id` est `NULL` porte le résultat global, tous sites confondus :
c'est pourquoi l'unicité passe par un index sur `coalesce(site_id, '')` et non par une contrainte,
qui ne dédoublonnerait jamais deux lignes globales. Le calcul, ses seuils et ce qu'il refuse de
comparer sont dans l'[ADR 0011](../adr/0011-surveillance-de-derive-dans-le-backend.md).
Les lignes de `recommendation` sont écrites par le moteur de règles du backend Les lignes de `recommendation` sont écrites par le moteur de règles du backend
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`, (`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`,
par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en
@@ -304,6 +312,7 @@ n'ajoute aucune ligne.
- Les mesures API ne sont pas rattachées à un dataset historique. - Les mesures API ne sont pas rattachées à un dataset historique.
- Une alerte peut être associée à une prévision du même site. - Une alerte peut être associée à une prévision du même site.
- Une alerte peut donner lieu à plusieurs recommandations. - Une alerte peut donner lieu à plusieurs recommandations.
- Un site possède plusieurs rapports de dérive ; un rapport global n'est rattaché à aucun site.
## Ingestion des données historiques ## Ingestion des données historiques
+122 -20
View File
@@ -6,11 +6,21 @@ vérifié, ce qui bloque, et ce qui ne l'est pas.
| Étage | Sert à | Statut | | Étage | Sert à | Statut |
|---|---|---| |---|---|---|
| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` | | 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 Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en
publiée, aucun environnement GitHub. L'issue #21 le porte. C'est la limite principale de cet production sur la VM de l'école, par un runner auto-hébergé (issue #21,
étage, et elle est nommée ici plutôt que découverte en soutenance. [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.
Ce que ce workflow ne fait pas, et ne fera pas : préparer la machine. Installation de Docker,
clones, `.env`, certificats et enregistrement du runner sont provisionnés par
`infra/terraform/environments/vm-eni`
([ADR 0010](../adr/0010-terraform-provisionne-github-actions-deploie.md)). Terraform provisionne,
GitHub Actions déploie ; aucun des deux ne fait le travail de l'autre.
## Vue d'ensemble ## Vue d'ensemble
@@ -41,6 +51,10 @@ flowchart TB
ab["image<br/>construction de l'image"] ab["image<br/>construction de l'image"]
end end
subgraph infw["Infra · infra.yml"]
it["terraform<br/>fmt -check, init et validate par racine"]
end
subgraph sq["SonarQube · sonarqube.yml"] subgraph sq["SonarQube · sonarqube.yml"]
sb1["build-front / test-front"] sb1["build-front / test-front"]
sb2["build-back / test-back"] sb2["build-back / test-back"]
@@ -53,17 +67,23 @@ flowchart TB
push --> fd push --> fd
push --> mv & ms push --> mv & ms
push --> av & ab push --> av & ab
push --> it
push --> sb1 & sb2 --> sscan 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 ## Déclenchement
Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** : Les six workflows hébergés par GitHub se déclenchent sur `push` **et** sur `pull_request`,
`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`, filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur
`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun `apps/frontend/**`, `ml.yml` sur `ml/**`, `infra.yml` sur `infra/terraform/**`, `airflow.yml` sur
incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun incluant son propre
déclenche le pipeline. fichier de workflow dans le filtre pour qu'une modification du pipeline déclenche le pipeline.
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`, Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et `ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
@@ -81,9 +101,50 @@ rien changer), mais ce serait à borner sur un dépôt à forte fréquence de pu
`backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence `backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence
git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent. git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent.
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14, parce qu'Airflow 2.10 **Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14 : c'est l'interpréteur
ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image de l'image `apache/airflow:3.3.2-python3.12` retenue, et les tests d'intégrité doivent tourner sur
Docker et son propre environnement. le même. 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 septiè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 ## Ce qui bloque un merge
@@ -94,6 +155,8 @@ Docker et son propre environnement.
| Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque | | Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque |
| Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque | | Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque |
| Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque | | Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque |
| Tests d'intégration ML ↔ DB | ml | marqueur `integration`, base réelle migrée par Alembic | Bloque |
| Chaîne ML → DB → API | ml | marqueur `chaine`, vrais binaires en sous-processus | Bloque |
| Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque | | Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque |
| Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque | | Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque |
| **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque | | **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque |
@@ -101,6 +164,7 @@ Docker et son propre environnement.
| Build `npm run build` | frontend | compilation | Bloque | | Build `npm run build` | frontend | compilation | Bloque |
| Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque | | Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque |
| Construction de l'image Airflow | airflow | `docker build` de `etl/airflow/Dockerfile` | Bloque | | Construction de l'image Airflow | airflow | `docker build` de `etl/airflow/Dockerfile` | Bloque |
| Formatage et validité Terraform | infra | `fmt -check -recursive`, puis `init` et `validate` par racine | Bloque |
Deux seuils portent une décision qu'il faut savoir défendre : Deux seuils portent une décision qu'il faut savoir défendre :
@@ -180,18 +244,47 @@ les tests ne se merge pas.
## Secrets ## 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 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 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 run.
déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par
l'issue #22. 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.
### Pourquoi le job d'intégration ML installe aussi le backend
Le schéma de la base n'a qu'une source, les sept révisions Alembic de `apps/backend/alembic` : le
backend est propriétaire du schéma, `ml/` n'en est que consommateur. Reconstruire ce schéma à la
main dans le job ML donnerait un job vert sur une base qui n'est pas la nôtre, exactement l'erreur
qu'évite déjà le choix de l'image `timescaledb-ha` plutôt qu'un `postgres` nu. Le job installe
donc les deux environnements uv, applique `alembic upgrade head`, puis joue `-m integration` côté
`ml/` et `-m chaine` côté backend.
Conséquence sur le déclenchement : les `paths` de `ml.yml` incluent `apps/backend/alembic/**` et
`apps/backend/app/**`. Sans eux, une migration qui renomme une colonne de `reading` ne
déclencherait pas ce job, le SQL brut du pipeline dériverait du schéma, et **rien ne casserait
avant la production**. `app/**` en entier, et non les seuls modèles : ce job est le seul à jouer
`-m chaine`, or la chaîne traverse les endpoints, les services et les schémas jusqu'à
`GET /predictions`. Un filtre plus étroit laisserait le test muet sur la PR même qui le casse. Le
prix est qu'une PR backend lance aussi le lint et le typage de `ml/` : environ deux minutes de
runner, en parallèle. Même arbitrage que le filtre d'`airflow.yml`, qui écoute déjà `ml/**` et
`apps/backend/app/**` parce que son image réunit les deux.
Le marqueur `chaine` est distinct d'`integration` pour une raison mécanique : le job `integration`
de `backend.yml` n'installe pas `ml/.venv`, et sélectionnerait sinon un test qui lance les
binaires du pipeline. Il est aussi exclu d'`addopts`, sans quoi `make test` échouerait sur tout
poste où `ml/` n'est pas installé.
## Ce qui manque, et pourquoi ## Ce qui manque, et pourquoi
| Manque | Issue | Conséquence assumée | | 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 | | 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 end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
| Tests de charge | #47 | Aucun garde-fou de performance | | Tests de charge | #47 | Aucun garde-fou de performance |
@@ -200,8 +293,17 @@ l'issue #22.
## Reproduire la CI en local ## Reproduire la CI en local
`make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job `make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job
`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration `verification`. `make ml-check` fait la même chose pour le module ML.
demandent une base : `make db-up` puis `uv run pytest -m integration`.
Les tests d'intégration demandent une base **migrée**, et `db/init` ne crée `enervision_test` que
vide :
```bash
make db-up migrate-test # la base de test reçoit les sept révisions Alembic
make test-integration # backend, marqueur `integration`
make ml-test-integration # pipeline ML, marqueur `integration`
make test-chaine # vrais binaires ML puis relecture par l'API, marqueur `chaine`
```
Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium
--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis --confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis
+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. 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`, `alertes.py`, `historical_import.py` et `derive.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. Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
+5 -6
View File
@@ -1,9 +1,9 @@
# Image Airflow EnerVision : ajoute ml/ et apps/backend/ dans leurs propres environnements Python # Image Airflow EnerVision : ajoute ml/ et apps/backend/ dans leurs propres environnements Python
# 3.14, distincts du Python 3.12 qui fait tourner Airflow lui-meme (apache-airflow 2.10 ne supporte # 3.14, distincts du Python 3.12 de l'image de base qui fait tourner Airflow lui-meme, pour que
# pas 3.14), pour que les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`, # les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`,
# `app.detection.internal_alerts` et `app.cli` en sous-processus. Airflow ne devient jamais un # `app.detection.internal_alerts` et `app.cli` en sous-processus. Airflow ne devient jamais un
# consommateur direct de LightGBM, de MLflow ou du SQLAlchemy du backend. Cf. ADR 0008. # consommateur direct de LightGBM, de MLflow ou du SQLAlchemy du backend. Cf. ADR 0008.
FROM apache/airflow:2.10.4-python3.12 FROM apache/airflow:3.3.2-python3.12
# LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans # LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans
# toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file` # toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file`
@@ -21,9 +21,8 @@ RUN apt-get update \
RUN mkdir -p /opt/ml/state /opt/backend && chown -R airflow:root /opt/ml /opt/backend RUN mkdir -p /opt/ml/state /opt/backend && chown -R airflow:root /opt/ml /opt/backend
USER airflow USER airflow
# L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de # L'image de base embarque deja un `uv`, mais pas celui que le depot epingle par ailleurs
# `ml/uv.lock`. On le remplace par la version deja pinnee ailleurs dans le depot # (apps/backend/Dockerfile) : on aligne, pour que le format de verrou lu soit le meme partout.
# (apps/backend/Dockerfile).
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv
# Piege : pas de `UV_PROJECT_ENVIRONMENT` global. Il vaudrait pour les deux projets, et `uv run` # Piege : pas de `UV_PROJECT_ENVIRONMENT` global. Il vaudrait pour les deux projets, et `uv run`
+2 -2
View File
@@ -15,8 +15,8 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from airflow.models.dag import DAG from airflow.providers.standard.operators.bash import BashOperator
from airflow.operators.bash import BashOperator from airflow.sdk import DAG
# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et # Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et
# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement. # `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement.
+49
View File
@@ -0,0 +1,49 @@
"""DAG de surveillance de la dérive du modèle de prévision (issue #45).
Quotidien, pas horaire : la fenêtre mesurée couvre 168 h, la recalculer chaque heure écrirait
vingt-quatre lignes par jour pour un verdict qui ne bouge pas à cette cadence. Planifié après
les scorings de la nuit, et décalé de `ml_score` (à l'heure pile) comme de `alertes` (à la
quinzième minute).
Sans `--now` : le service tronque son instant de référence à l'heure, donc deux exécutions de
la même heure portent la même clé `uq_drift_report_window` et la seconde n'écrit rien.
Tâche distincte du DAG `alertes` plutôt qu'ajoutée à lui : un échec de dérive y ferait croire
que la détection d'alertes a échoué, et ce DAG porte un budget temporel déjà argumenté face à
son pas horaire.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG
# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et
# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement.
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
# Piège : aucune reprise. Une dérive n'est pas un échec transitoire, la rejouer la redéclarerait
# à l'identique ; et la cadence quotidienne pardonne une connexion perdue.
TENTATIVES = 0
PLAFOND = timedelta(minutes=10)
with DAG(
dag_id="derive",
description=(
"Compare les prévisions déjà écrites aux lectures réellement arrivées "
"(app.monitoring.drift)."
),
schedule="30 5 * * *",
start_date=datetime(2026, 1, 1),
catchup=False,
max_active_runs=1,
tags=["ml", "monitoring"],
) as dag:
BashOperator(
task_id="derive",
bash_command=f"{COMMANDE_BACKEND} app.monitoring.drift",
retries=TENTATIVES,
execution_timeout=PLAFOND,
)
+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.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG
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),
)
+2 -2
View File
@@ -10,8 +10,8 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from airflow.models.dag import DAG from airflow.providers.standard.operators.bash import BashOperator
from airflow.operators.bash import BashOperator from airflow.sdk import DAG
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt" MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
+2 -2
View File
@@ -11,8 +11,8 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from airflow.models.dag import DAG from airflow.providers.standard.operators.bash import BashOperator
from airflow.operators.bash import BashOperator from airflow.sdk import DAG
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt" MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
MLFLOW_TRACKING_URI = "sqlite:////opt/ml/state/mlflow.db" MLFLOW_TRACKING_URI = "sqlite:////opt/ml/state/mlflow.db"
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
description = "DAGs d'orchestration EnerVision (Airflow)" description = "DAGs d'orchestration EnerVision (Airflow)"
requires-python = ">=3.12,<3.13" requires-python = ">=3.12,<3.13"
dependencies = [ dependencies = [
"apache-airflow==2.10.4", "apache-airflow==3.3.2",
] ]
[dependency-groups] [dependency-groups]
+48 -8
View File
@@ -5,23 +5,25 @@ from datetime import timedelta
from pathlib import Path from pathlib import Path
import pytest import pytest
from airflow.models.baseoperator import BaseOperator from airflow.dag_processing.dagbag import DagBag
from airflow.models.dagbag import DagBag from airflow.sdk import BaseOperator
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags" DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
DAG_IDS = ["ml_train", "ml_score", "alertes"] DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import", "derive"]
TACHES = [ TACHES = [
("ml_train", "train"), ("ml_train", "train"),
("ml_score", "score"), ("ml_score", "score"),
("alertes", "detection"), ("alertes", "detection"),
("alertes", "recommandations"), ("alertes", "recommandations"),
("historical_import", "import_historical"),
("derive", "derive"),
] ]
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def dagbag() -> DagBag: def dagbag() -> DagBag:
return DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False) return DagBag(dag_folder=str(DAGS_FOLDER))
def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None: def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None:
@@ -33,18 +35,22 @@ def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None:
def test_ml_train_has_no_schedule(dagbag: DagBag) -> None: def test_ml_train_has_no_schedule(dagbag: DagBag) -> None:
assert dagbag.dags["ml_train"].timetable.summary == "None" assert dagbag.dags["ml_train"].schedule is None
def test_ml_score_runs_every_hour(dagbag: DagBag) -> None: def test_ml_score_runs_every_hour(dagbag: DagBag) -> None:
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend. # `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que la timetable le rend.
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *" assert dagbag.dags["ml_score"].timetable.expression == "0 * * * *"
def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None: def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None:
# Le decalage n'est pas cosmetique : la regle `anomaly` compare une lecture a la `prediction` # Le decalage n'est pas cosmetique : la regle `anomaly` compare une lecture a la `prediction`
# du meme instant, que `ml_score` ecrit a l'heure pile. # du meme instant, que `ml_score` ecrit a l'heure pile.
assert dagbag.dags["alertes"].timetable.summary == "15 * * * *" assert dagbag.dags["alertes"].timetable.expression == "15 * * * *"
def test_historical_import_has_no_schedule(dagbag: DagBag) -> None:
assert dagbag.dags["historical_import"].schedule is None
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None: def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
@@ -67,12 +73,29 @@ def test_alertes_recommendation_task_calls_the_backend_cli(dagbag: DagBag) -> No
assert "app.cli generate-recommendations" in tache.bash_command 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"]) @pytest.mark.parametrize("task_id", ["detection", "recommandations"])
def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None: 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). # 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 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: 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 # `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire
# tant que la detection n'a pas ecrit. # tant que la detection n'a pas ecrit.
@@ -133,6 +156,23 @@ def test_alertes_retries_after_a_transient_failure(dagbag: DagBag, task_id: str)
assert dagbag.dags["alertes"].get_task(task_id).retries >= 1 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
def test_derive_runs_once_a_day(dagbag: DagBag) -> None:
assert dagbag.dags["derive"].timetable.expression == "30 5 * * *"
def test_derive_calls_the_backend_drift_module(dagbag: DagBag) -> None:
assert "app.monitoring.drift" in dagbag.dags["derive"].get_task("derive").bash_command
def test_derive_never_retries_a_detected_drift(dagbag: DagBag) -> None:
# Une derive n'est pas une panne passagere : la rejouer la redeclarerait a l'identique.
assert dagbag.dags["derive"].get_task("derive").retries == 0
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES) @pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
def test_tasks_never_resync_the_baked_environment( def test_tasks_never_resync_the_baked_environment(
dagbag: DagBag, dag_id: str, task_id: str dagbag: DagBag, dag_id: str, task_id: str
+554 -653
View File
File diff suppressed because it is too large Load Diff
+36 -6
View File
@@ -1,18 +1,48 @@
# Infrastructure # Infrastructure
Provisionnement Terraform de la machine on-premise (serveur physique, accessible en SSH). Provisionnement Terraform des machines on-premise. Terraform prepare la machine, GitHub Actions
deploie l'application : voir l'[ADR 0010](../docs/adr/0010-terraform-provisionne-github-actions-deploie.md).
Rien ici ne construit d'image ni ne lance de conteneur.
- `terraform/modules` : modules reutilisables. - `terraform/modules` : modules reutilisables.
- `k3s` : installe un cluster k3s single-node sur une machine distante via SSH - `k3s` : installe un cluster k3s single-node sur une machine distante via SSH
(script officiel `get.k3s.io`) et rapatrie le kubeconfig en local. (script officiel `get.k3s.io`) et rapatrie le kubeconfig en local.
- `terraform/environments/<env>` : racines Terraform, une par environnement. - `terraform/environments/<racine>` : une racine par machine provisionnee.
- `dev` : instancie le module `k3s` sur le serveur de l'ecole. - `vm-eni` : la VM `eadl-2025-nantes-g3`, qui porte les environnements `rec` et `prod`
- `prod` : non initialise, voir le ticket dedie. ([ADR 0009](../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Installe Docker,
execute `scripts/provision-host.sh`, enregistre le runner GitHub Actions.
- `k3s-cible` : le cluster k3s, cible a terme de `docs/architecture/10-infra.md`. Jamais
applique.
## Usage (environments/dev) ## Usage (environments/vm-eni)
```bash ```bash
cd infra/terraform/environments/dev cd infra/terraform/environments/vm-eni
cp terraform.tfvars.example terraform.tfvars
terraform init
terraform apply
```
`terraform.tfvars` est ignore par git. Trois valeurs sont a renseigner avant l'apply :
- `proprietaire` : l'utilisateur qui possede `/srv/enervision` et fait tourner le runner. Il doit
deja exister sur la machine.
- `runner_version` : a epingler depuis <https://github.com/actions/runner/releases>.
- `runner_token` : jeton d'enregistrement, valable une heure et pour une seule inscription.
Parametres du depot, Actions, Runners, New self-hosted runner. Seul un administrateur du depot
peut le creer.
Apres l'apply, la machine porte `/srv/enervision/rec` et `/srv/enervision/prod`, chacun avec son
`.env` et son certificat. Le premier demarrage reste manuel, `make stack-up` dans chaque dossier ;
les suivants sont joues par le runner a chaque push sur `dev` et sur `main`.
Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le
desinscrit pas.
## Usage (environments/k3s-cible)
```bash
cd infra/terraform/environments/k3s-cible
cp terraform.tfvars.example terraform.tfvars # renseigner ssh_host / ssh_private_key_path cp terraform.tfvars.example terraform.tfvars # renseigner ssh_host / ssh_private_key_path
terraform init terraform init
terraform apply terraform apply
+8 -2
View File
@@ -9,12 +9,18 @@ Terminaison TLS et routage de la stack déployée. Seul composant publié sur le
- `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git. - `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git.
- `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`. - `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`.
Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la Pas de `Dockerfile` : l'image officielle `nginx:1.31-alpine` est utilisée telle quelle et la
configuration est montée en volume par `docker-compose.prod.yml`. 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 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. 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 ## Routage
| Chemin | Destination | Remarque | | Chemin | Destination | Remarque |
@@ -82,7 +88,7 @@ docker run --rm \
-v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \ -v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \ -v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \
-v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \ -v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \
nginx:1.28-alpine nginx -t nginx:1.31-alpine nginx -t
``` ```
Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image. Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image.
@@ -0,0 +1,23 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/null" {
version = "3.3.2"
constraints = "~> 3.2"
hashes = [
"h1:IQ1qrkht1sC1nibUR+AJ3ulryyhVDHfCHZhoJi0sg2Y=",
"zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08",
"zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55",
"zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27",
"zh:64a8f9ce7852d9efc5b464c12306c946366d59f5e2757def97969c9fd64bd1d6",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:92a374fb736a52f465283326d0a5bf4f495132eb99be209dfb4c75ec803fe8db",
"zh:98da9c42785d27a50f0604758bcb61a30f6278b9f2acd92bb3b2046e0e71916c",
"zh:b0f7896fae554729cdf4a24ac06359a050cff5817e6cd8597cba8a4ae01a7409",
"zh:bc8179ee35d67c72fb03012e7023b9f9816f033a7ec4109c001dd6d29752e812",
"zh:d23a598f713bfb6098bc003571d7de90b5a33b78f9be240488252fe5f3c2a60d",
"zh:d2855b922ea345dbd89ea287e4c6c4757e38bc0aaffeb2b79aa0b8004f9c53ff",
"zh:d3a60422bc6a2f9244d076c5222c07060c826ef91bdbaf4634cb752b86057473",
"zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba",
]
}
+23
View File
@@ -0,0 +1,23 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/null" {
version = "3.3.2"
constraints = "~> 3.2"
hashes = [
"h1:IQ1qrkht1sC1nibUR+AJ3ulryyhVDHfCHZhoJi0sg2Y=",
"zh:10ec43b8b7b18d5639238c7fb9e111f6a4b038523dd66c7a426bf27b25fa4c08",
"zh:60beb9cc2ad5b871c710860cee75b42850cc6acd43db0d77cb5e00fda7288b55",
"zh:62538582d0a4a2f10ad8a8d9a6c3cd3f05af6c6d91c6641ffc78d4f0e8e69b27",
"zh:64a8f9ce7852d9efc5b464c12306c946366d59f5e2757def97969c9fd64bd1d6",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:92a374fb736a52f465283326d0a5bf4f495132eb99be209dfb4c75ec803fe8db",
"zh:98da9c42785d27a50f0604758bcb61a30f6278b9f2acd92bb3b2046e0e71916c",
"zh:b0f7896fae554729cdf4a24ac06359a050cff5817e6cd8597cba8a4ae01a7409",
"zh:bc8179ee35d67c72fb03012e7023b9f9816f033a7ec4109c001dd6d29752e812",
"zh:d23a598f713bfb6098bc003571d7de90b5a33b78f9be240488252fe5f3c2a60d",
"zh:d2855b922ea345dbd89ea287e4c6c4757e38bc0aaffeb2b79aa0b8004f9c53ff",
"zh:d3a60422bc6a2f9244d076c5222c07060c826ef91bdbaf4634cb752b86057473",
"zh:faa01928c25d2a6ecd9c7eb8b88134cb08de55a6b11ca6c703ac0092845344ba",
]
}
+137
View File
@@ -0,0 +1,137 @@
# Pourquoi : Terraform provisionne la machine, GitHub Actions la deploie (ADR 0010). Rien ici ne
# construit d'image ni ne lance de conteneur : la livraison reste portee par `deploy.yml` et
# `make stack-up`, et un `apply` n'interrompt pas la stack qui tourne.
# Piege : seul le bloc `triggers` d'un `null_resource` atterrit dans le state. Ni le jeton du
# runner ni la cle SSH n'y figurent, et ne doivent jamais y etre ajoutes pour forcer un rejeu.
# Contrainte : pas de provisioner `destroy` sur le runner. Il imposerait une connexion ne lisant
# que `self`, donc le chemin de la cle SSH dans le state, et `svc.sh uninstall` ne desinscrit pas
# le runner cote GitHub : le retrait reste manuel, depuis les parametres du depot.
# Ref : ADR 0009 pour les deux environnements, `scripts/provision-host.sh` pour leur contenu.
locals {
sudo = var.ssh_user == "root" ? "" : "sudo "
en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --"
provisionneur = "${path.root}/../../../../scripts/provision-host.sh"
runner_archive = "actions-runner-linux-x64-${var.runner_version}.tar.gz"
# Substitution shell, evaluee par le sh -c distant : un nom de runner doit etre unique dans
# le depot, le nom d'hote l'est deja et le reste si cette racine sert a une autre machine.
runner_nom = var.runner_nom != "" ? var.runner_nom : "$(hostname -s)"
}
resource "null_resource" "docker_engine" {
triggers = {
hote = var.ssh_host
user = var.proprietaire
}
connection {
type = "ssh"
host = var.ssh_host
port = var.ssh_port
user = var.ssh_user
private_key = file(pathexpand(var.ssh_private_key_path))
timeout = "5m"
}
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
id ${var.proprietaire} >/dev/null 2>&1 || {
echo "l'utilisateur ${var.proprietaire} n'existe pas sur la machine" >&2
exit 1
}
command -v docker >/dev/null || ${local.sudo}sh -c 'curl -fsSL https://get.docker.com | sh'
${local.sudo}systemctl enable --now docker
${local.sudo}usermod -aG docker ${var.proprietaire}
${local.sudo}docker compose version
EOT
]
}
}
# `provision-host.sh` verifie lui-meme docker, compose et la sortie HTTPS, puis prepare un clone
# par environnement, son `.env` et son certificat. Il est rejouable : un `.env` existant n'est
# jamais reecrit, un certificat present jamais regenere.
resource "null_resource" "environnements" {
depends_on = [null_resource.docker_engine]
triggers = {
script = filesha256(local.provisionneur)
racine = var.racine
depot = var.depot_url
}
connection {
type = "ssh"
host = var.ssh_host
port = var.ssh_port
user = var.ssh_user
private_key = file(pathexpand(var.ssh_private_key_path))
timeout = "5m"
}
provisioner "file" {
source = local.provisionneur
destination = "/tmp/provision-host.sh"
}
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
${local.sudo}env RACINE='${var.racine}' \
REPO_URL='${var.depot_url}' \
PROPRIETAIRE='${var.proprietaire}' \
PUBLIC_IP='${var.adresse_publique}' \
bash /tmp/provision-host.sh
rm -f /tmp/provision-host.sh
EOT
]
}
}
# Piege : le jeton d'enregistrement expire en une heure. Un `apply` rejoue cette ressource des
# que `runner_version`, `runner_labels` ou `runner_nom` change, et redemande donc un jeton frais.
resource "null_resource" "runner_github" {
depends_on = [null_resource.environnements]
triggers = {
version = var.runner_version
labels = var.runner_labels
nom = local.runner_nom
dossier = var.runner_dossier
}
connection {
type = "ssh"
host = var.ssh_host
port = var.ssh_port
user = var.ssh_user
private_key = file(pathexpand(var.ssh_private_key_path))
timeout = "5m"
}
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
${local.sudo}install -d -o ${var.proprietaire} -g ${var.proprietaire} ${var.runner_dossier}
if [ ! -x ${var.runner_dossier}/config.sh ]; then
curl -fsSL -o /tmp/${local.runner_archive} \
https://github.com/actions/runner/releases/download/v${var.runner_version}/${local.runner_archive}
${local.sudo}tar -xzf /tmp/${local.runner_archive} -C ${var.runner_dossier}
${local.sudo}chown -R ${var.proprietaire}:${var.proprietaire} ${var.runner_dossier}
rm -f /tmp/${local.runner_archive}
fi
if [ ! -f ${var.runner_dossier}/.runner ]; then
${local.en_tant_que} sh -c 'cd ${var.runner_dossier} && ./config.sh --unattended --replace \
--url ${var.runner_url} --token ${var.runner_token} \
--labels ${var.runner_labels} --name ${local.runner_nom} --work _work'
${local.sudo}${var.runner_dossier}/svc.sh install ${var.proprietaire}
fi
${local.sudo}${var.runner_dossier}/svc.sh start
EOT
]
}
}
@@ -0,0 +1,9 @@
output "machine" {
description = "Machine provisionnee et racine qui porte un clone par environnement."
value = "${var.ssh_user}@${var.ssh_host}:${var.racine}"
}
output "runner" {
description = "Dossier d'installation du runner et libelles supplementaires annonces a GitHub."
value = "${var.runner_dossier} (${var.runner_labels})"
}
@@ -0,0 +1,22 @@
ssh_host = "10.101.200.37"
ssh_port = 22
ssh_user = "root"
ssh_private_key_path = "~/.ssh/id_ed25519"
# Utilisateur qui possede la racine et fait tourner le runner. Il doit deja exister sur la
# machine : git refuse les depots appartenant a un autre utilisateur, et un .env en 600 lui
# echapperait.
proprietaire = "enervision"
# Epingler une version reelle : https://github.com/actions/runner/releases
runner_version = "2.330.0"
# Jeton d'enregistrement du runner, valable une heure et pour une seule inscription :
# Parametres du depot > Actions > Runners > New self-hosted runner. Seul un administrateur du
# depot peut le creer. terraform.tfvars est ignore par git, mais le jeton ne doit pas y rester
# apres l'apply.
runner_token = "A_RENSEIGNER"
# Nom du runner cote GitHub. Vide par defaut : le nom d'hote de la machine. A renseigner
# seulement si deux runners doivent tourner sur la meme machine, leurs noms devant differer.
# runner_nom = "eni-g3-bis"
@@ -0,0 +1,90 @@
variable "ssh_host" {
type = string
description = "Adresse de la VM ENI qui porte les deux environnements (ADR 0009)."
}
variable "ssh_port" {
type = number
description = "Port SSH de la VM."
default = 22
}
variable "ssh_user" {
type = string
description = "Utilisateur SSH du provisionnement. Different de root, les commandes privilegiees sont prefixees par sudo."
default = "root"
}
variable "ssh_private_key_path" {
type = string
description = "Chemin local vers la cle privee SSH. L'authentification par mot de passe n'est volontairement pas prise en charge : une variable de mot de passe finit en clair dans le state ou dans les triggers."
sensitive = true
}
variable "proprietaire" {
type = string
description = "Utilisateur qui possede la racine et fait tourner le runner. Il doit exister sur la machine : git refuse les depots appartenant a un autre utilisateur, et un .env en 600 lui echapperait."
validation {
condition = can(regex("^[a-z_][a-z0-9_-]*$", var.proprietaire))
error_message = "proprietaire doit etre un nom d'utilisateur Unix valide."
}
}
variable "racine" {
type = string
description = "Dossier qui porte un clone du depot par environnement."
default = "/srv/enervision"
}
variable "depot_url" {
type = string
description = "URL de clonage du depot, passee a provision-host.sh."
default = "https://github.com/ineszang/ProjetPiscine_EnerVision.git"
}
variable "adresse_publique" {
type = string
description = "Adresse annoncee dans les certificats auto-signes. Vide : la premiere adresse de la VM."
default = ""
}
variable "runner_url" {
type = string
description = "Depot GitHub auquel le runner s'enregistre."
default = "https://github.com/ineszang/ProjetPiscine_EnerVision"
}
variable "runner_version" {
type = string
description = "Version d'actions-runner a installer, sans le v initial (ex: 2.330.0). Voir https://github.com/actions/runner/releases."
validation {
condition = can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.runner_version))
error_message = "runner_version doit etre epinglee explicitement (ex: 2.330.0), sinon l'installation cesse d'etre reproductible."
}
}
variable "runner_token" {
type = string
description = "Jeton d'enregistrement du runner. Expire au bout d'une heure et ne vaut que pour une inscription : Parametres du depot > Actions > Runners > New self-hosted runner. Seul un administrateur du depot peut le creer."
sensitive = true
}
variable "runner_labels" {
type = string
description = "Libelles supplementaires du runner. deploy.yml cible [self-hosted, linux, eni-g3], les deux premiers etant poses par GitHub."
default = "eni-g3"
}
variable "runner_nom" {
type = string
description = "Nom du runner cote GitHub, unique dans le depot. Vide : le nom d'hote de la machine, qui reste unique si cette racine est reprise pour une seconde VM. A renseigner pour faire tourner deux runners sur la meme machine."
default = ""
}
variable "runner_dossier" {
type = string
description = "Dossier d'installation du runner sur la machine."
default = "/opt/actions-runner"
}
@@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.7"
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
backend "local" {
path = "terraform.tfstate"
}
}
+12 -5
View File
@@ -5,19 +5,26 @@ locals {
kubeconfig_cmd = "${local.sudo_prefix}cat /etc/rancher/k3s/k3s.yaml" kubeconfig_cmd = "${local.sudo_prefix}cat /etc/rancher/k3s/k3s.yaml"
} }
# Piege : un provisioner `destroy` impose que tout le bloc `connection` ne lise que `self`, sinon
# `terraform init` refuse le module. D'ou la connexion batie sur `triggers`, ou ne figurent que
# l'adresse, le port, l'utilisateur et le chemin de la cle : jamais la cle ni un mot de passe.
resource "null_resource" "k3s_install" { resource "null_resource" "k3s_install" {
triggers = { triggers = {
ssh_host = var.ssh_host ssh_host = var.ssh_host
ssh_port = tostring(var.ssh_port)
ssh_user = var.ssh_user
ssh_key_path = var.ssh_private_key_path
sudo_prefix = local.sudo_prefix
k3s_version = var.k3s_version k3s_version = var.k3s_version
disable_components = join(",", var.k3s_disable_components) disable_components = join(",", var.k3s_disable_components)
} }
connection { connection {
type = "ssh" type = "ssh"
host = var.ssh_host host = self.triggers.ssh_host
port = var.ssh_port port = tonumber(self.triggers.ssh_port)
user = var.ssh_user user = self.triggers.ssh_user
private_key = file(var.ssh_private_key_path) private_key = file(pathexpand(self.triggers.ssh_key_path))
} }
provisioner "remote-exec" { provisioner "remote-exec" {
@@ -33,7 +40,7 @@ resource "null_resource" "k3s_install" {
when = destroy when = destroy
on_failure = continue on_failure = continue
inline = [ inline = [
"${local.sudo_prefix}sh -c 'test -x /usr/local/bin/k3s-uninstall.sh && /usr/local/bin/k3s-uninstall.sh || true'", "${self.triggers.sudo_prefix}sh -c 'test -x /usr/local/bin/k3s-uninstall.sh && /usr/local/bin/k3s-uninstall.sh || true'",
] ]
} }
} }

Some files were not shown because too many files have changed in this diff Show More