Compare commits

..
Author SHA1 Message Date
Valentin 44163bfb98 fix(ml): corrige le build MLflow (psycopg2), le garde-fou Makefile, la doc et la fuite de mot de passe
ML / Analyse statique de sécurité (push) Successful in 6s
SonarQube / test-ml (push) Failing after 6m4s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / build-back (push) Successful in 9m50s
ML / Lint, typage et tests (push) Successful in 11m41s
SonarQube / test-front (push) Failing after 5m1s
SonarQube / test-back (push) Failing after 5m11s
SonarQube / SonarQube (push) Skipped
2026-09-22 15:04:21 +02:00
Valentin 8760ebc701 fix(ml): applique les corrections de la review MLflow (securite, documentation, robustesse) 2026-09-22 10:41:02 +02:00
Valentin a013dfa87f Merge remote-tracking branch 'origin/dev' into feat/registry-modele-prediction 2026-09-22 10:11:24 +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
Valentin 9cd4f0de1c Merge branch 'feat/entrainement-du-modele' of https://github.com/ineszang/ProjetPiscine_EnerVision into feat/registry-modele-prediction 2026-09-21 15:41:14 +02:00
Valentin 5cc99178c2 fix(ml): execute MLflow en non-root et installe uniquement des wheels 2026-09-21 15:41:02 +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
ValentinDeFariaandGitHub b41a16364b Merge branch 'dev' into feat/entrainement-du-modele 2026-09-21 15:14:57 +02:00
Valentin 33aeea835b fix(ml): retire le mot de passe PostgreSQL du compose 2026-09-21 15:06:05 +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
Valentin 9312d3b60f feat(ml): enregistrer le modèle dans le MLflow Model Registry 2026-09-21 14:52:00 +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
Johan LEROY 7e504bee72 docs(frontend): documente la vue recommandations
Route, paramètres ?site= et ?alert=, jointure côté client et sa raison (pas de
site_id sur une recommandation, aucun filtre sur GET /recommendations,
/alerts non paginé), génération réservée aux admins, points d'entrée.
2026-09-21 14:41:56 +02:00
Johan LEROY 32155cbfc5 feat(frontend): section recommandations sur la vue détail d'un site
La vue détail pose app-recommendation-list restreint au site consulté (les
alertes sont demandées avec site_id) et renvoie vers la vue complète préfiltrée
sur ce site. Le spec fournit les deux services du composant enfant et vérifie le
filtre transmis.
2026-09-21 14:41:56 +02:00
Meryemel-gham cb4df846cb docs(etl): documente l'orchestration de l'import historique 2026-09-21 14:41:38 +02:00
Johan LEROY d16de84559 feat(frontend): vue /recommendations avec filtre site, focus ?alert= et génération admin
Page derrière authGuard, ouverte à tous les rôles : filtre site (.form-select,
présélectionné par ?site=), focus sur une alerte par ?alert= (entier strictement
positif, sinon ignoré), et pour les admins un bouton « Générer les
recommandations » qui appelle POST /recommendations/generate sur le site filtré,
affiche le bilan accordé en nombre et recharge la liste. Route ajoutée dans
app.routes.ts, lien « Recommandations » dans la navigation du tableau de bord.
2026-09-21 14:41:33 +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 90639b5618 feat(frontend): composant app-recommendation-list, jointure alertes/recommandations
Une recommandation ne porte que alert_id et GET /recommendations n'a aucun
filtre : le composant charge en parallèle les alertes (filtrées par site quand
`siteId` est fourni) et toutes les recommandations, puis les joint côté client
(joinByAlert, fonction pure testée à part) en groupes par alerte, du plus récent
au plus ancien. Chaque groupe montre le contexte de l'alerte (sévérité, type,
site, horodatage, message) puis ses actions avec l'explication et la règle.
L'input `alertId` réduit la vue à une alerte et la met en évidence ; `reload()`
rejoue les deux appels. Un échec de l'un des deux vide tout : une demi-jointure
tromperait.
2026-09-21 14:39:56 +02:00
Johan LEROY e147ea69bc feat(frontend): modèle et service Recommendations (liste, détail, génération)
Types alignés sur RecommendationResponse et RecommendationGenerationResponse
du backend. RecommendationsService couvre GET /recommendations,
GET /recommendations/{id} et POST /recommendations/generate?site_id= (réservé
admin côté API). recommendation-presentation.ts traduit les sept règles connues
du moteur et retombe sur la référence brute pour une règle inconnue, la
politique de renommage en -v2 de l'ADR 0006 l'impose.
2026-09-21 14:39:56 +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
Valentin 268496a8c4 feat(ml): enregistrer le modèle dans le MLflow Model Registry 2026-09-21 12:04:49 +02:00
64 changed files with 2835 additions and 1050 deletions
+24 -3
View File
@@ -29,15 +29,18 @@ APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me
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.
AIRFLOW_PORT=8080
# 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())"
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))"
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
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
# 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=enervision.local
ACME_EMAIL=
# Deux environnements sur la même machine (ADR 0009) : un dossier, un `.env` et un projet Compose
# chacun. Le nom de projet préfixe volumes, réseau et conteneurs et l'emporte sur `name:`.
# Vide sur un poste de développement : le projet reste `enervision`.
COMPOSE_PROJECT_NAME=
# Origine publique, avec le port si le proxy HTTPS n'écoute pas 443. Vide : https://PUBLIC_HOST.
# Recette : PUBLIC_HOST=rec.enervision.local et PUBLIC_ORIGIN=https://rec.enervision.local:8443.
PUBLIC_ORIGIN=
# Ports publiés par le proxy. Vides : 80 et 443. Recette : PROXY_HTTPS_PORT=8443 et
# PROXY_HTTP_PORT=127.0.0.1:8081, la redirection vers 443 n'ayant pas à être joignable de
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
PROXY_HTTP_PORT=
PROXY_HTTPS_PORT=
# Réglages mémoire de la stack déployée. Sans eux, timescaledb-tune réserve 25 % de la RAM de la
# machine à chaque base au premier démarrage, et le webserver Airflow lance 4 workers gunicorn.
TS_TUNE_MEMORY=2GB
TS_TUNE_NUM_CPUS=2
AIRFLOW_WEBSERVER_WORKERS=2
+9
View File
@@ -10,6 +10,15 @@ updates:
frontend-dependencies:
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)
- package-ecosystem: "uv"
+11 -9
View File
@@ -1,8 +1,9 @@
name: Airflow
# 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
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
# (contrairement à backend.yml et ml.yml) : celui de l'image apache/airflow retenue, et les tests
# 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
# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans
@@ -47,10 +48,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: etl/airflow/uv.lock
@@ -78,7 +79,7 @@ jobs:
steps:
- 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/)
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"
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
# l'import du module prouve que l'environnement /opt/backend est complet. Les deux
# commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui.
- name: Vérifie que les deux commandes du DAG alertes s'importent sans réseau
# l'import des modules prouve que l'environnement /opt/backend est complet.
# Les deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes.
- name: Vérifie que les trois commandes backend s'importent sans réseau
run: >
docker run --rm --network none enervision-airflow:ci
bash -c "cd /opt/backend
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help"
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help"
+8 -8
View File
@@ -30,10 +30,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -90,10 +90,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -126,10 +126,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -150,12 +150,12 @@ jobs:
steps:
- 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
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- 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
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
+57
View File
@@ -0,0 +1,57 @@
# Pourquoi : le runner tourne sur la VM ENI, adresse privée que les runners hébergés par GitHub
# ne joignent pas, et travaille dans un dossier stable par environnement plutôt que dans son
# espace de travail : `.env`, certificats et volumes y survivent d'un déploiement à l'autre.
# Piège : jamais de déclencheur `pull_request` ici. Sur un dépôt public, une PR de fork
# exécuterait son code sur la machine de production (ADR 0009) - job deploy.
name: Déploiement
on:
push:
branches: [dev, main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: false
jobs:
deploy:
runs-on: [self-hosted, linux, eni-g3]
timeout-minutes: 30
environment:
name: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
url: ${{ github.ref_name == 'main' && 'https://enervision.local' || 'https://rec.enervision.local:8443' }}
env:
ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
PORT_HTTPS: ${{ github.ref_name == 'main' && '443' || '8443' }}
steps:
- name: Aligner le dossier de l'environnement sur la branche poussée
run: |
cd "/srv/enervision/${ENVIRONNEMENT}"
git fetch --quiet origin "${GITHUB_REF_NAME}"
git checkout --quiet "${GITHUB_REF_NAME}"
git reset --quiet --hard "origin/${GITHUB_REF_NAME}"
git log -1 --format='%h %s'
- name: Reconstruire et redémarrer la stack
run: |
cd "/srv/enervision/${ENVIRONNEMENT}"
make stack-up
- name: Attendre que l'API réponde derrière le proxy
run: |
for tentative in $(seq 1 36); do
if curl --fail --silent --insecure "https://localhost:${PORT_HTTPS}/api/v1/health/ready"; then
exit 0
fi
sleep 5
done
echo "L'API ne répond pas après 3 minutes" >&2
cd "/srv/enervision/${ENVIRONNEMENT}"
docker compose ps
docker compose logs --tail=50 backend proxy
exit 1
+10 -10
View File
@@ -17,10 +17,10 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
node-version: 26
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
@@ -33,10 +33,10 @@ jobs:
name: Audit des dépendances
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
node-version: 26
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend
@@ -45,10 +45,10 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
node-version: 26
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front)
@@ -58,7 +58,7 @@ jobs:
run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
+4 -4
View File
@@ -30,10 +30,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
@@ -67,12 +67,12 @@ jobs:
steps:
- 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
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
- name: Analyse le code livré (bloquant à partir de 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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
node-version: 26
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
@@ -40,10 +40,10 @@ jobs:
needs: build-front
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
node-version: 26
cache: npm
cache-dependency-path: apps/frontend/package-lock.json
@@ -56,7 +56,7 @@ jobs:
working-directory: apps/frontend
- name: Upload coverage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info
@@ -64,9 +64,9 @@ jobs:
build-back:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -95,9 +95,9 @@ jobs:
needs: build-back
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
@@ -107,7 +107,7 @@ jobs:
working-directory: apps/backend
- name: Upload coverage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: backend-coverage
path: apps/backend/coverage.xml
@@ -115,9 +115,9 @@ jobs:
test-ml:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Installe uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
@@ -135,7 +135,7 @@ jobs:
working-directory: ml
- name: Upload coverage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ml-coverage
path: ml/coverage.xml
@@ -143,23 +143,26 @@ jobs:
sonarqube:
needs: [build-front, build-back, test-front, test-back, test-ml]
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
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: backend-coverage
path: apps/backend
- name: Téléchargement du rapport de couverture (ML)
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: ml-coverage
path: ml
+2 -1
View File
@@ -64,7 +64,8 @@ ml/models/*
!ml/models/.gitkeep
ml/mlruns/
ml/mlartifacts/
ml/mlflow.db
ml/mlflow.db*
ml/.env
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
etl/airflow/tests/.airflow_home/
+75 -10
View File
@@ -13,12 +13,31 @@ ifdef ACME_EMAIL
export ACME_EMAIL
endif
# Piege : make ne lit pas `.env`, que seul docker compose interpole. Les cibles hors conteneur
# (ml-*, demo-data, db-wait) joignent la base par le port publie et ont besoin de ces valeurs.
env-val = $(shell sed -n 's/^$(1)=//p' .env 2>/dev/null | tail -1)
PG_USER := $(or $(strip $(call env-val,POSTGRES_USER)),enervision)
PG_PASSWORD := $(or $(strip $(call env-val,POSTGRES_PASSWORD)),change_me)
PG_DB := $(or $(strip $(call env-val,POSTGRES_DB)),enervision)
PG_PORT := $(or $(strip $(call env-val,POSTGRES_PORT)),5433)
ml-env-val = $(shell sed -n 's/^$(1)=//p' ml/.env 2>/dev/null | tail -1)
ML_ENV_DB_PASSWORD := $(call ml-env-val,MLFLOW_DB_PASSWORD)
AIRFLOW_PORT := $(or $(strip $(call env-val,AIRFLOW_PORT)),8080)
MAILPIT_UI_PORT := $(or $(strip $(call env-val,MAILPIT_UI_PORT)),8025)
ML_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_DB)
export ML_DATABASE_URL
# Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne
# verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score.
DEMO_NOW ?= 2024-12-31T00:00:00Z
.DEFAULT_GOAL := help
.PHONY: help install install-backend install-frontend install-ml install-airflow \
dev dev-backend dev-frontend \
lint format typecheck test test-cov test-integration check \
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \
openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \
migrate bootstrap-admin services-up demo-data demo-data-force \
ml-lint ml-typecheck ml-test ml-check ml-train ml-score mlflow-up detect-alerts recommendations \
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
@@ -39,12 +58,19 @@ install-ml: ## Installe les dépendances du pipeline ML
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
cd $(AIRFLOW) && uv sync --all-groups
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airflow, puis backend et frontend
@echo "airflow -> http://localhost:$(AIRFLOW_PORT) mailpit -> http://localhost:$(MAILPIT_UI_PORT)"
@trap 'kill 0' EXIT INT TERM; \
$(MAKE) --no-print-directory dev-backend & \
$(MAKE) --no-print-directory dev-frontend & \
wait
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow)
docker compose up -d db mailpit
@$(MAKE) --no-print-directory db-wait
@$(MAKE) --no-print-directory db-ensure-airflow
docker compose up -d airflow-init airflow-webserver airflow-scheduler
dev-backend: ## Lance l'API seule en rechargement à chaud
@echo "backend -> http://localhost:8000 (docs sur /docs)"
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
@@ -91,8 +117,16 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV= et NOW= optionnels
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) $(if $(NOW),--now $(NOW),)
mlflow-up: ## Démarre le serveur MLflow (tracking + registry) en conteneur. ml/.env requis
@test -n "$(strip $(ML_ENV_DB_PASSWORD))" \
|| { echo "MLFLOW_DB_PASSWORD absente de ml/.env (copier ml/.env.example)"; exit 1; }
@echo "$(ML_ENV_DB_PASSWORD)" | grep -qE '^[A-Za-z0-9]+$$' \
|| { echo "MLFLOW_DB_PASSWORD doit contenir uniquement lettres et chiffres (interpolee dans l'URI postgresql://)"; exit 1; }
cd $(ML) && docker compose -f docker-compose.mlflow.yml up -d --build
@echo "mlflow -> http://localhost:5000"
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
@@ -108,12 +142,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-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
docker compose up -d airflow-init airflow-webserver airflow-scheduler
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-apiserver airflow-scheduler airflow-dag-processor
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
airflow-down: ## Arrête le webserver et le scheduler Airflow
docker compose stop airflow-webserver airflow-scheduler
airflow-down: ## Arrête l'api-server, le scheduler et le dag-processor Airflow
docker compose stop airflow-apiserver airflow-scheduler airflow-dag-processor
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
docker compose logs -f airflow-scheduler
@@ -124,12 +158,15 @@ docker-build: ## Construit l'image du backend
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin
# Piège : l'image backend ne migre pas au démarrage, et `/health/ready` ne teste que la connexion
# et l'extension. Sans `alembic upgrade head`, la stack démarre verte sur une base sans schéma.
stack-up: ## Démarre la stack derrière le reverse proxy, puis migre la base. PUBLIC_HOST=... au besoin
@test -f infra/proxy/tls/fullchain.pem \
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
$(COMPOSE_PROD) up -d --build
$(COMPOSE_PROD) exec -T backend alembic upgrade head
stack-down: ## Arrête la stack complète en conservant les données
$(COMPOSE_PROD) stop
@@ -165,8 +202,36 @@ db-logs: ## Suit les journaux de la base
db-psql: ## Ouvre une session psql sur la base applicative
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision}
db-wait: ## Attend que la base accepte les connexions
@for _ in $$(seq 1 60); do \
docker compose exec -T db pg_isready -U $(PG_USER) -d $(PG_DB) >/dev/null 2>&1 && exit 0; \
sleep 1; \
done; \
echo "La base n'accepte toujours pas de connexion apres 60s"; exit 1
# Piege : db/init ne rejoue qu'a la premiere initialisation du volume. Un `pgdata` cree avant
# db/init/120-airflow-database.sql n'a pas de base `airflow`, et airflow-init boucle dessus.
db-ensure-airflow: ## Crée la base de métadonnées Airflow si le volume pgdata est antérieur à db/init/120
@docker compose exec -T db psql -U $(PG_USER) -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname = 'airflow'" | grep -q 1 \
|| docker compose exec -T db psql -U $(PG_USER) -d postgres -c "CREATE DATABASE airflow"
migrate: ## Applique les migrations Alembic
cd $(BACKEND) && uv run alembic upgrade head
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier
cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis}
demo-data: ## Renseigne prédictions, alertes et recommandations si elles manquent. NOW= optionnel
@nombre=$$(docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -tAc 'SELECT count(*) FROM alert') \
|| { echo "demo-data : base injoignable ou migrations non appliquees"; exit 1; }; \
if [ "$$nombre" = 0 ]; then \
$(MAKE) --no-print-directory demo-data-force; \
else \
echo "demo-data : $$nombre alerte(s) deja en base (make demo-data-force pour rejouer)"; \
fi
demo-data-force: ## Rejoue le peuplement sans regarder l'existant. Les trois etapes sont idempotentes
$(MAKE) --no-print-directory ml-score NOW=$(DEMO_NOW)
$(MAKE) --no-print-directory detect-alerts NOW=$(DEMO_NOW)
$(MAKE) --no-print-directory recommendations
+54 -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 |
| J2 | Valider le périmètre retenu et les choix technologiques |
| J3 | Valider l'architecture et la gestion de la sécurité |
| J4 | Valider la robustesse et assurer les livrables |
| J3 | Ingestion & backend |
| 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).
@@ -18,15 +20,15 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Domaine | Technologie | Emplacement | Etat |
|------------|-------------------------------------|---------------------|---------------|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
| ETL | Apache Airflow | `etl/airflow` | Trois DAGs |
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
| Frontend | Angular 22, Node 26 | `apps/frontend` | En place |
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
| CI/CD | GitHub Actions | `.github/workflows` | En place |
| 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
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
│ └── seeds/ Jeux de donnees de reference
├── etl/airflow/
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes)
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique)
│ ├── plugins/ Operateurs et hooks maison
│ ├── include/ Requetes SQL et ressources des DAGs
│ └── tests/ Tests d'integrite des DAGs
@@ -75,25 +77,52 @@ Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Pyth
cp .env.example .env # variables de docker-compose
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
make install # dependances du backend et du frontend
make migrate # applique les migrations Alembic
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
make install # dependances du backend, du frontend, du ML et des DAGs
make dev # toute la stack, voir ci-dessous
make check # lint + typage + tests
```
`make dev` enchaine tout : demarrage des services conteneurises (base sur le port 5433, Mailpit,
Airflow), migrations Alembic, peuplement de demonstration si les alertes manquent, puis backend
et frontend en rechargement a chaud sur le poste.
| Service | Adresse |
|---|---|
| Backend | <http://localhost:8000> (documentation sur `/docs`) |
| Frontend | <http://localhost:4200> |
| Airflow | <http://localhost:8080> (`AIRFLOW_ADMIN_USERNAME` / `AIRFLOW_ADMIN_PASSWORD` du `.env`) |
| Mailpit | <http://localhost:8025> |
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
`AIRFLOW_WEBSERVER_SECRET_KEY`, `AIRFLOW_APP_SECRET_KEY` et `AIRFLOW_ADMIN_PASSWORD`. Sans elles
`airflow-init` refuse de demarrer, et `airflow-webserver` comme `airflow-scheduler` avec lui.
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
`make airflow-up`, `make dev-backend`, `make dev-frontend`.
`make help` liste les cibles disponibles.
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`,
`apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que
5432, souvent deja pris par une autre base.
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend`
et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend`
du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc
toute modification y demande un `docker compose up -d --build backend`.
Le backend et le frontend tournent sur le poste, lances ensemble par `make dev` (logs
entrelaces dans le meme terminal, Ctrl+C arrete les deux) ; la base, Mailpit et Airflow tournent
en conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
et n'embarque pas le source, donc toute modification y demande un
`docker compose up -d --build backend`.
### Donnees de demonstration
Le jeu historique s'arrete au 31/12/2024. `make demo-data` renseigne les tables que les vues
alertes, recommandations et previsions lisent, en ancrant le scoring et la detection a cette
date (`DEMO_NOW`) plutot qu'a l'horloge reelle, qui ne verrait qu'un parc muet depuis des mois.
La cible ne fait rien si des alertes existent deja ; `make demo-data-force` rejoue les trois
etapes, toutes idempotentes en base.
Un volume `pgdata` cree avant `db/init/120-airflow-database.sql` n'a pas de base `airflow` :
`db/init` ne rejoue qu'a la premiere initialisation. `make db-ensure-airflow`, appelee par
`make dev` et `make airflow-up`, la cree au besoin, sans detruire les donnees applicatives.
Verifier que la base repond et que l'extension est chargee :
@@ -116,6 +145,12 @@ nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvel
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
Sur la VM ENI, deux environnements cohabitent, recette sur `dev` et production sur `main`,
chacun dans son dossier et son projet Compose : `scripts/provision-host.sh` les prépare, le
workflow `deploy.yml` les redéploie à chaque push par un runner auto-hébergé. Ports, noms
d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et
[l'ADR 0009](docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
## Conventions
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
+17 -8
View File
@@ -6,6 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert
# Douze colonnes par alerte, contre quatre pour une recommandation : le plafond asyncpg de
# 32 767 parametres tombe a 2 730 lignes, d'ou un lot plus petit que `recommendation.py`.
TAILLE_DE_LOT = 1000
class AlertRepository:
def __init__(self, session: AsyncSession) -> None:
@@ -44,12 +48,17 @@ class AlertRepository:
}
for alerte in alerts
]
requete = (
insert(Alert)
.values(valeurs)
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
.returning(Alert)
)
resultat = await self._session.execute(requete)
creees: list[Alert] = []
# Piège : asyncpg plafonne une requête à 32 767 paramètres. Une détection sur une fenêtre
# chargée dépasse ce seuil, et l'`INSERT` d'un seul tenant échouerait.
for debut in range(0, len(valeurs), TAILLE_DE_LOT):
requete = (
insert(Alert)
.values(valeurs[debut : debut + TAILLE_DE_LOT])
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
.returning(Alert)
)
resultat = await self._session.execute(requete)
creees.extend(resultat.scalars().all())
await self._session.flush()
return resultat.scalars().all()
return creees
@@ -5,6 +5,7 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Alert
from app.repositories import alert as module_alert
from app.repositories.alert import AlertRepository
from app.schemas.alert import AlertSeverity
from tests.repositories.test_site import creer as creer_site
@@ -146,3 +147,20 @@ async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession)
creees = await depot.create_many([])
assert creees == []
async def test_create_many_inserts_every_alert_across_several_batches(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(module_alert, "TAILLE_DE_LOT", 2)
site = await creer_site(session)
depot = AlertRepository(session)
a_inserer = [
_alerte_a_inserer(site_id=site.site_id, source_alert_id=f"threshold:lot-{index}")
for index in range(5)
]
creees = await depot.create_many(a_inserer)
await session.rollback()
assert len(creees) == 5
+37 -37
View File
@@ -655,31 +655,31 @@ wheels = [
[[package]]
name = "pandas"
version = "3.0.5"
version = "3.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ 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 = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
@@ -936,27 +936,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.16.7"
version = "0.16.8"
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 = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
+2 -2
View File
@@ -3,7 +3,7 @@
# ==================
# Image pour frontend
FROM node:24-alpine3.22 AS builder
FROM node:26-alpine3.22 AS builder
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
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
+95 -174
View File
@@ -8,12 +8,12 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@angular/common": "^22.1.0",
"@angular/compiler": "^22.1.0",
"@angular/core": "^22.1.0",
"@angular/forms": "^22.1.0",
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"@angular/common": "^22.1.7",
"@angular/compiler": "^22.1.7",
"@angular/core": "^22.1.7",
"@angular/forms": "^22.1.7",
"@angular/platform-browser": "^22.1.7",
"@angular/router": "^22.1.7",
"chart.js": "^4.5.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
@@ -21,21 +21,14 @@
"devDependencies": {
"@angular/build": "^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",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"jsdom": "^30.1.0",
"prettier": "^3.9.8",
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -255,9 +248,9 @@
}
},
"node_modules/@angular/common": {
"version": "22.1.6",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.6.tgz",
"integrity": "sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==",
"version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.7.tgz",
"integrity": "sha512-SLW4AdH2Y8G9SM/iFRNpCQtkmvq55RcIQWsADT9ypZ7FxxcnJRsr4Q/WnbUBbuCslsCkrgzU1i9WDiW0m7gcnw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -266,14 +259,14 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"@angular/core": "22.1.6",
"@angular/core": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/compiler": {
"version": "22.1.6",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.6.tgz",
"integrity": "sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==",
"version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.7.tgz",
"integrity": "sha512-qhoHAByh805nT4Xy4Wd2kE5FvkTc7wcg8Qps+EH6+z4KRfwGiL5cwdgooGWjTAldFCeqTgJ0BO/0dB6GHZjdkQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -283,9 +276,9 @@
}
},
"node_modules/@angular/compiler-cli": {
"version": "22.1.6",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.6.tgz",
"integrity": "sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==",
"version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.7.tgz",
"integrity": "sha512-VGg8Kyt0YymQfnUUFk79vFaM+x5xmIMHy+BZh3P2Lh/uxqCXTWhJJH8kOHKtIGGH5/wKhBEmSy2CezdcIuM9xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -306,7 +299,7 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"@angular/compiler": "22.1.6",
"@angular/compiler": "22.1.7",
"typescript": ">=6.0 <6.1"
},
"peerDependenciesMeta": {
@@ -316,9 +309,9 @@
}
},
"node_modules/@angular/core": {
"version": "22.1.6",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz",
"integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==",
"version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.7.tgz",
"integrity": "sha512-T9bt1PIgoN7n+TxGHTUgu0EsomlGMQ84YMXbMB+N6HhD8C1qZxn0uCbnAtCUjcecg1am8ioKxNTO0eII9ouioQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -327,7 +320,7 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"@angular/compiler": "22.1.6",
"@angular/compiler": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0 || ~0.16.0"
},
@@ -341,9 +334,9 @@
}
},
"node_modules/@angular/forms": {
"version": "22.1.6",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.6.tgz",
"integrity": "sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==",
"version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.7.tgz",
"integrity": "sha512-oc0DT39C3ZboJpDx8xKUCltgn56LHi0kbv+ThNK/dCSZLgjF2nd+muMMRYF+amdljQp2q7+hm2ORmhON3CNFog==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -354,16 +347,16 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"@angular/common": "22.1.6",
"@angular/core": "22.1.6",
"@angular/platform-browser": "22.1.6",
"@angular/common": "22.1.7",
"@angular/core": "22.1.7",
"@angular/platform-browser": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
"version": "22.1.6",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.6.tgz",
"integrity": "sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==",
"version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.7.tgz",
"integrity": "sha512-4dT3qGw3ZHVQUcHKALzMx9MTo+pxAMzHIPxc8HUESklqXfq6GRaIIfFMvE0RsquI9iqu6admD6lOAEzLWS8DxA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -372,9 +365,9 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"@angular/animations": "22.1.6",
"@angular/common": "22.1.6",
"@angular/core": "22.1.6"
"@angular/animations": "22.1.7",
"@angular/common": "22.1.7",
"@angular/core": "22.1.7"
},
"peerDependenciesMeta": {
"@angular/animations": {
@@ -383,9 +376,9 @@
}
},
"node_modules/@angular/router": {
"version": "22.1.6",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.6.tgz",
"integrity": "sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==",
"version": "22.1.7",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.7.tgz",
"integrity": "sha512-CxYa2Ym5L0WmqToHU2I0Z4VEpocdryLlvx5svLfo9M3uOaP3/9icB/gjS/vXPbbQxV8/GZ59E/7/LF14anIoRg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -394,60 +387,45 @@
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"@angular/common": "22.1.6",
"@angular/core": "22.1.6",
"@angular/platform-browser": "22.1.6",
"@angular/common": "22.1.7",
"@angular/core": "22.1.7",
"@angular/platform-browser": "22.1.7",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
"integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-calc": "^3.3.0",
"@csstools/css-color-parser": "^4.2.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": {
"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": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
"version": "9.1.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
"integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.1.0",
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.6"
}
},
"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",
"lru-cache": "^11.5.2"
},
"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": {
"version": "8.0.0",
"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": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz",
"integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
@@ -879,9 +857,9 @@
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
"dev": true,
"funding": [
{
@@ -4490,22 +4468,6 @@
"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": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5283,30 +5245,6 @@
"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": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
@@ -5529,39 +5467,38 @@
"license": "MIT"
},
"node_modules/jsdom": {
"version": "28.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"version": "30.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
"integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1",
"@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^9.1.2",
"@bramus/specificity": "^2.4.2",
"@exodus/bytes": "^1.11.0",
"cssstyle": "^6.0.1",
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.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",
"parse5": "^8.0.0",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.0",
"undici": "^7.21.0",
"tough-cookie": "^6.0.2",
"undici": "^8.10.2",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0",
"whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
@@ -5569,28 +5506,19 @@
}
}
},
"node_modules/jsdom/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/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==",
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "17.1.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
"integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": ">= 14"
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/jsesc": {
@@ -6708,9 +6636,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true,
"license": "MIT",
"bin": {
@@ -7308,13 +7236,6 @@
"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": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -7469,13 +7390,13 @@
}
},
"node_modules/undici": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
"node": ">=22.19.0"
}
},
"node_modules/unpipe": {
+10 -10
View File
@@ -12,12 +12,12 @@
"private": true,
"packageManager": "npm@11.19.0",
"dependencies": {
"@angular/common": "^22.1.0",
"@angular/compiler": "^22.1.0",
"@angular/core": "^22.1.0",
"@angular/forms": "^22.1.0",
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"@angular/common": "^22.1.7",
"@angular/compiler": "^22.1.7",
"@angular/core": "^22.1.7",
"@angular/forms": "^22.1.7",
"@angular/platform-browser": "^22.1.7",
"@angular/router": "^22.1.7",
"chart.js": "^4.5.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
@@ -25,11 +25,11 @@
"devDependencies": {
"@angular/build": "^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",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"jsdom": "^30.1.0",
"prettier": "^3.9.8",
"typescript": "~6.0.2",
"vitest": "^4.0.8"
"vitest": "^4.1.11"
}
}
+28 -7
View File
@@ -1,21 +1,36 @@
import { Routes } from '@angular/router';
import {authGuard} from './core/guards/auth-guard';
import { authGuard } from './core/guards/auth-guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
{
path: 'login',
loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
},
{
path: 'change-password',
loadComponent: () =>
import('./features/auth/change-password/change-password').then((m) => m.ChangePassword),
},
{
path: 'forgot-password',
loadComponent: () =>
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
},
{
path: 'reset-password',
loadComponent: () =>
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
},
{
path: 'sites',
canActivate: [authGuard],
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList),
},
{
path: 'sites/:siteId',
@@ -23,6 +38,12 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
},
{
path: 'recommendations',
canActivate: [authGuard],
loadComponent: () =>
import('./features/recommendations/recommendations').then((m) => m.RecommendationsView),
},
{
path: 'monitoring/sensors',
canActivate: [authGuard],
@@ -0,0 +1,74 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { RecommendationsService } from './recommendations.service';
import { environment } from '../../../environments/environment';
import { Recommendation } from '../../shared/models/recommendation.model';
const RECOMMANDATION_API: Recommendation = {
recommendation_id: 1,
alert_id: 1,
action: 'Vérifier la consommation',
explanation: 'Pic détecté',
rule_reference: 'spike-v1',
created_at: '2024-01-01T00:00:00Z',
};
describe('RecommendationsService', () => {
let service: RecommendationsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(RecommendationsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('liste les recommandations depuis le bon endpoint', () => {
let result: Recommendation[] = [];
service.getRecommendations().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations`);
expect(req.request.method).toBe('GET');
req.flush([RECOMMANDATION_API]);
expect(result.length).toBe(1);
expect(result[0].alert_id).toBe(1);
});
it('décrit une recommandation par son identifiant', () => {
service.getRecommendation(42).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations/42`);
expect(req.request.method).toBe('GET');
req.flush({ ...RECOMMANDATION_API, recommendation_id: 42 });
});
it('déclenche la génération en POST avec le site en paramètre de requête', () => {
let result: unknown;
service.generate('SITE001').subscribe((r) => (result = r));
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.body).toBeNull();
req.flush({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
expect(result).toEqual({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
});
it('génère pour tout le parc quand aucun site n’est donné', () => {
service.generate().subscribe();
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
);
expect(req.request.params.has('site_id')).toBe(false);
req.flush({ alerts_examined: 0, recommendations_created: 0, already_present: 0 });
});
});
@@ -0,0 +1,34 @@
import { Service, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import {
Recommendation,
RecommendationGenerationReport,
} from '../../shared/models/recommendation.model';
@Service()
export class RecommendationsService {
private http = inject(HttpClient);
getRecommendations() {
return this.http.get<Recommendation[]>(`${environment.apiUrl}/recommendations`);
}
getRecommendation(recommendationId: number) {
return this.http.get<Recommendation>(
`${environment.apiUrl}/recommendations/${recommendationId}`,
);
}
generate(siteId?: string) {
let params = new HttpParams();
if (siteId) {
params = params.set('site_id', siteId);
}
return this.http.post<RecommendationGenerationReport>(
`${environment.apiUrl}/recommendations/generate`,
null,
{ params },
);
}
}
@@ -14,6 +14,7 @@
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
}
<a routerLink="/sites" class="ev-link">Voir les sites</a>
<a routerLink="/recommendations" class="ev-link">Recommandations</a>
<ev-button
class="logout-button"
variant="secondary"
@@ -0,0 +1,58 @@
<div class="recommendations">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
</nav>
<header class="recommendations__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="recommendations__logo" />
</a>
<div>
<h1>Recommandations</h1>
<p class="recommendations__subtitle">
Actions proposées par le moteur de règles à partir des alertes
</p>
</div>
</header>
<div class="recommendations__toolbar">
<label class="recommendations__filter">
<span class="form-label">Site</span>
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
<option value="" [selected]="!siteFilter()">Tous les sites</option>
@for (site of sites(); track site.site_id) {
<option [value]="site.site_id" [selected]="site.site_id === siteFilter()">
{{ site.site_name }}
</option>
}
</select>
</label>
@if (isAdmin()) {
<ev-button
variant="secondary"
[fullWidth]="false"
[disabled]="generating()"
data-testid="generate"
(click)="onGenerate()"
>
{{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }}
</ev-button>
}
</div>
@if (generationReport(); as report) {
<ev-alert severity="success" class="recommendations__banner">{{ bilan(report) }}.</ev-alert>
}
@if (generationError(); as message) {
<ev-alert severity="danger" class="recommendations__banner">{{ message }}</ev-alert>
}
@if (alertId(); as id) {
<p class="recommendations__focus">
Alerte n° {{ id }} ·
<a routerLink="/recommendations" class="ev-link">Toutes les recommandations</a>
</p>
}
<app-recommendation-list [siteId]="siteFilter()" [alertId]="alertId()" [sites]="sites()" />
</div>
@@ -0,0 +1,59 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.recommendations__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.recommendations__logo {
font-size: 1.3rem;
}
.recommendations__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.recommendations__toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.recommendations__filter {
display: flex;
flex-direction: column;
min-width: 14rem;
.form-label {
margin-top: 0;
}
}
.recommendations__banner {
display: block;
margin-bottom: var(--space-3);
}
.recommendations__focus {
margin: 0 0 var(--space-3);
font-size: 0.9rem;
color: var(--color-text-muted);
}
@@ -0,0 +1,169 @@
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { RecommendationsView, parseAlertId } from './recommendations';
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
import { SitesService } from '../../core/services/sites.service';
import { AlertsService } from '../../core/services/alerts.service';
import { RecommendationsService } from '../../core/services/recommendations.service';
import { AuthService } from '../../core/services/auth.service';
const SITES = [
{
site_id: 'SITE001',
site_name: 'Usine Nantes',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
];
const BILAN = { alerts_examined: 2, recommendations_created: 3, already_present: 1 };
function setup(options: { query?: Record<string, string>; role?: string } = {}) {
const query = options.query ?? {};
const queryParamMap = new BehaviorSubject(convertToParamMap(query));
const generate = vi.fn().mockReturnValue(of(BILAN));
const getRecommendations = vi.fn().mockReturnValue(of([]));
const getAlerts = vi.fn().mockReturnValue(of([]));
TestBed.configureTestingModule({
imports: [RecommendationsView],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { queryParamMap, snapshot: { queryParamMap: convertToParamMap(query) } },
},
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of(SITES)) } },
{ provide: AlertsService, useValue: { getAlerts } },
{ provide: RecommendationsService, useValue: { getRecommendations, generate } },
{
provide: AuthService,
useValue: { principal: vi.fn().mockReturnValue({ role: options.role ?? 'lecteur' }) },
},
],
});
const fixture = TestBed.createComponent(RecommendationsView);
fixture.detectChanges();
fixture.detectChanges();
return { fixture, queryParamMap, generate, getRecommendations, getAlerts };
}
function listeEnfant(fixture: ReturnType<typeof setup>['fixture']): RecommendationList {
return fixture.debugElement.query(By.directive(RecommendationList)).componentInstance;
}
describe('parseAlertId', () => {
it("n'accepte qu'un entier strictement positif", () => {
expect(parseAlertId('12')).toBe(12);
expect(parseAlertId('0')).toBeNull();
expect(parseAlertId('-3')).toBeNull();
expect(parseAlertId('abc')).toBeNull();
expect(parseAlertId('12abc')).toBeNull();
expect(parseAlertId(null)).toBeNull();
});
});
describe('RecommendationsView', () => {
it("cible l'alerte donnée par ?alert= et la transmet à la liste", () => {
const { fixture } = setup({ query: { alert: '12' } });
expect(fixture.componentInstance.alertId()).toBe(12);
expect(listeEnfant(fixture).alertId()).toBe(12);
expect(fixture.nativeElement.textContent).toContain('Alerte n° 12');
expect(fixture.nativeElement.querySelector('a[href="/recommendations"]')).not.toBeNull();
});
it('ignore un paramètre alert invalide', () => {
const { fixture } = setup({ query: { alert: 'abc' } });
expect(fixture.componentInstance.alertId()).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('Alerte n°');
});
it('applique le site donné par ?site= au filtre et à la liste', () => {
const { fixture, getAlerts } = setup({ query: { site: 'SITE001' } });
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
const option = fixture.nativeElement.querySelector(
'option[value="SITE001"]',
) as HTMLOptionElement;
expect(option.selected).toBe(true);
});
it('relance la liste sur le site choisi dans le filtre', () => {
const { fixture, getAlerts } = setup();
const select = fixture.nativeElement.querySelector(
'[data-testid="site-filter"]',
) as HTMLSelectElement;
select.value = 'SITE001';
select.dispatchEvent(new Event('change'));
fixture.detectChanges();
fixture.detectChanges();
expect(getAlerts).toHaveBeenLastCalledWith({ site_id: 'SITE001' });
expect(listeEnfant(fixture).siteId()).toBe('SITE001');
});
it('cache le bouton de génération aux lecteurs', () => {
const { fixture } = setup({ role: 'lecteur' });
expect(fixture.nativeElement.querySelector('[data-testid="generate"]')).toBeNull();
});
it('permet à un admin de générer pour le site filtré, affiche le bilan et recharge la liste', () => {
const { fixture, generate, getRecommendations } = setup({
role: 'admin',
query: { site: 'SITE001' },
});
fixture.nativeElement.querySelector('[data-testid="generate"]').click();
fixture.detectChanges();
fixture.detectChanges();
expect(generate).toHaveBeenCalledWith('SITE001');
expect(fixture.nativeElement.textContent).toContain(
'3 recommandations créées, 1 déjà présente, 2 alertes examinées.',
);
expect(getRecommendations).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.generating()).toBe(false);
});
it('génère pour tout le parc quand aucun site n’est filtré', () => {
const { fixture, generate } = setup({ role: 'admin' });
fixture.componentInstance.onGenerate();
expect(generate).toHaveBeenCalledWith(undefined);
});
it("signale l'échec de la génération sans casser la page", () => {
const { fixture, generate } = setup({ role: 'admin' });
generate.mockReturnValue(throwError(() => new Error('403')));
fixture.componentInstance.onGenerate();
fixture.detectChanges();
expect(fixture.componentInstance.generationError()).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain(
'La génération des recommandations a échoué',
);
expect(fixture.componentInstance.generating()).toBe(false);
});
it('accorde le bilan au singulier', () => {
const { fixture } = setup();
expect(
fixture.componentInstance.bilan({
alerts_examined: 1,
recommendations_created: 1,
already_present: 0,
}),
).toBe('1 recommandation créée, 0 déjà présente, 1 alerte examinée');
});
});
@@ -0,0 +1,85 @@
import { Component, computed, inject, signal, viewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { SitesService } from '../../core/services/sites.service';
import { RecommendationsService } from '../../core/services/recommendations.service';
import { AuthService } from '../../core/services/auth.service';
import { Site } from '../../shared/models/site.model';
import { RecommendationGenerationReport } from '../../shared/models/recommendation.model';
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
import { Brand } from '../../shared/components/ui/brand/brand';
import { Button } from '../../shared/components/ui/button/button';
const GENERATION_FAILED_MESSAGE =
'La génération des recommandations a échoué, réessayez plus tard.';
export function parseAlertId(raw: string | null): number | null {
return raw !== null && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
}
function pluriel(nombre: number, singulier: string, plurielForme: string): string {
return `${nombre} ${nombre > 1 ? plurielForme : singulier}`;
}
@Component({
selector: 'app-recommendations',
standalone: true,
imports: [RouterLink, RecommendationList, EvAlert, Brand, Button],
templateUrl: './recommendations.html',
styleUrl: './recommendations.scss',
})
export class RecommendationsView {
private route = inject(ActivatedRoute);
private sitesService = inject(SitesService);
private recommendationsService = inject(RecommendationsService);
private auth = inject(AuthService);
alertId = toSignal(
this.route.queryParamMap.pipe(map((params) => parseAlertId(params.get('alert')))),
{ initialValue: null },
);
siteFilter = signal<string | null>(this.route.snapshot.queryParamMap.get('site'));
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
initialValue: [] as Site[],
});
list = viewChild.required(RecommendationList);
isAdmin = computed(() => this.auth.principal()?.role === 'admin');
generating = signal(false);
generationReport = signal<RecommendationGenerationReport | null>(null);
generationError = signal<string | null>(null);
onSiteChange(event: Event): void {
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
}
onGenerate(): void {
if (this.generating()) {
return;
}
this.generating.set(true);
this.generationError.set(null);
this.recommendationsService.generate(this.siteFilter() ?? undefined).subscribe({
next: (report) => {
this.generating.set(false);
this.generationReport.set(report);
this.list().reload();
},
error: () => {
this.generating.set(false);
this.generationError.set(GENERATION_FAILED_MESSAGE);
},
});
}
bilan(report: RecommendationGenerationReport): string {
return [
pluriel(report.recommendations_created, 'recommandation créée', 'recommandations créées'),
pluriel(report.already_present, 'déjà présente', 'déjà présentes'),
pluriel(report.alerts_examined, 'alerte examinée', 'alertes examinées'),
].join(', ');
}
}
@@ -81,5 +81,16 @@
}
}
<section class="recommendations-section">
<h2>Recommandations</h2>
<app-recommendation-list [siteId]="siteId() ?? null" [sites]="siteAsList()" />
<a
routerLink="/recommendations"
[queryParams]="{ site: siteId() }"
class="ev-link recommendations-section__link"
>Voir dans la vue recommandations</a
>
</section>
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</div>
@@ -117,3 +117,12 @@ h2 {
.chart-section {
margin-bottom: 2rem;
}
.recommendations-section {
margin: 2.5rem 0 1.5rem;
}
.recommendations-section__link {
display: inline-block;
margin-top: 1rem;
}
@@ -5,6 +5,8 @@ import { BehaviorSubject, of, throwError } from 'rxjs';
import { SiteDetail } from './site-detail';
import { SitesService } from '../../../core/services/sites.service';
import { ReadingsService } from '../../../core/services/readings.service';
import { AlertsService } from '../../../core/services/alerts.service';
import { RecommendationsService } from '../../../core/services/recommendations.service';
const SITE = {
site_id: 'SITE001',
@@ -69,6 +71,7 @@ function setup(
readingsMock: Partial<ReadingsService>,
) {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
const getAlerts = vi.fn().mockReturnValue(of([]));
TestBed.configureTestingModule({
imports: [SiteDetail],
providers: [
@@ -76,9 +79,14 @@ function setup(
{ provide: ActivatedRoute, useValue: { paramMap } },
{ provide: SitesService, useValue: sitesMock },
{ provide: ReadingsService, useValue: readingsMock },
{ provide: AlertsService, useValue: { getAlerts } },
{
provide: RecommendationsService,
useValue: { getRecommendations: vi.fn().mockReturnValue(of([])) },
},
],
});
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
return { fixture: TestBed.createComponent(SiteDetail), paramMap, getAlerts };
}
describe('SiteDetail', () => {
@@ -261,6 +269,27 @@ describe('SiteDetail', () => {
);
});
it('demande les recommandations du site consulté à travers ses alertes', () => {
const { fixture, getAlerts } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
fixture.detectChanges();
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
expect(fixture.nativeElement.querySelector('app-recommendation-list')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Recommandations');
expect(
fixture.nativeElement.querySelector('a[href="/recommendations?site=SITE001"]'),
).not.toBeNull();
});
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
const getHistory = vi.fn().mockReturnValue(of([]));
const { fixture } = setup(
@@ -13,6 +13,7 @@ import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
import { Brand } from '../../../shared/components/ui/brand/brand';
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
import { RecommendationList } from '../../../shared/components/recommendation-list/recommendation-list';
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
@@ -96,7 +97,16 @@ export interface MetricView {
@Component({
selector: 'app-site-detail',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
imports: [
RouterLink,
Card,
Alert,
Badge,
Brand,
ConsumptionGauge,
ReadingHistoryChart,
RecommendationList,
],
templateUrl: './site-detail.html',
styleUrl: './site-detail.scss',
})
@@ -117,6 +127,11 @@ export class SiteDetail {
hasMeasurement = computed(() => this.current()?.timestamp != null);
siteAsList = computed<Site[]>(() => {
const site = this.site();
return site ? [site] : [];
});
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
consumptionLabel = computed(() => {
@@ -0,0 +1,48 @@
@if (error(); as message) {
<ev-alert severity="danger" class="reco-list__banner">{{ message }}</ev-alert>
} @else if (loading() && !hasData()) {
<p class="reco-list__state" aria-live="polite">Chargement des recommandations…</p>
} @else if (visibleGroups().length === 0) {
<ev-alert severity="success" class="reco-list__banner">{{ emptyMessage() }}</ev-alert>
}
<div class="reco-list" [attr.aria-busy]="loading()">
@for (group of visibleGroups(); track group.alert.alert_id) {
<ev-card
class="reco-group"
[class.reco-group--focus]="group.alert.alert_id === alertId()"
[id]="'alerte-' + group.alert.alert_id"
>
<header class="reco-group__alert">
<div class="reco-group__meta">
<ev-badge [tone]="toneFor(group.alert.severity)">{{
severityLabel(group.alert.severity)
}}</ev-badge>
<span class="reco-group__type">{{ typeLabel(group.alert.type) }}</span>
@if (!siteId()) {
<a [routerLink]="['/sites', group.alert.site_id]" class="ev-link">{{
group.siteName
}}</a>
}
<time [attr.datetime]="group.alert.timestamp">{{
group.alert.timestamp | date: 'dd/MM/yyyy HH:mm'
}}</time>
</div>
<p class="reco-group__message">{{ group.alert.message }}</p>
</header>
<ol class="reco-group__items">
@for (reco of group.recommendations; track reco.recommendation_id) {
<li class="reco">
<div class="reco__head">
<strong class="reco__action">{{ reco.action }}</strong>
<ev-badge [tone]="ruleTone(reco.rule_reference)">{{
ruleLabel(reco.rule_reference)
}}</ev-badge>
</div>
<p class="reco__explanation">{{ reco.explanation }}</p>
</li>
}
</ol>
</ev-card>
}
</div>
@@ -0,0 +1,94 @@
:host {
display: block;
}
.reco-list__banner {
display: block;
margin-bottom: var(--space-3);
}
.reco-list__state {
margin: 0 0 var(--space-3);
font-size: 0.9rem;
color: var(--color-text-muted);
}
.reco-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.reco-group {
padding: var(--space-4);
gap: var(--space-3);
}
.reco-group--focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.reco-group__alert {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-light);
}
.reco-group__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
font-size: 0.8rem;
color: var(--color-text-muted);
}
.reco-group__type {
font-weight: 600;
color: var(--color-text);
}
.reco-group__message {
margin: 0;
font-size: 0.9rem;
}
.reco-group__items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.reco {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--color-bg);
border-left: 3px solid var(--color-primary);
}
.reco__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.reco__action {
font-size: 0.95rem;
}
.reco__explanation {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
@@ -0,0 +1,244 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { NEVER, of, throwError } from 'rxjs';
import { RecommendationList, joinByAlert } from './recommendation-list';
import { AlertsService } from '../../../core/services/alerts.service';
import { RecommendationsService } from '../../../core/services/recommendations.service';
import { Alert } from '../../models/alert.model';
import { Recommendation } from '../../models/recommendation.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',
},
{
site_id: 'SITE002',
site_name: 'Bureau Lille',
site_type: 'bureau',
location: 'Lille',
capacity_kw: 80,
status: 'actif',
},
];
function alerte(surcharges: Partial<Alert>): Alert {
return {
alert_id: 1,
site_id: 'SITE001',
timestamp: '2026-09-15T09:00:00Z',
type: 'threshold',
severity: 'high',
message: 'Puissance appelée au-dessus de la capacité du site',
value: 812.5,
threshold: 720,
metric: 'consumption_kw',
prediction_id: null,
...surcharges,
};
}
function reco(surcharges: Partial<Recommendation>): Recommendation {
return {
recommendation_id: 1,
alert_id: 1,
action: 'Ramener la puissance appelée sous le seuil contractuel',
explanation: 'Seuil de consommation dépassé sur le site SITE001.',
rule_reference: 'threshold-reduction-v1',
created_at: '2026-09-15T09:05:00Z',
...surcharges,
};
}
const ALERTES: Alert[] = [
alerte({ alert_id: 1, site_id: 'SITE001', timestamp: '2026-09-15T09:00:00Z' }),
alerte({
alert_id: 2,
site_id: 'SITE002',
timestamp: '2026-09-15T11:00:00Z',
severity: 'critical',
type: 'spike',
message: 'Variation brutale entre deux lectures consécutives',
}),
alerte({ alert_id: 3, site_id: 'SITE001', timestamp: '2026-09-15T10:00:00Z', severity: 'low' }),
];
const RECOMMANDATIONS: Recommendation[] = [
reco({
recommendation_id: 3,
alert_id: 2,
action: "Escalader à l'astreinte sous une heure",
rule_reference: 'escalade-astreinte-v1',
}),
reco({ recommendation_id: 1, alert_id: 1 }),
reco({
recommendation_id: 2,
alert_id: 2,
action: 'Délester les équipements non prioritaires sur le créneau du pic',
rule_reference: 'spike-delestage-v1',
}),
reco({ recommendation_id: 4, alert_id: 99, rule_reference: 'orpheline-v1' }),
];
function setup(
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
recosMock: { getRecommendations: ReturnType<typeof vi.fn> },
inputs: Record<string, unknown> = {},
) {
TestBed.configureTestingModule({
imports: [RecommendationList],
providers: [
provideRouter([]),
{ provide: AlertsService, useValue: alertsMock },
{ provide: RecommendationsService, useValue: recosMock },
],
});
const fixture = TestBed.createComponent(RecommendationList);
for (const [nom, valeur] of Object.entries(inputs)) {
fixture.componentRef.setInput(nom, valeur);
}
return fixture;
}
function rendre(fixture: ComponentFixture<RecommendationList>) {
fixture.detectChanges();
fixture.detectChanges();
}
function texte(fixture: ComponentFixture<RecommendationList>): string {
return (fixture.nativeElement as HTMLElement).textContent ?? '';
}
const recosOk = () => ({ getRecommendations: vi.fn().mockReturnValue(of(RECOMMANDATIONS)) });
describe('joinByAlert', () => {
it('groupe par alerte, du plus récent au plus ancien, recommandations par identifiant', () => {
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map([['SITE001', 'Usine Nantes']]));
expect(groupes.map((g) => g.alert.alert_id)).toEqual([2, 1]);
expect(groupes[0].recommendations.map((r) => r.recommendation_id)).toEqual([2, 3]);
expect(groupes[1].siteName).toBe('Usine Nantes');
expect(groupes[0].siteName).toBe('SITE002');
});
it('ignore les alertes sans recommandation et les recommandations orphelines', () => {
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map());
expect(groupes.some((g) => g.alert.alert_id === 3)).toBe(false);
expect(groupes.flatMap((g) => g.recommendations).some((r) => r.alert_id === 99)).toBe(false);
});
});
describe('RecommendationList', () => {
it('charge alertes et recommandations puis affiche les groupes avec leur contexte', () => {
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
const fixture = setup({ getAlerts }, recosOk(), { sites: SITES });
rendre(fixture);
expect(getAlerts).toHaveBeenCalledWith({});
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(2);
const contenu = texte(fixture);
expect(contenu).toContain('Usine Nantes');
expect(contenu).toContain('Bureau Lille');
expect(contenu).toContain('Critique');
expect(contenu).toContain('Pic de consommation');
expect(contenu).toContain('Escalade astreinte');
expect(contenu).toContain('Délester les équipements');
expect(contenu).toContain('15/09/2026');
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE002"]')).not.toBeNull();
expect(fixture.componentInstance.total()).toBe(3);
expect(fixture.componentInstance.error()).toBeNull();
});
it('filtre les alertes du site côté API et masque le lien vers le site', () => {
const getAlerts = vi.fn().mockReturnValue(of(ALERTES.filter((a) => a.site_id === 'SITE001')));
const fixture = setup({ getAlerts }, recosOk(), { siteId: 'SITE001', sites: SITES });
rendre(fixture);
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(1);
expect(fixture.nativeElement.querySelector('a[href^="/sites/"]')).toBeNull();
});
it("ne garde que le groupe de l'alerte ciblée et le met en évidence", () => {
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
alertId: 2,
});
rendre(fixture);
const groupes = fixture.nativeElement.querySelectorAll('.reco-group');
expect(groupes.length).toBe(1);
expect(groupes[0].classList.contains('reco-group--focus')).toBe(true);
expect(groupes[0].id).toBe('alerte-2');
});
it("annonce l'absence de recommandation pour une alerte inconnue", () => {
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
alertId: 123,
});
rendre(fixture);
expect(texte(fixture)).toContain('Aucune recommandation pour cette alerte.');
});
it("annonce l'absence de recommandation pour le site consulté", () => {
const fixture = setup(
{ getAlerts: vi.fn().mockReturnValue(of([])) },
{ getRecommendations: vi.fn().mockReturnValue(of([])) },
{ siteId: 'SITE001' },
);
rendre(fixture);
expect(texte(fixture)).toContain('Aucune recommandation pour ce site.');
});
it("signale l'indisponibilité et n'affiche aucun groupe si un des deux appels échoue", () => {
const fixture = setup(
{ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) },
{ getRecommendations: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
);
rendre(fixture);
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.groups()).toEqual([]);
expect(texte(fixture)).toContain('Recommandations indisponibles');
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(0);
});
it('annonce le chargement tant que la réponse ne vient pas', () => {
const fixture = setup(
{ getAlerts: vi.fn().mockReturnValue(NEVER) },
{ getRecommendations: vi.fn().mockReturnValue(NEVER) },
);
rendre(fixture);
expect(fixture.componentInstance.loading()).toBe(true);
expect(texte(fixture)).toContain('Chargement des recommandations');
});
it('recharge les deux flux à la demande', () => {
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
const recos = recosOk();
const fixture = setup({ getAlerts }, recos);
rendre(fixture);
fixture.componentInstance.reload();
rendre(fixture);
expect(getAlerts).toHaveBeenCalledTimes(2);
expect(recos.getRecommendations).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,163 @@
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { catchError, EMPTY, forkJoin, Observable, switchMap, tap } from 'rxjs';
import { AlertsService } from '../../../core/services/alerts.service';
import { RecommendationsService } from '../../../core/services/recommendations.service';
import { Alert, AlertSeverity, AlertType } from '../../models/alert.model';
import { Recommendation } from '../../models/recommendation.model';
import { Site } from '../../models/site.model';
import {
LIBELLE_PAR_SEVERITE,
LIBELLE_PAR_TYPE,
TON_PAR_SEVERITE,
} from '../../models/alert-presentation';
import { libelleRegle, tonRegle } from '../../models/recommendation-presentation';
import { Card } from '../ui/card/card';
import { Badge, BadgeTone } from '../ui/badge/badge';
import { Alert as EvAlert } from '../ui/alert/alert';
const UNAVAILABLE_MESSAGE = 'Recommandations indisponibles, réessayez plus tard.';
export interface RecommendedAlertView {
alert: Alert;
siteName: string;
recommendations: Recommendation[];
}
interface Chargement {
alerts: Alert[];
recommendations: Recommendation[];
}
// Pourquoi : une recommandation ne porte que alert_id, jamais site_id, et /recommendations n'a
// aucun filtre ; la jointure se fait ici, en O(alertes), acceptable à la taille du jeu de données.
export function joinByAlert(
alerts: Alert[],
recommendations: Recommendation[],
siteNames: Map<string, string>,
): RecommendedAlertView[] {
const parAlerte = new Map<number, Recommendation[]>();
for (const recommandation of recommendations) {
const liste = parAlerte.get(recommandation.alert_id) ?? [];
liste.push(recommandation);
parAlerte.set(recommandation.alert_id, liste);
}
return alerts
.filter((alert) => parAlerte.has(alert.alert_id))
.map((alert) => ({
alert,
siteName: siteNames.get(alert.site_id) ?? alert.site_id,
recommendations: [...(parAlerte.get(alert.alert_id) ?? [])].sort(
(a, b) => a.recommendation_id - b.recommendation_id,
),
}))
.sort((a, b) => Date.parse(b.alert.timestamp) - Date.parse(a.alert.timestamp));
}
@Component({
selector: 'app-recommendation-list',
standalone: true,
imports: [DatePipe, RouterLink, Card, Badge, EvAlert],
templateUrl: './recommendation-list.html',
styleUrl: './recommendation-list.scss',
})
export class RecommendationList {
private alertsService = inject(AlertsService);
private recommendationsService = inject(RecommendationsService);
private destroyRef = inject(DestroyRef);
siteId = input<string | null>(null);
alertId = input<number | null>(null);
sites = input<Site[]>([]);
private data = signal<Chargement | null>(null);
private reloadTick = signal(0);
loading = signal(true);
error = signal<string | null>(null);
private trigger = computed(() => ({ siteId: this.siteId(), tick: this.reloadTick() }));
private siteNameById = computed(
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
);
hasData = computed(() => this.data() !== null);
groups = computed<RecommendedAlertView[]>(() => {
const data = this.data();
return data ? joinByAlert(data.alerts, data.recommendations, this.siteNameById()) : [];
});
visibleGroups = computed(() => {
const alertId = this.alertId();
const groups = this.groups();
return alertId === null ? groups : groups.filter((group) => group.alert.alert_id === alertId);
});
total = computed(() =>
this.visibleGroups().reduce((somme, group) => somme + group.recommendations.length, 0),
);
emptyMessage = computed(() => {
if (this.alertId() !== null) {
return 'Aucune recommandation pour cette alerte.';
}
return this.siteId()
? 'Aucune recommandation pour ce site.'
: 'Aucune recommandation pour le moment.';
});
constructor() {
toObservable(this.trigger)
.pipe(
tap(() => this.loading.set(true)),
switchMap(({ siteId }) =>
forkJoin({
alerts: this.alertsService.getAlerts(siteId ? { site_id: siteId } : {}),
recommendations: this.recommendationsService.getRecommendations(),
}).pipe(catchError(() => this.reportUnavailable())),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((data) => {
this.loading.set(false);
this.error.set(null);
this.data.set(data);
});
}
reload(): void {
this.reloadTick.update((tick) => tick + 1);
}
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];
}
ruleLabel(reference: string): string {
return libelleRegle(reference);
}
ruleTone(reference: string): BadgeTone {
return tonRegle(reference);
}
// Piège : vider les données avec l'erreur ; une demi-jointure (alertes sans recommandations,
// ou l'inverse) afficherait des groupes faux plutôt que rien.
private reportUnavailable(): Observable<never> {
this.loading.set(false);
this.error.set(UNAVAILABLE_MESSAGE);
this.data.set(null);
return EMPTY;
}
}
@@ -0,0 +1,23 @@
import { libelleRegle, tonRegle } from './recommendation-presentation';
describe('recommendation-presentation', () => {
it('traduit les sept règles connues du moteur', () => {
expect(libelleRegle('spike-delestage-v1')).toBe('Délestage');
expect(libelleRegle('threshold-reduction-v1')).toBe('Réduction de puissance');
expect(libelleRegle('outage-secours-v1')).toBe('Alimentation de secours');
expect(libelleRegle('sensor-maintenance-v1')).toBe('Maintenance capteur');
expect(libelleRegle('anomaly-verification-v1')).toBe('Vérification');
expect(libelleRegle('escalade-astreinte-v1')).toBe('Escalade astreinte');
expect(libelleRegle('contrat-puissance-v1')).toBe('Contrat de puissance');
});
it('affiche telle quelle une référence de règle inconnue', () => {
expect(libelleRegle('spike-delestage-v2')).toBe('spike-delestage-v2');
});
it("réserve le ton critique à l'escalade vers l'astreinte", () => {
expect(tonRegle('escalade-astreinte-v1')).toBe('critical');
expect(tonRegle('spike-delestage-v1')).toBe('neutral');
expect(tonRegle('inconnue-v9')).toBe('neutral');
});
});
@@ -0,0 +1,23 @@
import { BadgeTone } from '../components/ui/badge/badge';
// Contrainte : une règle dont le sens change reçoit un suffixe -v2 côté backend (ADR 0006) ;
// une référence inconnue s'affiche donc telle quelle plutôt que de casser la vue.
const LIBELLE_PAR_REGLE: Record<string, string> = {
'spike-delestage-v1': 'Délestage',
'threshold-reduction-v1': 'Réduction de puissance',
'outage-secours-v1': 'Alimentation de secours',
'sensor-maintenance-v1': 'Maintenance capteur',
'anomaly-verification-v1': 'Vérification',
'escalade-astreinte-v1': 'Escalade astreinte',
'contrat-puissance-v1': 'Contrat de puissance',
};
const REGLE_ESCALADE = 'escalade-astreinte-v1';
export function libelleRegle(reference: string): string {
return LIBELLE_PAR_REGLE[reference] ?? reference;
}
export function tonRegle(reference: string): BadgeTone {
return reference === REGLE_ESCALADE ? 'critical' : 'neutral';
}
@@ -0,0 +1,14 @@
export interface Recommendation {
recommendation_id: number;
alert_id: number;
action: string;
explanation: string;
rule_reference: string;
created_at: string;
}
export interface RecommendationGenerationReport {
alerts_examined: number;
recommendations_created: number;
already_present: number;
}
+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
-- 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).
+13 -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.
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
# Pourquoi : ports du proxy et origine publique en variables, pour que deux environnements
# cohabitent sur la même machine, chacun dans son projet Compose (ADR 0009).
name: enervision
@@ -12,14 +14,19 @@ services:
db:
ports: !override
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
environment:
TS_TUNE_MEMORY: ${TS_TUNE_MEMORY:-2GB}
TS_TUNE_NUM_CPUS: ${TS_TUNE_NUM_CPUS:-2}
mailpit:
ports: !override
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
airflow-webserver:
airflow-apiserver:
ports: !override
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
environment:
AIRFLOW__WEBSERVER__WORKERS: ${AIRFLOW_WEBSERVER_WORKERS:-2}
backend:
ports: !reset null
@@ -37,22 +44,22 @@ services:
APP_ENV: prod
APP_DEBUG: "false"
APP_TRUST_PROXY_HEADERS: "true"
APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local}
APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password
APP_CORS_ORIGINS: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}
APP_FRONTEND_RESET_PASSWORD_URL: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}/reset-password
frontend:
ports: !reset null
proxy:
image: nginx:1.28-alpine
image: nginx:1.31-alpine
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
ports:
- "80:80"
- "443:443"
- "${PROXY_HTTP_PORT:-80}:80"
- "${PROXY_HTTPS_PORT:-443}:443"
volumes:
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
+32 -13
View File
@@ -5,9 +5,9 @@
name: enervision
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du
# webserver. `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
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais de
# 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 l'api-server pour que ce
# dernier puisse au besoin l'inspecter sans en devenir dependant.
x-airflow-common: &airflow-common
build:
@@ -19,9 +19,16 @@ x-airflow-common: &airflow-common
# 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`,
# `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__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
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) :
# memes identifiants que le backend en attendant.
@@ -36,6 +43,7 @@ x-airflow-common: &airflow-common
volumes:
- ./etl/airflow/dags:/opt/airflow/dags
- ./etl/airflow/plugins:/opt/airflow/plugins
- ./data/raw:/opt/data/raw:ro
- airflow_logs:/opt/airflow/logs
- airflow_ml_state:/opt/ml/state
restart: unless-stopped
@@ -110,11 +118,11 @@ services:
# 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
# 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
# succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement,
# jamais par `argv` (ni `ps`, ni `docker compose config`).
# `pgdata` deja peuple) fait echouer ce service, et api-server, dag-processor et scheduler,
# qui attendent son succes, ne demarrent pas sur une base non migree. Le mot de passe passe
# 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
# refuse en plus les deux cles de chiffrement vides.
# refuse en plus les cles et secrets vides.
airflow-init:
<<: *airflow-common
restart: "no"
@@ -134,13 +142,14 @@ services:
- |
set -euo pipefail
: "$${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}"
exec airflow version
airflow-webserver:
airflow-apiserver:
<<: *airflow-common
command: webserver
command: api-server
ports:
- "${AIRFLOW_PORT:-8080}:8080"
depends_on:
@@ -149,7 +158,7 @@ services:
airflow-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
test: ["CMD", "curl", "--fail", "http://localhost:8080/api/v2/monitor/health"]
interval: 30s
timeout: 10s
retries: 5
@@ -164,6 +173,16 @@ services:
airflow-init:
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:
pgdata:
airflow_logs:
+1
View File
@@ -15,3 +15,4 @@
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
| [0009](adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
@@ -0,0 +1,80 @@
# 0009 - Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé
- Statut : accepté
- Date : 2026-09-21
## Contexte
La grille note EC03 à EC06 sur ce qui est déployé et fonctionnel au J10. Au 21/09, rien ne
l'est : la CI s'arrête au merge (issue #21), la topologie Compose avec reverse proxy
([ADR 0007](0007-terminaison-tls-et-reverse-proxy-nginx.md)) n'a jamais quitté le poste, et le
module Terraform k3s n'a jamais été appliqué. L'école met à disposition une seule VM,
`eadl-2025-nantes-g3`, sur une adresse privée que les runners hébergés par GitHub ne joignent
pas, sans DNS public.
Il faut deux environnements, recette et production, parce que la stratégie de branches en a
déjà deux, `dev` et `main`, et qu'un déploiement direct en production à chaque merge sur `dev`
n'est pas défendable.
La branche `feat/deploy` tentait de déployer par provisioners Terraform : nginx système et copie
du build Angular. La revue postée sur #21 relève huit points bloquants, dont des racines `rec`
et `prod` qui ne passent pas `terraform validate`.
## Décision
**Un projet Docker Compose par environnement, sur la même machine.** Deux clones du dépôt,
`/srv/enervision/rec` sur `dev` et `/srv/enervision/prod` sur `main`, chacun avec son `.env` et
son `COMPOSE_PROJECT_NAME`. Le nom de projet préfixe volumes, réseau et conteneurs : les deux
stacks ne partagent rien.
**Les ports du proxy et l'origine publique deviennent des variables** de
`docker-compose.prod.yml`. La production garde 80 et 443. La recette publie 8443 et ramène sa
redirection HTTP sur la boucle locale, faute de quoi elle renverrait vers la production. Base,
Mailpit et Airflow restent sur `127.0.0.1`, décalés d'un port.
**Deux noms d'hôte**, `enervision.local` et `rec.enervision.local`, sur la même IP. Le cookie de
rafraîchissement est posé par hôte, pas par port : un seul nom ferait se déconnecter la
production à chaque connexion en recette.
**Un runner GitHub Actions auto-hébergé sur la VM** exécute `deploy.yml` : un `push` sur `dev`
déploie la recette, un `push` sur `main` déploie la production après approbation dans
l'environnement GitHub `prod`. Le job aligne le clone sur la branche puis lance `make stack-up`.
Les images sont construites sur la machine.
**Les secrets vivent dans le `.env` de chaque dossier**, générés sur la machine par
`scripts/provision-host.sh`, jamais dans git ni dans GitHub. Le runner n'a besoin d'aucun
secret.
## Alternatives écartées
- **k3s avec un namespace par environnement** : le cluster serait vide, sans manifeste, sans
registre, sans stockage persistant. C'est la cible de `10-infra.md`, pas celle de la semaine.
- **Provisioners Terraform de `feat/deploy`** : voir la revue sur #21. Terraform reste l'outil
de provisionnement de la machine, pas de livraison applicative.
- **Deux machines**, VM Proxmox et VM Azure ENI : une deuxième infrastructure à justifier devant
le jury et à provisionner, pour un bénéfice nul sur la grille.
- **Un seul proxy frontal routant par nom d'hôte vers les deux stacks** : des URL sans port,
mais le proxy devrait joindre deux réseaux Compose où les services portent les mêmes noms.
La complexité dépasse le gain.
- **Images publiées sur GHCR et déployées par digest** : la bonne pratique, remise à plus tard.
Un registre à authentifier sur la machine, alors que le runner y construit déjà.
## Conséquences
- Deux TimescaleDB sur une machine de 8 Go : sans réglage, chacune se réserverait 25 % de la
RAM au premier démarrage. L'overlay fixe `TS_TUNE_MEMORY` à 2 Go et `TS_TUNE_NUM_CPUS` à 2 par
base, et 2 workers gunicorn par webserver Airflow. La montée à 32 Go prévue par les
consignes est à demander.
- Un runner auto-hébergé sur un dépôt public exécute le code qu'on lui envoie. `deploy.yml` ne
se déclenche jamais sur `pull_request`, le runner tourne sous un utilisateur dédié, et le
dépôt doit exiger une approbation pour les workflows des PR externes.
- Les deux environnements construisent leurs images séparément à partir du même commit : ce qui
tourne en production a été construit deux fois, pas promu. Le passage à GHCR lèvera cette
limite.
- Le `make stack-up` du runner reconstruit l'image Airflow, qui copie `ml/` et `apps/backend/`,
à chaque push : plusieurs minutes par déploiement, acceptable pour la cadence du projet.
- `environments/prod` de Terraform reste vide. Le provisionnement de la machine est porté par
`scripts/provision-host.sh`, que Terraform pourra appeler par `remote-exec` le jour où une
racine visant la VM existera.
- L'image frontend quitte `dhi.io/nginx`, registre authentifié dont personne n'a l'accès, pour
`nginx:1.28-alpine`, la même image que le proxy. Elle n'avait jamais été construite.
+8 -5
View File
@@ -70,10 +70,11 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
[30-frontend.md](30-frontend.md).
Le lien `airflow --> db` est maintenant en trait plein : trois DAGs tournent, deux pour
Le lien `airflow --> db` est maintenant en trait plein : quatre DAGs tournent, deux pour
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
génération des recommandations (issue #116), cf. plus bas et [20-backend.md](20-backend.md). Le
reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit.
génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset
historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des
deux sources restent à compléter dans l'issue #15.
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
collecteur ne vient le lire.
@@ -88,8 +89,8 @@ collecteur ne vient le lire.
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 5 workflows, 16 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) |
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 6 workflows, 18 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) |
## Flux bout en bout
@@ -186,3 +187,5 @@ Elles vivent dans `../adr/`, pas ici.
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
| [0008](../adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
| [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
+82 -10
View File
@@ -7,7 +7,9 @@ quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre
|---|---|---|
| Docker Compose | Développer et recetter sur le poste | `Fait` |
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` |
| k3s single-node | Cible à terme | `En cours` |
| MLflow (`ml/`) | Tracker les expériences et le registre de modèles en local | `Fait`, non relié aux autres topologies |
## 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
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).
- `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).
### 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`) :
| 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-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même |
| `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-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 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/`) :
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` à
@@ -75,6 +85,12 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` |
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
`./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les
modifier.
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
@@ -95,14 +111,14 @@ rend contraignant.
`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
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`).
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`,
`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_APP_SECRET_KEY` vides).
`airflow-init` qui refuse de démarrer (clé Fernet, clé de session de l'API, secret JWT, mot de
passe ou `AIRFLOW_APP_SECRET_KEY` vides).
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
@@ -132,6 +148,34 @@ est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans qu
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
construction de l'image.
### MLflow (`ml/`)
Statut : `Fait`, en local uniquement. Défini par `ml/docker-compose.mlflow.yml`, indépendant
du `docker-compose.yml` principal (réseau, volumes et démarrage séparés).
| Service | Image | Points notables |
|---|---|---|
| `mlflow-db` | `postgres:17` | Stocke le tracking store MLflow. Mot de passe obligatoire via `MLFLOW_DB_PASSWORD` |
| `mlflow` | Construite depuis `ml/` | Expose l'UI et l'API MLflow sur `127.0.0.1:5000`. Artefacts sur volume `mlflow-artifacts`, tracking store sur `mlflow-db` |
Portée actuelle : environnement de tracking et de registre de modèles pour le développement
local uniquement. Ce compose n'est relié ni à `docker-compose.prod.yml`, ni aux deux
environnements Compose de la VM ENI, ni à la cible k3s. Le magasin utilisé par Airflow pour
`ml_train`/`ml_score` (SQLite, volume `airflow_ml_state`) en est distinct — les deux MLflow ne
se voient pas tant que `MLFLOW_TRACKING_URI` n'est pas posé côté Airflow.
Limite connue : le DAG Airflow `ml_train` enregistre lui aussi une version a chaque execution
via `registered_model_name` (magasin SQLite du volume `airflow_ml_state`, distinct de ce
serveur). Versions et artefacts s'y accumulent sans politique de nettoyage -- fonctionne en
l'etat, mais a surveiller si les entrainements deviennent frequents.
Pour relier les runs Airflow (`ml_train`, magasin SQLite local) a ce serveur MLflow, positionner
`MLFLOW_TRACKING_URI=http://mlflow:5000` dans l'environnement du service `airflow-scheduler` (ou
`http://host.docker.internal:5000` si le serveur MLflow tourne hors du reseau Compose principal),
et s'assurer que le conteneur Airflow peut joindre le service `mlflow` -- ce qui suppose de les
rapprocher sur le meme reseau Docker ou d'exposer MLflow autrement qu'en `127.0.0.1` uniquement
(cf. point 1 sur l'exposition du port). Non fait a ce jour : aucun besoin de centraliser les runs
d'entrainement Airflow et locaux n'a encore ete identifie.
## Machine cible, exécution Docker
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
@@ -169,6 +213,31 @@ Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
compte sur l'IP du proxy et devient globale.
### Deux environnements sur la même machine
Statut : `En cours`, la machine n'étant pas encore provisionnée. Décision et motifs dans
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
La VM `eadl-2025-nantes-g3` portera la recette et la production, chacune dans son clone du dépôt,
son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et conteneurs : rien
n'est partagé. `scripts/provision-host.sh` prépare les deux dossiers, génère les secrets et les
certificats, et ne démarre rien.
| | Recette | Production |
|---|---|---|
| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` |
| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` |
| Proxy HTTP, HTTPS | `127.0.0.1:8081`, `8443` | `80`, `443` |
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
Les deux noms d'hôte visent la même IP, à déclarer dans le `/etc/hosts` des postes. Deux noms
distincts sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
La redirection HTTP de la recette est ramenée sur la boucle locale parce que la configuration
Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur
la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
## Cible à terme, k3s
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
@@ -227,6 +296,9 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
| Un projet Compose par environnement, sur la même machine | Une seule VM, et l'isolation par nom de projet ne demande ni cluster ni registre | `.env` de chaque dossier, [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) |
| Runner GitHub Actions auto-hébergé sur la VM | Les runners hébergés par GitHub ne joignent pas une adresse privée d'école | `.github/workflows/deploy.yml` |
| Secrets dans le `.env` de chaque environnement, sur la machine | Ni dans git, ni dans GitHub : le runner n'a rien à recevoir | `scripts/provision-host.sh` |
## Ports et noms
@@ -237,12 +309,12 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| API | `8000` | Identique en conteneur et hors conteneur |
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`. 80 ne sert que la redirection et le défi ACME |
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. 80 ne sert que la redirection et le défi ACME. La recette publie `8443` et `127.0.0.1:8081` |
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
| 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
+23
View File
@@ -154,6 +154,29 @@ Compose.
Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md).
## Recommandations
Statut : `Fait`. La vue `/recommendations` (`features/recommendations`, derrière `authGuard`, tous
rôles) présente les recommandations du moteur de règles groupées par alerte, du plus récent au plus
ancien, avec le contexte de l'alerte (sévérité, type, site, horodatage, message) puis chaque action,
son explication et la règle qui l'a produite.
- **Jointure côté client.** Une recommandation ne porte que `alert_id`, jamais `site_id`, et
`GET /recommendations` n'a aucun filtre. `app-recommendation-list` (`shared/components/`) charge
donc en parallèle `GET /alerts` (filtré par `site_id` quand un site est fixé) et
`GET /recommendations`, puis les joint par `alert_id` (`joinByAlert`, fonction pure testée à
part). Les recommandations dont l'alerte n'est pas dans le jeu chargé sont ignorées : c'est ainsi
que le filtre site s'applique. `/alerts` n'étant pas paginé, un seul appel suffit.
- **Paramètres d'URL.** `?site=<site_id>` présélectionne le filtre site ; `?alert=<alert_id>`
réduit la vue à une alerte et la met en évidence (entier strictement positif, sinon ignoré).
- **Génération.** Le bouton « Générer les recommandations » n'apparaît que pour le rôle `admin`
(`POST /recommendations/generate?site_id=`, réservé admin côté API) et affiche le bilan renvoyé
(créées, déjà présentes, alertes examinées) avant de recharger la liste. La voie normale reste le
DAG Airflow `alertes` ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
- **Entrées.** Lien « Recommandations » dans l'en-tête du tableau de bord ; section
« Recommandations » sur la vue détail d'un site (liste restreinte au site, lien vers la vue
complète préfiltrée).
## Questions ouvertes
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
+4
View File
@@ -287,6 +287,10 @@ Chaque table remplit un rôle précis dans le traitement et l'exploitation des d
| `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 |
Le scoring (`ml_score`) charge le modèle depuis un fichier local (`models/lightgbm-consumption.txt`)
et trace son empreinte SHA-256 dans `prediction.model_reference`. Il ne lit aucune version depuis
le Model Registry MLflow (`ml/`) : ce registre sert aujourd'hui à la traçabilité des
entraînements, pas au déploiement du modèle de scoring.
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.
+68 -13
View File
@@ -6,11 +6,15 @@ vérifié, ce qui bloque, et ce qui ne l'est pas.
| Étage | Sert à | Statut |
|---|---|---|
| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` |
| Livraison continue | Porter un artefact vérifié jusqu'à la machine de déploiement | `Cible` |
| Livraison continue | Déployer chaque branche d'intégration sur son environnement de la VM ENI | `En cours` |
Le **D** de CI/CD n'existe pas encore : aucun job de déploiement, aucune construction d'image
publiée, aucun environnement GitHub. L'issue #21 le porte. C'est la limite principale de cet
étage, et elle est nommée ici plutôt que découverte en soutenance.
Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en
production sur la VM de l'école, par un runner auto-hébergé (issue #21,
[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Il n'a encore rien
déployé : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Statut à
basculer sur `Fait` au premier déploiement vert. Sa limite, nommée ici plutôt que découverte en
soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact
n'est publié puis promu d'un environnement à l'autre.
## Vue d'ensemble
@@ -54,7 +58,12 @@ flowchart TB
push --> mv & ms
push --> av & ab
push --> sb1 & sb2 --> sscan
sscan -.-> cd["deploy<br/>issue #21"]
subgraph cd["Déploiement · deploy.yml"]
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
end
push -->|"push sur dev ou main"| dep
```
## Déclenchement
@@ -81,9 +90,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
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
ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image
Docker et son propre environnement.
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14 : c'est l'interpréteur
de l'image `apache/airflow:3.3.2-python3.12` retenue, et les tests d'intégrité doivent tourner sur
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 sixième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
entrant n'est ouvert.
| Événement | Environnement GitHub | Dossier sur la VM | Garde |
|---|---|---|---|
| `push` sur `dev` | `rec` | `/srv/enervision/rec` | aucune : la recette suit `dev` |
| `push` sur `main` | `prod` | `/srv/enervision/prod` | approbation d'un relecteur dans l'environnement `prod`, branche `main` seule autorisée |
Le job aligne le clone sur la branche (`fetch`, `checkout`, `reset --hard`), lance
`make stack-up`, qui reconstruit les images, redémarre les conteneurs puis applique les
migrations Alembic dans le conteneur backend, et attend jusqu'à trois minutes que
`/api/v1/health/ready` réponde derrière le proxy. Cette sonde ne vérifie que la connexion à la
base et la présence de TimescaleDB : sans la migration, le déploiement serait vert sur une base
sans schéma, et c'est pourquoi `make stack-up` la porte. Un groupe de concurrence par branche,
sans annulation, empêche deux déploiements simultanés du même environnement.
Le job ne fait pas de `actions/checkout` dans son espace de travail, et c'est voulu : le dossier
de l'environnement est stable, hors du runner, parce que `.env`, certificats et volumes doivent
survivre d'un déploiement à l'autre.
**Piège à connaître.** Un runner auto-hébergé sur un dépôt public exécute ce qu'un workflow lui
envoie, et une PR de fork peut réécrire un workflow. Trois parades, et les trois sont
nécessaires : `deploy.yml` ne se déclenche jamais sur `pull_request` ; le runner tourne sous un
utilisateur dédié membre du groupe `docker`, jamais root ; le dépôt doit exiger une approbation
pour les workflows des PR externes (Settings, Actions, « Require approval for all outside
collaborators »), ce qui reste à activer. Les workflows de CI restent sur `ubuntu-latest`.
Cet utilisateur dédié doit posséder `/srv/enervision` : sinon git refuse les deux clones pour
propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=<utilisateur du runner>`
passé à `scripts/provision-host.sh` fixe ce propriétaire.
La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou
plus, clone les deux branches, génère les secrets de chaque `.env` et les certificats
auto-signés, et ne démarre rien. Le détail des deux environnements, ports et noms d'hôte, est
dans [10-infra.md](10-infra.md).
## Ce qui bloque un merge
@@ -180,18 +230,23 @@ les tests ne se merge pas.
## Secrets
Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions.
Un seul secret est consommé côté GitHub : **`SONAR_TOKEN`**, porté par les secrets du dépôt.
Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le
workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le
run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement : le job de
déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par
l'issue #22.
run.
Le déploiement ne consomme **aucun secret GitHub** (issue #22). Les secrets de chaque
environnement, mots de passe PostgreSQL et Airflow, clés de signature, clé Fernet, vivent dans le
`.env` de son dossier sur la VM, en `600`, générés sur la machine par `scripts/provision-host.sh`.
Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce dossier, n'a rien
à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se
régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow.
## Ce qui manque, et pourquoi
| Manque | Issue | Conséquence assumée |
|---|---|---|
| Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine |
| Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit |
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
| Tests de charge | #47 | Aucun garde-fou de performance |
+10 -2
View File
@@ -663,8 +663,16 @@ mock_api_import.py
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline ML (`ml_train`/`ml_score`, issue #115) ainsi que la détection d'alertes et la génération des recommandations (`alertes`, issue #116). Il n'orchestre pas encore ces deux imports : `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) restent à faire.
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline
ML (`ml_train`/`ml_score`, issue #115), la détection d'alertes et la génération des
recommandations (`alertes`, issue #116), ainsi que l'import historique
(`historical_import`, issue #119).
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
Le DAG `historical_import` est déclenché manuellement. Il exécute
`app.etl.historical_import` avec les fichiers montés en lecture seule depuis `data/raw` vers
`/opt/data/raw`. L'orchestration de l'import API Mock et la réconciliation globale des deux
sources restent couvertes par l'issue #15.
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` et `historical_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
+5 -6
View File
@@ -1,9 +1,9 @@
# 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
# pas 3.14), pour que les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`,
# 3.14, distincts du Python 3.12 de l'image de base qui fait tourner Airflow lui-meme, pour que
# 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
# 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
# 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
USER airflow
# L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de
# `ml/uv.lock`. On le remplace par la version deja pinnee ailleurs dans le depot
# (apps/backend/Dockerfile).
# L'image de base embarque deja un `uv`, mais pas celui que le depot epingle par ailleurs
# (apps/backend/Dockerfile) : on aligne, pour que le format de verrou lu soit le meme partout.
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`
+2 -2
View File
@@ -15,8 +15,8 @@ from __future__ import annotations
from datetime import datetime, timedelta
from airflow.models.dag import DAG
from airflow.operators.bash import BashOperator
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.
+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 airflow.models.dag import DAG
from airflow.operators.bash import BashOperator
from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG
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 airflow.models.dag import DAG
from airflow.operators.bash import BashOperator
from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
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)"
requires-python = ">=3.12,<3.13"
dependencies = [
"apache-airflow==2.10.4",
"apache-airflow==3.3.2",
]
[dependency-groups]
+34 -8
View File
@@ -5,23 +5,24 @@ from datetime import timedelta
from pathlib import Path
import pytest
from airflow.models.baseoperator import BaseOperator
from airflow.models.dagbag import DagBag
from airflow.dag_processing.dagbag import DagBag
from airflow.sdk import BaseOperator
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
DAG_IDS = ["ml_train", "ml_score", "alertes"]
DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import"]
TACHES = [
("ml_train", "train"),
("ml_score", "score"),
("alertes", "detection"),
("alertes", "recommandations"),
("historical_import", "import_historical"),
]
@pytest.fixture(scope="module")
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:
@@ -33,18 +34,22 @@ def test_every_expected_dag_is_discovered(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:
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend.
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *"
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que la timetable le rend.
assert dagbag.dags["ml_score"].timetable.expression == "0 * * * *"
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`
# 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:
@@ -67,12 +72,29 @@ def test_alertes_recommendation_task_calls_the_backend_cli(dagbag: DagBag) -> No
assert "app.cli generate-recommendations" in tache.bash_command
def test_historical_import_calls_the_existing_backend_module(dagbag: DagBag) -> None:
tache = dagbag.dags["historical_import"].get_task("import_historical")
assert "app.etl.historical_import" in tache.bash_command
def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> None:
commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command
assert "--csv /opt/data/raw/all_sites_combined.csv" in commande
assert "--metadata /opt/data/raw/dataset_metadata.json" in commande
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None:
# Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008).
assert "/opt/backend" in dagbag.dags["alertes"].get_task(task_id).bash_command
def test_historical_import_runs_in_the_backend_environment(dagbag: DagBag) -> None:
commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command
assert "/opt/backend" in commande
def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None:
# `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire
# tant que la detection n'a pas ecrit.
@@ -133,6 +155,10 @@ def test_alertes_retries_after_a_transient_failure(dagbag: DagBag, task_id: str)
assert dagbag.dags["alertes"].get_task(task_id).retries >= 1
def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) -> None:
assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
def test_tasks_never_resync_the_baked_environment(
dagbag: DagBag, dag_id: str, task_id: str
+554 -653
View File
File diff suppressed because it is too large Load Diff
+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.
- `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`.
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
Les ports publiés sont `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`, 80 et 443 par défaut. Quand deux
environnements partagent la machine ([ADR 0009](../../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md)),
la recette publie `8443` et ramène son port 80 sur `127.0.0.1:8081` : la redirection ci-dessous
renvoie vers `https://$host` sans port, donc vers la production. `PUBLIC_ORIGIN` porte alors
l'origine avec son port pour le CORS et le lien de réinitialisation.
## Routage
| Chemin | Destination | Remarque |
@@ -82,7 +88,7 @@ docker run --rm \
-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/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.
+6
View File
@@ -0,0 +1,6 @@
.venv
data
mlruns
mlflow.db*
models
.env
+1
View File
@@ -0,0 +1 @@
MLFLOW_DB_PASSWORD=change-me
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.14-slim
RUN pip install --no-cache-dir --only-binary :all: mlflow==3.16.1 psycopg2-binary==2.9.13
RUN useradd --create-home --uid 1000 mlflow \
&& mkdir /mlartifacts \
&& chown mlflow /mlartifacts
USER mlflow
EXPOSE 5000
+51
View File
@@ -57,6 +57,50 @@ validation. La coupure est **chronologique**, jamais un tirage aleatoire de lign
aleatoire laisserait des lignes de validation "voir" des lignes d'entrainement via leurs
lags/moyennes glissantes, une fuite qui masquerait un surapprentissage.
## Serveur MLflow (conteneur)
Premiere utilisation : copier `.env.example` en `.env` et y choisir un mot de passe PostgreSQL
(lettres et chiffres uniquement). Le fichier `.env` est ignore par git.
```bash
cp .env.example .env
```
Un serveur MLflow (PostgreSQL pour les metadonnees, volume pour les artefacts) se lance avec
Docker. Prerequis : Docker Desktop demarre.
```bash
make mlflow-up
```
La cible vérifie que `MLFLOW_DB_PASSWORD` (définie dans `ml/.env`) ne contient que des lettres et
des chiffres avant de démarrer le serveur : ce mot de passe est interpolé directement dans l'URI
PostgreSQL (`postgresql://mlflow:${MLFLOW_DB_PASSWORD}@...`), un caractère spécial la rendrait
invalide sans message d'erreur clair.
Interface : http://localhost:5000. Entrainer vers ce serveur :
```
uv run python -m enervision_ml.train --csv data/all_sites_combined.csv --mlflow-tracking-uri http://localhost:5000
```
Arreter : `docker compose -f docker-compose.mlflow.yml down` (ajouter `-v` pour effacer aussi les
runs et les modeles).
Pour voir les runs dans l'interface (MLflow 3.x) :
- Passer le selecteur en haut a gauche sur **Model training**. Le mode **GenAI** affiche des
traces LLM et reste vide pour un entrainement LightGBM.
- **Runs** liste les entrainements, **Models** les artefacts de modele de chaque run (tous nommes
`model`), et **Model registry** les versions numerotees de `consumption-forecast-lightgbm`.
Limites : l'identifiant PostgreSQL du compose est fixe a `mlflow`, le mot de passe vient de la
variable obligatoire `MLFLOW_DB_PASSWORD` (aucune valeur par defaut, le compose refuse de
demarrer sans elle) -- ce mot de passe est choisi lors de la copie de `.env.example`, il ne
convient donc qu'au developpement local tel quel. Un deploiement partage demandera des secrets,
de l'authentification et un stockage d'artefacts dedie (S3/MinIO). Le port 5000 doit etre libre : arreter `mlflow ui` avant,
ou changer le mapping (`"5001:5000"`) dans le compose.
## Scoring
```bash
@@ -83,6 +127,13 @@ section 2 :
fichier : `train.py` reecrit toujours le meme chemin a chaque entrainement, donc le nom seul ne
distinguerait pas deux versions du modele.
**Le scoring ne lit pas le Model Registry.** Le fichier charge par `--model` est local
(`models/lightgbm-consumption.txt`), independant des versions enregistrees dans le
**Model registry** MLflow (`consumption-forecast-lightgbm`). `train.py` enregistre bien une
version a chaque entrainement (tracabilite), mais aucun alias (`champion` par exemple) n'est
pose, et `enervision_ml.score` ne les lit pas. Le registre sert aujourd'hui a la tracabilite des
entrainements, pas au deploiement du modele utilise en scoring.
En mode `--csv`, rien n'est ecrit en base : c'est un instantane historique fige (l'heure "future"
calculee a partir de la fin du CSV n'existe dans aucune base reelle), utile pour valider le
pipeline sans base joignable.
+32
View File
@@ -0,0 +1,32 @@
services:
mlflow-db:
image: postgres:17
environment:
POSTGRES_USER: mlflow
POSTGRES_PASSWORD: ${MLFLOW_DB_PASSWORD:?definir MLFLOW_DB_PASSWORD dans ml/.env}
POSTGRES_DB: mlflow
volumes:
- mlflow-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mlflow"]
interval: 5s
retries: 10
mlflow:
build: .
depends_on:
mlflow-db:
condition: service_healthy
ports:
- "127.0.0.1:5000:5000"
volumes:
- mlflow-artifacts:/mlartifacts
environment:
MLFLOW_DB_PASSWORD: ${MLFLOW_DB_PASSWORD}
entrypoint: [ "/bin/sh", "-c" ]
command:
- exec mlflow server --host 0.0.0.0 --port 5000 --backend-store-uri "postgresql://mlflow:$$MLFLOW_DB_PASSWORD@mlflow-db:5432/mlflow" --artifacts-destination /mlartifacts --serve-artifacts
volumes:
mlflow-db-data:
mlflow-artifacts:
+5 -1
View File
@@ -181,7 +181,11 @@ def _log_to_mlflow(
)
mlflow.log_metrics({f"model_{cle}": valeur for cle, valeur in model_metrics.items()})
mlflow.log_metrics({f"baseline_{cle}": valeur for cle, valeur in baseline_metrics.items()})
mlflow.lightgbm.log_model(booster, name="model")
mlflow.lightgbm.log_model(
booster,
name="model",
registered_model_name="consumption-forecast-lightgbm",
)
mlflow.log_artifact(str(model_output))
+17
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import numpy as np
import pandas as pd
import pytest
from enervision_ml.features import TARGET_COLUMN, build_features, feature_columns
from enervision_ml.train import chronological_split, prepare_dataset, train
@@ -74,3 +75,19 @@ def test_train_runs_end_to_end_on_synthetic_data_and_beats_a_dummy_baseline(
assert model_metrics["n_observations"] > 0
assert model_metrics["mae"] >= 0
assert baseline_metrics["n_observations"] == model_metrics["n_observations"]
assert model_metrics["mae"] < baseline_metrics["mae"]
def test_train_raises_when_the_validation_window_is_empty(tmp_path: Path) -> None:
depart = datetime(2026, 1, 1, tzinfo=UTC)
frame = make_frame("site-a", heures=50, depart=depart) # trop court pour un lag de 168h
csv_path = tmp_path / "trop_court.csv"
frame.to_csv(csv_path, index=False)
with pytest.raises(ValueError, match="Fenetre d'entrainement ou de validation vide"):
train(
csv_path=csv_path,
model_output=tmp_path / "model.txt",
test_fraction=0.2,
tracking_uri=f"sqlite:///{tmp_path / 'mlflow.db'}",
)
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Pourquoi : la machine porte deux environnements, chacun un clone du dépôt, un `.env` et un
# projet Compose (ADR 0009). Ce script prépare la machine et les deux dossiers sans rien
# démarrer : construction des images et démarrage restent à l'opérateur, puis au runner GitHub.
# Rejouable : un dossier déjà cloné est réaligné sur sa branche, un `.env` existant n'est jamais
# réécrit, un certificat présent n'est jamais régénéré.
set -euo pipefail
DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}"
RACINE="${RACINE:-/srv/enervision}"
ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}"
PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}"
COMPOSE_MINIMALE="2.24.4"
erreur() { echo "erreur : $*" >&2; exit 1; }
secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; }
# Clé Fernet : 32 octets en base64 urlsafe, padding compris.
fernet() { openssl rand -base64 32 | tr '+/' '-_'; }
verifier_outils() {
for outil in git make openssl curl; do
command -v "$outil" >/dev/null || erreur "$outil absent (apt-get install $outil)"
done
command -v docker >/dev/null || erreur "Docker absent : https://docs.docker.com/engine/install/debian/"
docker info >/dev/null 2>&1 || erreur "le démon Docker ne répond pas, ou l'utilisateur n'est pas dans le groupe docker"
local version
version="$(docker compose version --short 2>/dev/null || true)"
[[ -n "$version" ]] || erreur "plugin docker compose absent (paquet docker-compose-plugin)"
[[ "$(printf '%s\n%s\n' "$COMPOSE_MINIMALE" "${version#v}" | sort -V | head -1)" == "$COMPOSE_MINIMALE" ]] \
|| erreur "docker compose $version trop ancien : $COMPOSE_MINIMALE requis pour !override et !reset"
curl -fsSI --max-time 10 https://github.com >/dev/null || erreur "pas de sortie HTTPS vers github.com"
echo "docker compose $version, sortie Internet : ok"
}
preparer() {
local env="$1" branche="$2" hote="$3" origine="$4"
local port_https="$5" port_http="$6" port_pg="$7" port_mailpit="$8" port_airflow="$9"
local dossier="$RACINE/$env"
if [[ -d "$dossier/.git" ]]; then
git -C "$dossier" fetch --quiet origin "$branche"
git -C "$dossier" checkout --quiet "$branche"
git -C "$dossier" reset --quiet --hard "origin/$branche"
else
git clone --quiet --branch "$branche" "$DEPOT" "$dossier"
fi
if [[ ! -f "$dossier/.env" ]]; then
sed -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|" \
-e "s|^POSTGRES_PORT=.*|POSTGRES_PORT=$port_pg|" \
-e "s|^APP_SECRET_KEY=.*|APP_SECRET_KEY=$(secret)|" \
-e "s|^MAILPIT_UI_PORT=.*|MAILPIT_UI_PORT=$port_mailpit|" \
-e "s|^AIRFLOW_PORT=.*|AIRFLOW_PORT=$port_airflow|" \
-e "s|^AIRFLOW_FERNET_KEY=.*|AIRFLOW_FERNET_KEY=$(fernet)|" \
-e "s|^AIRFLOW_WEBSERVER_SECRET_KEY=.*|AIRFLOW_WEBSERVER_SECRET_KEY=$(secret)|" \
-e "s|^AIRFLOW_ADMIN_PASSWORD=.*|AIRFLOW_ADMIN_PASSWORD=$(secret | cut -c1-20)|" \
-e "s|^AIRFLOW_APP_SECRET_KEY=.*|AIRFLOW_APP_SECRET_KEY=$(secret)|" \
-e "s|^PUBLIC_HOST=.*|PUBLIC_HOST=$hote|" \
-e "s|^PUBLIC_ORIGIN=.*|PUBLIC_ORIGIN=$origine|" \
-e "s|^COMPOSE_PROJECT_NAME=.*|COMPOSE_PROJECT_NAME=enervision-$env|" \
-e "s|^PROXY_HTTP_PORT=.*|PROXY_HTTP_PORT=$port_http|" \
-e "s|^PROXY_HTTPS_PORT=.*|PROXY_HTTPS_PORT=$port_https|" \
"$dossier/.env.example" > "$dossier/.env"
# Branche antérieure à l'ADR 0009 : ces clés manquent alors dans .env.example.
for cle in "COMPOSE_PROJECT_NAME=enervision-$env" "PUBLIC_ORIGIN=$origine" \
"PROXY_HTTP_PORT=$port_http" "PROXY_HTTPS_PORT=$port_https"; do
grep -q "^${cle%%=*}=" "$dossier/.env" || echo "$cle" >> "$dossier/.env"
done
chmod 600 "$dossier/.env"
echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD."
fi
if [[ ! -f "$dossier/infra/proxy/tls/fullchain.pem" ]]; then
(cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh)
fi
echo "$env : $dossier sur $branche, $origine"
}
verifier_outils
mkdir -p "$RACINE"
# env branche hôte origine https http pg mailpit airflow
preparer prod main enervision.local https://enervision.local 443 80 5433 8025 8080
preparer rec dev rec.enervision.local https://rec.enervision.local:8443 8443 127.0.0.1:8081 5434 8026 8082
if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then
chown -R "$PROPRIETAIRE" "$RACINE"
fi
cat <<FIN
Démarrage, dans chaque dossier : make stack-up, qui applique aussi les migrations.
Premier administrateur, stack démarrée, dans chaque dossier :
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \\
python -m app.cli create-admin --email <adresse>
Le runner GitHub Actions (label eni-g3) rejouera le déploiement à chaque push sur dev et main.
L'installer sous le propriétaire de $RACINE, sinon git refuse ces dépôts et le .env en 600 lui
échappe : relancer au besoin ce script avec PROPRIETAIRE=<utilisateur du runner>.
Données historiques : git ne porte pas data/raw, déposer les fichiers dans chaque dossier avant
de déclencher le DAG historical_import.
Depuis un poste : ajouter « $ADRESSE enervision.local rec.enervision.local » à /etc/hosts.
FIN