Compare commits

..
Author SHA1 Message Date
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
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
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
Johan LEROY 83caa9006e chore(frontend): active strict, ajoute .form-select et documente le spec ciblé
Frontend / Audit des dépendances (push) Successful in 5s
SonarQube / build-back (push) Successful in 1m6s
Frontend / build (push) Successful in 9m47s
SonarQube / test-ml (push) Failing after 2m2s
SonarQube / build-front (push) Successful in 10m5s
SonarQube / test-back (push) Failing after 49s
Frontend / test (push) Failing after 5m5s
SonarQube / test-front (push) Failing after 5m3s
SonarQube / SonarQube (push) Skipped
- tsconfig.json : "strict": true, vérifié sans erreur sur app et specs
- _forms.scss : .form-select, select natif habillé comme .form-input avec un
  chevron, documenté dans le design système
- TESTING.md : commande pour jouer un seul fichier ou dossier de specs
2026-09-21 14:25:30 +02:00
Johan LEROY 91752a2b0b fix(frontend): aligne le modèle Alert et AlertsService sur le contrat /alerts
Le modèle front portait un alert_id texte, des value/threshold non nullables et
ignorait metric et prediction_id, alors que l'API sérialise un entier, des
flottants nullables et ces deux champs. Toute comparaison avec l'alert_id d'une
recommandation échouait silencieusement.

- alert.model.ts : alert_id number, value/threshold/metric/prediction_id nullables
- alerts.service.ts : getAlerts(filters) pose site_id et severity en HttpParams,
  les deux filtres que l'API accepte
- alerts.fixture.ts : réaligné sur le contrat (ids entiers, horodatages UTC,
  champs nuls sur outage/sensor, un cas spike)
- alert-presentation.ts : tons, libellés et unités partagés ; low passe en
  neutre, le vert se lisait comme un état sain
2026-09-21 14:25:30 +02:00
53 changed files with 2216 additions and 809 deletions
+3 -6
View File
@@ -29,18 +29,15 @@ APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10
# Airflow (api-server + scheduler + dag-processor, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
# Airflow (webserver + scheduler, 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é de session de l'api-server Airflow, distincte de la précédente. Générer la
# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
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_WEBSERVER_SECRET_KEY=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.
+5 -6
View File
@@ -1,9 +1,8 @@
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) : 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).
# (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).
#
# 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
@@ -48,10 +47,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: etl/airflow/uv.lock
@@ -79,7 +78,7 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
+8 -8
View File
@@ -30,10 +30,10 @@ jobs:
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
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@v7
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
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@v7
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
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@v7
uses: actions/checkout@v4
# 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@v7
uses: astral-sh/setup-uv@v5
# 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.
+10 -10
View File
@@ -17,10 +17,10 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
node-version: 24
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@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
node-version: 24
# 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@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
node-version: 24
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@v7
uses: actions/upload-artifact@v4
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@v7
uses: actions/checkout@v4
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
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@v7
uses: actions/checkout@v4
# 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@v7
uses: astral-sh/setup-uv@v5
- name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
+19 -22
View File
@@ -24,10 +24,10 @@ jobs:
build-front:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
node-version: 24
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@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 26
node-version: 24
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@v7
uses: actions/upload-artifact@v4
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@v7
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
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@v7
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
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@v7
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: apps/backend/coverage.xml
@@ -115,9 +115,9 @@ jobs:
test-ml:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
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@v7
uses: actions/upload-artifact@v4
with:
name: ml-coverage
path: ml/coverage.xml
@@ -143,26 +143,23 @@ 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@v7
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: frontend-coverage
path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: backend-coverage
path: apps/backend
- name: Téléchargement du rapport de couverture (ML)
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: ml-coverage
path: ml
+4 -4
View File
@@ -108,12 +108,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 (api-server + scheduler + dag-processor, LocalExecutor). db-up requis avant.
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
docker compose up -d airflow-init airflow-webserver airflow-scheduler
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
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-down: ## Arrête le webserver et le scheduler Airflow
docker compose stop airflow-webserver airflow-scheduler
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
docker compose logs -f airflow-scheduler
+7 -9
View File
@@ -9,10 +9,8 @@ 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 | Ingestion & backend |
| J4 | Architecture, sécurité & frontend |
| J5 | Valider la robustesse et assurer les livrables |
| J6 | Amélioration possible |
| J3 | Valider l'architecture et la gestion de la sécurité |
| J4 | Valider la robustesse et assurer les livrables |
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
@@ -20,15 +18,15 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Domaine | Technologie | Emplacement | Etat |
|------------|-------------------------------------|---------------------|---------------|
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | En place |
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
| 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 |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
| ML | LightGBM, MLflow | `ml` | En place |
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
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
+37 -37
View File
@@ -655,31 +655,31 @@ wheels = [
[[package]]
name = "pandas"
version = "3.0.6"
version = "3.0.5"
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/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -936,27 +936,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.16.8"
version = "0.16.7"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
+1 -1
View File
@@ -3,7 +3,7 @@
# ==================
# Image pour frontend
FROM node:26-alpine3.22 AS builder
FROM node:24-alpine3.22 AS builder
WORKDIR /app
+4 -1
View File
@@ -1,4 +1,4 @@
# Conventions de tests unitaires — Frontend
# Conventions de tests unitaires : Frontend
## Outil
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
@@ -83,3 +83,6 @@ describe('MonComposant', () => {
## Lancer les tests
- Développement (mode watch) : `npm test`
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
- Un fichier ou un dossier seulement :
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
+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],
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
export const ALERTS_FIXTURE: Alert[] = [
{
alert_id: 'ALR-SITE002-1718458320',
timestamp: '2026-09-15T11:12:00',
alert_id: 5,
site_id: 'SITE002',
timestamp: '2026-09-15T11:12:00Z',
type: 'threshold',
severity: 'critical',
type: 'outage',
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 'ALR-SITE003-1718458321',
timestamp: '2026-09-15T11:05:00',
alert_id: 4,
site_id: 'SITE003',
timestamp: '2026-09-15T11:05:00Z',
type: 'outage',
severity: 'critical',
type: 'sensor',
message: 'Perte réseau totale sur Data Center Marseille',
value: 0,
threshold: 0,
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
value: null,
threshold: null,
metric: null,
prediction_id: null,
},
{
alert_id: 'ALR-SITE005-1718458322',
timestamp: '2026-09-15T10:47:00',
alert_id: 3,
site_id: 'SITE005',
timestamp: '2026-09-15T10:47:00Z',
type: 'spike',
severity: 'high',
type: 'threshold',
message: 'Usine Toulouse approche de son seuil de capacité',
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
value: 410.0,
threshold: 480.0,
threshold: 260.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 'ALR-SITE006-1718458323',
timestamp: '2026-09-15T10:30:00',
alert_id: 2,
site_id: 'SITE006',
severity: 'medium',
timestamp: '2026-09-15T10:30:00Z',
type: 'sensor',
message: 'Capteur de température défaillant sur Bureau Lille',
value: 0,
threshold: 0,
severity: 'medium',
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
value: null,
threshold: null,
metric: null,
prediction_id: null,
},
{
alert_id: 'ALR-SITE004-1718458324',
timestamp: '2026-09-15T09:58:00',
alert_id: 1,
site_id: 'SITE004',
severity: 'low',
timestamp: '2026-09-15T09:58:00Z',
type: 'anomaly',
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
severity: 'low',
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
value: 62.0,
threshold: 55.0,
metric: 'consumption_kwh',
prediction_id: 42,
},
];
@@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AlertsService } from './alerts.service';
import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model';
const ALERT_API: Alert = {
alert_id: 1,
site_id: 'site-1',
timestamp: '2026-09-16T00:00:00Z',
type: 'threshold',
severity: 'high',
message: 'Dépassement du seuil configuré',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
};
describe('AlertsService', () => {
let service: AlertsService;
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
afterEach(() => httpMock.verify());
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
let result: unknown;
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
let result: Alert[] = [];
service.getAlerts().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
expect(req.request.method).toBe('GET');
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
);
expect(req.request.params.keys()).toEqual([]);
req.flush([ALERT_API]);
req.flush([
{
alert_id: 'ALR-TEST-1',
timestamp: '2026-09-15T12:00:00',
site_id: 'SITE001',
severity: 'high',
type: 'threshold',
message: 'Test',
value: 100,
threshold: 90,
},
]);
expect(result.length).toBe(1);
expect(result[0].alert_id).toBe(1);
expect(result[0].prediction_id).toBeNull();
});
expect((result as unknown[]).length).toBe(1);
it('transmet les filtres site_id et severity en paramètres de requête', () => {
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.params.get('severity')).toBe('high');
req.flush([]);
});
it('ne pose pas de paramètre pour un filtre omis', () => {
service.getAlerts({ site_id: 'SITE001' }).subscribe();
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
expect(req.request.params.has('severity')).toBe(false);
req.flush([]);
});
});
@@ -1,13 +1,25 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
export interface AlertFilters {
site_id?: string;
severity?: AlertSeverity;
}
@Service()
export class AlertsService {
private http = inject(HttpClient);
getAlerts() {
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
getAlerts(filters: AlertFilters = {}) {
let params = new HttpParams();
if (filters.site_id) {
params = params.set('site_id', filters.site_id);
}
if (filters.severity) {
params = params.set('severity', filters.severity);
}
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
}
}
@@ -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,37 @@
import {
LIBELLE_PAR_SEVERITE,
LIBELLE_PAR_TYPE,
SEVERITES,
TON_PAR_SEVERITE,
TYPES_ALERTE,
UNITE_PAR_METRIQUE,
} from './alert-presentation';
describe('alert-presentation', () => {
it('distingue le ton des sévérités high et critical', () => {
expect(TON_PAR_SEVERITE.high).toBe('danger');
expect(TON_PAR_SEVERITE.critical).toBe('critical');
expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical);
});
it("n'affiche pas une alerte faible avec le ton de succès", () => {
expect(TON_PAR_SEVERITE.low).toBe('neutral');
expect(TON_PAR_SEVERITE.medium).toBe('warning');
});
it('donne un libellé français à chaque sévérité et à chaque type', () => {
for (const severite of SEVERITES) {
expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy();
}
for (const type of TYPES_ALERTE) {
expect(LIBELLE_PAR_TYPE[type]).toBeTruthy();
}
expect(SEVERITES.length).toBe(4);
expect(TYPES_ALERTE.length).toBe(5);
});
it('associe une unité à chaque métrique du contrat', () => {
expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW');
expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh');
});
});
@@ -0,0 +1,41 @@
import { BadgeTone } from '../components/ui/badge/badge';
import { AlertMetric, AlertSeverity, AlertType } from './alert.model';
// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se
// lisait comme « tout va bien » à côté des rouges.
export const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
low: 'neutral',
medium: 'warning',
high: 'danger',
critical: 'critical',
};
export const LIBELLE_PAR_SEVERITE: Record<AlertSeverity, string> = {
low: 'Faible',
medium: 'Moyenne',
high: 'Élevée',
critical: 'Critique',
};
export const LIBELLE_PAR_TYPE: Record<AlertType, string> = {
spike: 'Pic de consommation',
threshold: 'Seuil dépassé',
anomaly: 'Anomalie',
outage: 'Coupure',
sensor: 'Capteur',
};
export const UNITE_PAR_METRIQUE: Record<AlertMetric, string> = {
consumption_kw: 'kW',
consumption_kwh: 'kWh',
};
export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical'];
export const TYPES_ALERTE: readonly AlertType[] = [
'spike',
'threshold',
'anomaly',
'outage',
'sensor',
];
@@ -1,13 +1,16 @@
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
export interface Alert {
alert_id: string;
timestamp: string;
alert_id: number;
site_id: string;
severity: AlertSeverity;
timestamp: string;
type: AlertType;
severity: AlertSeverity;
message: string;
value: number;
threshold: number;
value: number | null;
threshold: number | null;
metric: AlertMetric | null;
prediction_id: number | null;
}
@@ -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;
}
+15
View File
@@ -29,3 +29,18 @@
color: var(--color-disabled);
margin-top: 0.25rem;
}
// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur
// reprend en dur la valeur de --color-text-muted.
.form-select {
@extend .form-input;
padding-right: 2.25rem;
color: var(--color-text);
background-color: var(--color-surface);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
background-size: 1rem;
appearance: none;
cursor: pointer;
}
+1
View File
@@ -3,6 +3,7 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
+1 -1
View File
@@ -1,4 +1,4 @@
-- Base de metadonnees Airflow (api-server + scheduler + dag-processor, LocalExecutor). Separee de la base
-- Base de metadonnees Airflow (webserver + scheduler, 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).
+2 -2
View File
@@ -17,7 +17,7 @@ services:
ports: !override
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
airflow-apiserver:
airflow-webserver:
ports: !override
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
@@ -44,7 +44,7 @@ services:
ports: !reset null
proxy:
image: nginx:1.31-alpine
image: nginx:1.28-alpine
depends_on:
backend:
condition: service_healthy
+13 -31
View File
@@ -5,9 +5,9 @@
name: enervision
# 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
# 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
# dernier puisse au besoin l'inspecter sans en devenir dependant.
x-airflow-common: &airflow-common
build:
@@ -19,16 +19,9 @@ 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 `api-server`, `dag-processor` et `scheduler` dependent.
# `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent.
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_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__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-}
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.
@@ -117,11 +110,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 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`).
# `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`).
# Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous
# refuse en plus les cles et secrets vides.
# refuse en plus les deux cles de chiffrement vides.
airflow-init:
<<: *airflow-common
restart: "no"
@@ -141,14 +134,13 @@ services:
- |
set -euo pipefail
: "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_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}"
: "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}"
: "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}"
exec airflow version
airflow-apiserver:
airflow-webserver:
<<: *airflow-common
command: api-server
command: webserver
ports:
- "${AIRFLOW_PORT:-8080}:8080"
depends_on:
@@ -157,7 +149,7 @@ services:
airflow-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/api/v2/monitor/health"]
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
@@ -172,16 +164,6 @@ 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:
+8 -16
View File
@@ -48,28 +48,20 @@ 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 de l'api-server :
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver :
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
### Airflow (issues #115 et #116)
Quatre services (Airflow 3.3), `docker compose profiles` non utilisés (démarrage explicite via `make
Trois services, `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. `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-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-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` à
@@ -103,14 +95,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
`api-server`, `dag-processor` et `scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
`webserver`/`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é de session de l'API, secret JWT, mot de
passe ou `AIRFLOW_APP_SECRET_KEY` vides).
`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, 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
@@ -250,7 +242,7 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| 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` |
| 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 |
| 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 |
## Le trou vers k3s
+23
View File
@@ -146,6 +146,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
@@ -24,12 +24,13 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
| `--shadow-card` | Ombre portée des cartes |
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
`<ev-alert severity="danger">`, pas une classe dédiée.
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`)
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>`/`<select>` natifs, liés par `formControlName` ou
par un simple `(change)` : pas de composant `ControlValueAccessor` dédié, le gain n'en vaut pas la
complexité pour des formulaires aussi simples que ceux de ce projet. `.form-select` habille un
`<select>` natif avec la bordure et le focus de `.form-input`, plus un chevron. Les erreurs de
formulaire, elles, s'affichent via `<ev-alert severity="danger">`, pas une classe dédiée.
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
+3 -4
View File
@@ -81,10 +81,9 @@ 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 : 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.
**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.
## Ce qui bloque un merge
+6 -5
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 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`,
# 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`,
# `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:3.3.2-python3.12
FROM apache/airflow:2.10.4-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,8 +21,9 @@ 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 pas celui que le depot epingle par ailleurs
# (apps/backend/Dockerfile) : on aligne, pour que le format de verrou lu soit le meme partout.
# 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).
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.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG
from airflow.models.dag import DAG
from airflow.operators.bash import BashOperator
# 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.
+2 -2
View File
@@ -10,8 +10,8 @@ from __future__ import annotations
from datetime import datetime, timedelta
from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG
from airflow.models.dag import DAG
from airflow.operators.bash import BashOperator
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.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG
from airflow.models.dag import DAG
from airflow.operators.bash import BashOperator
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==3.3.2",
"apache-airflow==2.10.4",
]
[dependency-groups]
+7 -7
View File
@@ -5,8 +5,8 @@ from datetime import timedelta
from pathlib import Path
import pytest
from airflow.dag_processing.dagbag import DagBag
from airflow.sdk import BaseOperator
from airflow.models.baseoperator import BaseOperator
from airflow.models.dagbag import DagBag
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
@@ -21,7 +21,7 @@ TACHES = [
@pytest.fixture(scope="module")
def dagbag() -> DagBag:
return DagBag(dag_folder=str(DAGS_FOLDER))
return DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None:
@@ -33,18 +33,18 @@ 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"].schedule is None
assert dagbag.dags["ml_train"].timetable.summary == "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 la timetable le rend.
assert dagbag.dags["ml_score"].timetable.expression == "0 * * * *"
# `@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 * * * *"
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.expression == "15 * * * *"
assert dagbag.dags["alertes"].timetable.summary == "15 * * * *"
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
+659 -560
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -9,7 +9,7 @@ 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.31-alpine` est utilisée telle quelle et la
Pas de `Dockerfile` : l'image officielle `nginx:1.28-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
@@ -82,7 +82,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.31-alpine nginx -t
nginx:1.28-alpine nginx -t
```
Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image.