Compare commits

..
Author SHA1 Message Date
Johan LEROY 3d6d2eb760 style(infra): reflow un commentaire du compose Airflow à 100 colonnes 2026-09-22 09:01:07 +02:00
Johan LEROY 154deca511 docs(architecture): décrit les composants Airflow 3
Quatre services au lieu de trois (api-server, dag-processor), le secret JWT
partagé et le choix du FabAuthManager. Le Python 3.12 d'etl/airflow est celui
de l'image retenue, plus une limite d'Airflow.
2026-09-22 09:01:07 +02:00
Johan LEROY a577356198 feat(infra): fait tourner Airflow 3 dans la stack Compose
Image apache/airflow:3.3.2-python3.12. Le webserver devient l'api-server
(healthcheck /api/v2/monitor/health) et le dag-processor est un service à
part : depuis Airflow 3 le scheduler ne parse plus les DAGs.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 06:33:45 +00:00
Johan LEROYandGitHub 095cb4559b Merge pull request #142 from ineszang/chore/sonar-skip-dependabot
chore(ci): ignore le scan SonarQube pour les PR Dependabot
2026-09-22 08:31:48 +02:00
Johan LEROY 401d5a3174 chore(ci): ignore le scan SonarQube pour les PR Dependabot
SonarQube / build-back (push) Successful in 1m20s
SonarQube / test-ml (push) Failing after 1m11s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 59s
SonarQube / test-front (push) Failing after 5m8s
SonarQube / SonarQube (push) Skipped
GitHub ne fournit pas les secrets du dépôt aux workflows déclenchés par
dependabot[bot] : SONAR_TOKEN arrive vide et le scan échoue sans rien
analyser, ce qui marque rouge toutes les PR de mise à jour de dépendances.
Les jobs de build et de tests du workflow restent joués sur ces PR.
2026-09-22 08:28:03 +02:00
PhyriosandGitHub cf50d3d36c Revise project milestones in README
Updated project milestones in the README to reflect new tasks and structure.
2026-09-21 14:42:41 +02:00
PhyriosandGitHub 4afcfb3ced Update project status in README.md 2026-09-21 14:28:56 +02:00
dependabot[bot]andGitHub 851e0279f6 chore(deps): bump the backend-dependencies group
Bumps the backend-dependencies group in /apps/backend with 2 updates: [pandas](https://github.com/pandas-dev/pandas) and [ruff](https://github.com/astral-sh/ruff).


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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:17:47 +00:00
PhyriosandGitHub 1cd206adc8 Merge pull request #125 from ineszang/dev
Push dev to main
2026-09-21 14:17:01 +02:00
52 changed files with 1220 additions and 2243 deletions
+6 -3
View File
@@ -29,15 +29,18 @@ APP_MOCK_API_USERNAME=change_me
APP_MOCK_API_PASSWORD=change_me APP_MOCK_API_PASSWORD=change_me
APP_MOCK_API_TIMEOUT_SECONDS=10 APP_MOCK_API_TIMEOUT_SECONDS=10
# Airflow (webserver + scheduler, LocalExecutor). Base de métadonnées dédiée `airflow` dans le # Airflow (api-server + scheduler + dag-processor, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus. # même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus.
AIRFLOW_PORT=8080 AIRFLOW_PORT=8080
# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre : # Chiffre les connexions/variables stockées par Airflow. Générer la vôtre :
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
AIRFLOW_FERNET_KEY=change_me AIRFLOW_FERNET_KEY=change_me
# Clé Flask du webserver Airflow (signature de session), distincte de la précédente. Générer la # Clé de session de l'api-server Airflow, distincte de la précédente. Générer la
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))" # vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
AIRFLOW_WEBSERVER_SECRET_KEY=change_me AIRFLOW_API_SECRET_KEY=change_me
# Secret des jetons JWT entre scheduler, tâches et api-server (conteneurs distincts, le secret
# doit être partagé). Même générateur que ci-dessus.
AIRFLOW_JWT_SECRET=change_me
AIRFLOW_ADMIN_USERNAME=admin AIRFLOW_ADMIN_USERNAME=admin
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les # Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
# comptes `app_user` d'EnerVision. # comptes `app_user` d'EnerVision.
+6 -5
View File
@@ -1,8 +1,9 @@
name: Airflow name: Airflow
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14 # Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de # (contrairement à backend.yml et ml.yml) : celui de l'image apache/airflow retenue, et les tests
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile). # doivent tourner sur le même interpréteur qu'elle. Le 3.14 de ml/ ne vit que dans l'image
# Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
# #
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une # Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans # modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans
@@ -47,10 +48,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: etl/airflow/uv.lock cache-dependency-glob: etl/airflow/uv.lock
@@ -78,7 +79,7 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/) - name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci . run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
+8 -8
View File
@@ -30,10 +30,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -90,10 +90,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -126,10 +126,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -150,12 +150,12 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit. # aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des # Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod. # secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
+10 -10
View File
@@ -17,10 +17,10 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
@@ -33,10 +33,10 @@ jobs:
name: Audit des dépendances name: Audit des dépendances
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison. # Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
- run: npm audit --audit-level=high --package-lock-only - run: npm audit --audit-level=high --package-lock-only
working-directory: apps/frontend working-directory: apps/frontend
@@ -45,10 +45,10 @@ jobs:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
- name : Installation des dépendances (Front) - name : Installation des dépendances (Front)
@@ -58,7 +58,7 @@ jobs:
run: npm test --watch=false --code-coverage --coverageReporters=lcov run: npm test --watch=false --code-coverage --coverageReporters=lcov
working-directory: apps/frontend working-directory: apps/frontend
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: frontend-coverage name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info path: apps/frontend/coverage/frontend/lcov.info
+4 -4
View File
@@ -30,10 +30,10 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: ml/uv.lock cache-dependency-glob: ml/uv.lock
@@ -67,12 +67,12 @@ jobs:
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc # Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit. # aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
- name: Analyse le code livré (bloquant à partir de MEDIUM) - name: Analyse le code livré (bloquant à partir de MEDIUM)
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
+22 -19
View File
@@ -24,10 +24,10 @@ jobs:
build-front: build-front:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
@@ -40,10 +40,10 @@ jobs:
needs: build-front needs: build-front
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: 24 node-version: 26
cache: npm cache: npm
cache-dependency-path: apps/frontend/package-lock.json cache-dependency-path: apps/frontend/package-lock.json
@@ -56,7 +56,7 @@ jobs:
working-directory: apps/frontend working-directory: apps/frontend
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: frontend-coverage name: frontend-coverage
path: apps/frontend/coverage/frontend/lcov.info path: apps/frontend/coverage/frontend/lcov.info
@@ -64,9 +64,9 @@ jobs:
build-back: build-back:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -95,9 +95,9 @@ jobs:
needs: build-back needs: build-back
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
@@ -107,7 +107,7 @@ jobs:
working-directory: apps/backend working-directory: apps/backend
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: backend-coverage name: backend-coverage
path: apps/backend/coverage.xml path: apps/backend/coverage.xml
@@ -115,9 +115,9 @@ jobs:
test-ml: test-ml:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: ml/uv.lock cache-dependency-glob: ml/uv.lock
@@ -135,7 +135,7 @@ jobs:
working-directory: ml working-directory: ml
- name: Upload coverage - name: Upload coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: ml-coverage name: ml-coverage
path: ml/coverage.xml path: ml/coverage.xml
@@ -143,23 +143,26 @@ jobs:
sonarqube: sonarqube:
needs: [build-front, build-back, test-front, test-back, test-ml] needs: [build-front, build-back, test-front, test-back, test-ml]
name: SonarQube name: SonarQube
# Pourquoi : GitHub ne fournit pas les secrets aux workflows lancés par dependabot[bot].
# Sans SONAR_TOKEN le scan échoue sans rien analyser ; build et tests restent joués.
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Téléchargement du rapport de couverture (Front) - name: Téléchargement du rapport de couverture (Front)
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: frontend-coverage name: frontend-coverage
path: apps/frontend/coverage/frontend path: apps/frontend/coverage/frontend
- name: Téléchargement du rapport de couverture (Back) - name: Téléchargement du rapport de couverture (Back)
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: backend-coverage name: backend-coverage
path: apps/backend path: apps/backend
- name: Téléchargement du rapport de couverture (ML) - name: Téléchargement du rapport de couverture (ML)
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: ml-coverage name: ml-coverage
path: ml path: ml
+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-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant. airflow-up: ## Démarre Airflow (api-server + scheduler + dag-processor, LocalExecutor). db-up requis avant.
docker compose up -d airflow-init airflow-webserver airflow-scheduler docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}" @echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
airflow-down: ## Arrête le webserver et le scheduler Airflow airflow-down: ## Arrête l'api-server, le scheduler et le dag-processor Airflow
docker compose stop airflow-webserver airflow-scheduler docker compose stop airflow-apiserver airflow-scheduler airflow-dag-processor
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor) airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
docker compose logs -f airflow-scheduler docker compose logs -f airflow-scheduler
+9 -7
View File
@@ -9,8 +9,10 @@ series temporelles energetiques, deployee sur une machine on-premise.
|-------|----------------------------------------------------------| |-------|----------------------------------------------------------|
| J1 | Valider la préparation de l'environnement et du repo | | J1 | Valider la préparation de l'environnement et du repo |
| J2 | Valider le périmètre retenu et les choix technologiques | | J2 | Valider le périmètre retenu et les choix technologiques |
| J3 | Valider l'architecture et la gestion de la sécurité | | J3 | Ingestion & backend |
| J4 | Valider la robustesse et assurer les livrables | | J4 | Architecture, sécurité & frontend |
| J5 | Valider la robustesse et assurer les livrables |
| J6 | Amélioration possible |
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md). Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
@@ -18,15 +20,15 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Domaine | Technologie | Emplacement | Etat | | Domaine | Technologie | Emplacement | Etat |
|------------|-------------------------------------|---------------------|---------------| |------------|-------------------------------------|---------------------|---------------|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | | Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord | | Frontend | Angular 22, Node 24 LTS | `apps/frontend` | En place |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
| ETL | Apache Airflow | `etl/airflow` | Trois DAGs | | ETL | Apache Airflow | `etl/airflow` | Trois DAGs |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place | | CI/CD | GitHub Actions | `.github/workflows` | En place |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
| ML | LightGBM, MLflow | `ml` | Entrainement initialise | | ML | LightGBM, MLflow | `ml` | En place |
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
+37 -37
View File
@@ -655,31 +655,31 @@ wheels = [
[[package]] [[package]]
name = "pandas" name = "pandas"
version = "3.0.5" version = "3.0.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, { url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" },
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, { url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, { url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, { url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" },
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, { url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" },
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, { url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" },
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, { url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" },
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, { url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, { url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" },
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, { url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" },
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, { url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" },
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, { url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, { url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" },
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, { url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" },
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, { url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, { url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" },
] ]
[[package]] [[package]]
@@ -936,27 +936,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.7" version = "0.16.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
] ]
[[package]] [[package]]
+1 -1
View File
@@ -3,7 +3,7 @@
# ================== # ==================
# Image pour frontend # Image pour frontend
FROM node:24-alpine3.22 AS builder FROM node:26-alpine3.22 AS builder
WORKDIR /app WORKDIR /app
+1 -4
View File
@@ -1,4 +1,4 @@
# Conventions de tests unitaires : Frontend # Conventions de tests unitaires — Frontend
## Outil ## Outil
Vitest (intégré nativement à Angular CLI, pas d'installation à faire). Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
@@ -83,6 +83,3 @@ describe('MonComposant', () => {
## Lancer les tests ## Lancer les tests
- Développement (mode watch) : `npm test` - Développement (mode watch) : `npm test`
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html` - 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)
@@ -8,10 +8,8 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
describe('mockApiInterceptor', () => { describe('mockApiInterceptor', () => {
let http: HttpClient; let http: HttpClient;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
let useMockFixturesInitial: boolean;
beforeEach(() => { beforeEach(() => {
useMockFixturesInitial = environment.useMockFixtures;
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
provideHttpClient(withInterceptors([mockApiInterceptor])), provideHttpClient(withInterceptors([mockApiInterceptor])),
@@ -23,7 +21,7 @@ describe('mockApiInterceptor', () => {
}); });
afterEach(() => { afterEach(() => {
environment.useMockFixtures = useMockFixturesInitial; environment.useMockFixtures = true;
httpMock.verify(); httpMock.verify();
}); });
@@ -2,63 +2,53 @@ import { Alert } from '../../shared/models/alert.model';
export const ALERTS_FIXTURE: Alert[] = [ export const ALERTS_FIXTURE: Alert[] = [
{ {
alert_id: 5, alert_id: 'ALR-SITE002-1718458320',
timestamp: '2026-09-15T11:12:00',
site_id: 'SITE002', site_id: 'SITE002',
timestamp: '2026-09-15T11:12:00Z',
type: 'threshold',
severity: 'critical', severity: 'critical',
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)', type: 'outage',
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
value: 812.5, value: 812.5,
threshold: 720.0, threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
}, },
{ {
alert_id: 4, alert_id: 'ALR-SITE003-1718458321',
timestamp: '2026-09-15T11:05:00',
site_id: 'SITE003', site_id: 'SITE003',
timestamp: '2026-09-15T11:05:00Z',
type: 'outage',
severity: 'critical', severity: 'critical',
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: 3,
site_id: 'SITE005',
timestamp: '2026-09-15T10:47:00Z',
type: 'spike',
severity: 'high',
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
value: 410.0,
threshold: 260.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 2,
site_id: 'SITE006',
timestamp: '2026-09-15T10:30:00Z',
type: 'sensor', type: 'sensor',
severity: 'medium', message: 'Perte réseau totale sur Data Center Marseille',
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)', value: 0,
value: null, threshold: 0,
threshold: null,
metric: null,
prediction_id: null,
}, },
{ {
alert_id: 1, alert_id: 'ALR-SITE005-1718458322',
timestamp: '2026-09-15T10:47:00',
site_id: 'SITE005',
severity: 'high',
type: 'threshold',
message: 'Usine Toulouse approche de son seuil de capacité',
value: 410.0,
threshold: 480.0,
},
{
alert_id: 'ALR-SITE006-1718458323',
timestamp: '2026-09-15T10:30:00',
site_id: 'SITE006',
severity: 'medium',
type: 'sensor',
message: 'Capteur de température défaillant sur Bureau Lille',
value: 0,
threshold: 0,
},
{
alert_id: 'ALR-SITE004-1718458324',
timestamp: '2026-09-15T09:58:00',
site_id: 'SITE004', site_id: 'SITE004',
timestamp: '2026-09-15T09:58:00Z',
type: 'anomaly',
severity: 'low', severity: 'low',
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)', type: 'anomaly',
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
value: 62.0, value: 62.0,
threshold: 55.0, threshold: 55.0,
metric: 'consumption_kwh',
prediction_id: 42,
}, },
]; ];
@@ -3,20 +3,6 @@ import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AlertsService } from './alerts.service'; import { AlertsService } from './alerts.service';
import { environment } from '../../../environments/environment'; 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', () => { describe('AlertsService', () => {
let service: AlertsService; let service: AlertsService;
@@ -32,35 +18,26 @@ describe('AlertsService', () => {
afterEach(() => httpMock.verify()); afterEach(() => httpMock.verify());
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => { it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
let result: Alert[] = []; let result: unknown;
service.getAlerts().subscribe((r) => (result = r)); service.getAlerts().subscribe((r) => (result = r));
const req = httpMock.expectOne( const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET', expect(req.request.method).toBe('GET');
);
expect(req.request.params.keys()).toEqual([]);
req.flush([ALERT_API]);
expect(result.length).toBe(1); req.flush([
expect(result[0].alert_id).toBe(1); {
expect(result[0].prediction_id).toBeNull(); alert_id: 'ALR-TEST-1',
}); timestamp: '2026-09-15T12:00:00',
site_id: 'SITE001',
severity: 'high',
type: 'threshold',
message: 'Test',
value: 100,
threshold: 90,
},
]);
it('transmet les filtres site_id et severity en paramètres de requête', () => { expect((result as unknown[]).length).toBe(1);
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,25 +1,13 @@
import { Service, inject } from '@angular/core'; import { Service, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { Alert, AlertSeverity } from '../../shared/models/alert.model'; import { Alert } from '../../shared/models/alert.model';
export interface AlertFilters {
site_id?: string;
severity?: AlertSeverity;
}
@Service() @Service()
export class AlertsService { export class AlertsService {
private http = inject(HttpClient); private http = inject(HttpClient);
getAlerts(filters: AlertFilters = {}) { getAlerts() {
let params = new HttpParams(); return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
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 });
} }
} }
@@ -27,120 +27,87 @@
@if (statsError(); as message) { @if (statsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
@if (alertsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (predictionsError(); as message) { @if (predictionsError(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
@if (stats(); as s) { @if (stats(); as s) {
<p class="dashboard__status"> <section class="overview">
<span class="dashboard__pulse" aria-hidden="true"></span> <ev-card class="card card--gauge">
Actualisé à {{ s.timestamp | date: 'HH:mm:ss' }} · {{ s.total_sites }} sites suivis <span class="card__label">Consommation vs capacité</span>
</p>
<section class="overview" aria-label="Indicateurs du parc">
<ev-card class="kpi kpi--gauge">
<span class="kpi__label">Consommation vs capacité</span>
<app-consumption-gauge <app-consumption-gauge
[consumption]="s.total_consumption_kw" [consumption]="s.total_consumption_kw"
[capacity]="s.total_capacity_kw" [capacity]="s.total_capacity_kw"
/> />
<span class="kpi__value"> <span class="card__value"
{{ s.total_consumption_kw | number: '1.0-1' }} >{{ s.total_consumption_kw | number: '1.0-1' }} /
<small>/ {{ s.total_capacity_kw | number }} kW</small> {{ s.total_capacity_kw | number }} kW</span
</span> >
</ev-card> </ev-card>
<ev-card class="kpi"> <ev-card class="card">
<span class="kpi__label">Charge moyenne du parc</span> <span class="card__label">Charge moyenne du parc</span>
<span class="kpi__value" <span class="card__value">{{ s.average_load_percent }} %</span>
>{{ s.average_load_percent | number: '1.0-0' }} <small>%</small></span <div class="progress-bar">
> <div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
<div
class="progress-bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="s.average_load_percent"
>
<div
class="progress-bar__fill"
[class]="'progress-bar__fill--' + loadTone(s.average_load_percent)"
[style.width.%]="s.average_load_percent"
></div>
</div> </div>
<span class="kpi__hint">{{ loadHint(s.average_load_percent) }}</span>
</ev-card> </ev-card>
<ev-card class="kpi"> <ev-card class="card">
<span class="kpi__label">Sites suivis</span> <span class="card__label">Sites suivis</span>
<span class="kpi__value">{{ s.total_sites }}</span> <span class="card__value">{{ s.total_sites }}</span>
<a routerLink="/sites" class="ev-link kpi__link">Voir la liste des sites</a>
</ev-card> </ev-card>
</section> </section>
}
<div class="dashboard__grid">
<div class="dashboard__main">
@if (stats(); as s) {
<section class="chart-section"> <section class="chart-section">
<h2>Charge par site</h2> <h2>Charge et alerte visuelle par site</h2>
<ev-card class="chart-card">
<app-site-load-chart [sites]="s.sites" /> <app-site-load-chart [sites]="s.sites" />
</ev-card>
</section> </section>
} }
@if (alerts().length > 0) {
<section class="alerts-section">
<h2>Alertes actives</h2>
<ul class="alerts-list">
@for (alert of alerts(); track alert.alert_id) {
<li class="alert-item">
<ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
<span class="alert-item__message">{{ alert.message }}</span>
</li>
}
</ul>
</section>
}
@if (predictions().length > 0) {
<section class="predictions-section"> <section class="predictions-section">
<h2>Prévisions de consommation</h2> <h2>Prévisions de consommation</h2>
@if (predictions().length > 0) { <ul class="predictions-list">
<ev-card class="ev-table-card">
<table class="ev-table">
<thead>
<tr>
<th>Site</th>
<th>Prévision</th>
<th>Échéance</th>
<th></th>
</tr>
</thead>
<tbody>
@for (site of predictions(); track site.site_id) { @for (site of predictions(); track site.site_id) {
<tr> <li class="prediction-item">
<td>{{ site.site_name }}</td> <span class="prediction-item__site">{{ site.site_name }}</span>
@if (site.prediction; as prediction) { @if (site.prediction; as prediction) {
@if (prediction.status === 'available') { @if (prediction.status === 'available') {
<td class="ev-table__number"> <span class="prediction-item__value">
{{ prediction.predicted_value | number: '1.0-1' }} kWh {{ prediction.predicted_value | number: '1.0-1' }} kWh
</td> <span class="prediction-item__target"
<td>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</td> >{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
>
</span>
} @else { } @else {
<td>
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{ <ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
prediction.status === 'insufficient_data' prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur'
? 'Historique insuffisant'
: 'Erreur'
}}</ev-badge> }}</ev-badge>
</td>
<td class="ev-table__muted">-</td>
} }
} @else { } @else {
<td><ev-badge tone="neutral">Pas encore de prévision</ev-badge></td> <ev-badge tone="neutral">Pas encore de prévision</ev-badge>
<td class="ev-table__muted">-</td>
} }
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td> </li>
</tr>
}
</tbody>
</table>
</ev-card>
} @else if (!predictionsError()) {
<p class="dashboard__empty">Aucune prévision disponible pour le moment.</p>
} }
</ul>
</section> </section>
</div> }
<aside class="dashboard__side" aria-label="Alertes actives">
<app-alert-feed />
</aside>
</div>
</div> </div>
@@ -8,11 +8,9 @@
.dashboard__header { .dashboard__header {
display: flex; display: flex;
flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); margin-bottom: 2rem;
margin-bottom: var(--space-3);
} }
.dashboard__brand { .dashboard__brand {
@@ -22,9 +20,8 @@
h1 { h1 {
margin: 0; margin: 0;
font-size: var(--font-size-xl); font-size: 1.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em;
} }
} }
@@ -34,69 +31,19 @@
.dashboard__subtitle { .dashboard__subtitle {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.dashboard__actions { .dashboard__actions {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: var(--space-2); gap: 1rem;
.ev-link {
padding: 0.45rem 0.9rem;
border-radius: var(--radius-pill);
background: var(--color-primary-light);
color: var(--color-primary-hover);
font-size: var(--font-size-sm);
transition: background 0.15s ease;
&:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
text-decoration: none;
}
}
}
.dashboard__status {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0 0 var(--space-4);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.dashboard__pulse {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--color-success);
animation: pulse 2s ease-out infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45);
}
100% {
box-shadow: 0 0 0 8px rgba(22, 163, 74, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard__pulse {
animation: none;
}
} }
h2 { h2 {
margin: 0 0 var(--space-3); font-size: 1.1rem;
font-size: var(--font-size-lg);
font-weight: 600; font-weight: 600;
margin: 0 0 1rem;
} }
.banner-error { .banner-error {
@@ -107,124 +54,116 @@ h2 {
.overview { .overview {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-3); gap: 1rem;
margin-bottom: var(--space-5); margin-bottom: 2.5rem;
} }
.kpi { .card {
position: relative; padding: 1.25rem;
overflow: hidden; gap: 0.35rem;
padding: var(--space-4);
gap: var(--space-1);
transition:
box-shadow 0.15s ease,
transform 0.15s ease;
&::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: var(--color-primary);
} }
&:hover { .card--gauge {
box-shadow: var(--shadow-card-hover);
transform: translateY(-1px);
}
}
.kpi--gauge {
align-items: center; align-items: center;
text-align: center; text-align: center;
} }
.kpi__label { .card--link {
font-size: var(--font-size-xs); cursor: pointer;
font-weight: 600; transition: border-color 0.15s ease;
&:hover {
border-color: var(--color-primary);
}
}
.card__label {
font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.02em;
} }
.kpi__value { .card__value {
font-size: var(--font-size-2xl); font-size: 1.6rem;
font-weight: 700; font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
small {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-muted);
}
}
.kpi__hint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.kpi__link {
margin-top: auto;
font-size: var(--font-size-sm);
} }
.progress-bar { .progress-bar {
height: 8px; height: 6px;
margin: var(--space-1) 0;
background: var(--color-border-light); background: var(--color-border-light);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
overflow: hidden; overflow: hidden;
margin-top: 0.25rem;
} }
.progress-bar__fill { .progress-bar__fill {
height: 100%; height: 100%;
background: var(--color-primary);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--color-success);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.progress-bar__fill--warning {
background: var(--color-warning);
}
.progress-bar__fill--danger {
background: var(--color-danger);
}
.dashboard__grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
gap: var(--space-4);
align-items: start;
}
.dashboard__side {
position: sticky;
top: var(--space-3);
}
.chart-section { .chart-section {
margin-bottom: var(--space-5); margin-bottom: 2.5rem;
} }
.chart-card { .alerts-list {
padding: var(--space-3); list-style: none;
}
.dashboard__empty {
margin: 0; margin: 0;
font-size: var(--font-size-sm); padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.alert-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
}
.alert-item__message {
font-size: 0.9rem;
}
.predictions-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prediction-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
}
.prediction-item__site {
font-size: 0.9rem;
font-weight: 600;
}
.prediction-item__value {
font-size: 0.9rem;
font-weight: 600;
}
.prediction-item__target {
margin-left: 0.35rem;
font-size: 0.8rem;
font-weight: 400;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
@media (max-width: 900px) {
.dashboard__grid {
grid-template-columns: 1fr;
}
.dashboard__side {
position: static;
}
}
@@ -1,13 +1,12 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { Observable, of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { Router, provideRouter } from '@angular/router';
import { Dashboard } from './dashboard'; import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service'; import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service'; import { AlertsService } from '../../core/services/alerts.service';
import { SitesService } from '../../core/services/sites.service';
import { PredictionsService } from '../../core/services/predictions.service'; import { PredictionsService } from '../../core/services/predictions.service';
import {AuthService} from '../../core/services/auth.service'; import {AuthService} from '../../core/services/auth.service';
import {Router, provideRouter} from '@angular/router';
vi.mock('chart.js', () => { vi.mock('chart.js', () => {
class ChartMock { class ChartMock {
@@ -19,73 +18,66 @@ vi.mock('chart.js', () => {
return { Chart: ChartMock, registerables: [] }; return { Chart: ChartMock, registerables: [] };
}); });
const STATS = { total_sites: 7, sites: [] };
function predictionsMock(sites: unknown[] = []) { function predictionsMock(sites: unknown[] = []) {
return { return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })),
};
}
function setup(
options: {
stats?: Observable<unknown>;
predictions?: { getPredictions: ReturnType<typeof vi.fn> };
auth?: Record<string, unknown>;
} = {},
) {
const statsMock = { getSummary: vi.fn().mockReturnValue(options.stats ?? of(STATS)) };
const predictions = options.predictions ?? predictionsMock();
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: { getAlerts: vi.fn().mockReturnValue(of([])) } },
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of([])) } },
{ provide: PredictionsService, useValue: predictions },
...(options.auth ? [{ provide: AuthService, useValue: options.auth }] : []),
provideRouter([]),
],
});
return { fixture: TestBed.createComponent(Dashboard), statsMock, predictions };
} }
describe('Dashboard', () => { describe('Dashboard', () => {
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
it('charge les stats et les prévisions au démarrage', async () => { it('charge les stats, les alertes et les prévisions au démarrage', async () => {
const { fixture, statsMock, predictions } = setup({ const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]), const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
}); });
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
// laisse le timer(0, ...) se déclencher avant de vérifier
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges(); fixture.detectChanges();
expect(statsMock.getSummary).toHaveBeenCalled(); expect(statsMock.getSummary).toHaveBeenCalled();
expect(alertsMock.getAlerts).toHaveBeenCalled();
expect(predictions.getPredictions).toHaveBeenCalled(); expect(predictions.getPredictions).toHaveBeenCalled();
expect(fixture.componentInstance.alerts().length).toBe(1);
expect(fixture.componentInstance.predictions().length).toBe(1); expect(fixture.componentInstance.predictions().length).toBe(1);
expect(fixture.componentInstance.statsError()).toBeNull(); expect(fixture.componentInstance.statsError()).toBeNull();
expect(fixture.componentInstance.alertsError()).toBeNull();
expect(fixture.componentInstance.predictionsError()).toBeNull(); expect(fixture.componentInstance.predictionsError()).toBeNull();
}); });
it('délègue les alertes au widget app-alert-feed', () => {
const { fixture } = setup();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-alert-feed')).not.toBeNull();
});
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { fixture, statsMock } = setup({ const statsMock = {
stats: throwError(() => new Error('API injoignable')), getSummary: vi
}); .fn()
statsMock.getSummary
.mockReturnValueOnce(throwError(() => new Error('API injoignable'))) .mockReturnValueOnce(throwError(() => new Error('API injoignable')))
.mockReturnValue(of(STATS)); .mockReturnValue(of({ total_sites: 7, sites: [] })),
};
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
vi.advanceTimersByTime(1); vi.advanceTimersByTime(1);
@@ -99,11 +91,45 @@ describe('Dashboard', () => {
expect(fixture.componentInstance.statsError()).toBeNull(); expect(fixture.componentInstance.statsError()).toBeNull();
}); });
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => { it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
const { fixture } = setup({ const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
}); });
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.alerts().length).toBe(0);
expect(fixture.componentInstance.alertsError()).not.toBeNull();
});
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const predictions = {
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.componentInstance.predictions().length).toBe(0); expect(fixture.componentInstance.predictions().length).toBe(0);
@@ -112,11 +138,25 @@ describe('Dashboard', () => {
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => { it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { fixture } = setup({ const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const predictions = {
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
};
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictions },
provideRouter([]),
],
}); });
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.componentInstance.predictionsError()).not.toBeNull(); expect(fixture.componentInstance.predictionsError()).not.toBeNull();
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit // Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
@@ -128,106 +168,109 @@ describe('Dashboard', () => {
}); });
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = { const authMock = {
logout: vi.fn().mockReturnValue(of(undefined)), logout: vi.fn().mockReturnValue(of(undefined)),
clearSession: vi.fn(), clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }), principal: vi.fn().mockReturnValue({ role: 'admin' }),
}; };
const { fixture } = setup({ auth: authMock }); TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture.nativeElement.querySelector('.logout-button').click(); const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.logout).toHaveBeenCalled(); expect(authMock.logout).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/login']); expect(navigateSpy).toHaveBeenCalledWith(['/login']);
}); });
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = { const authMock = {
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(), clearSession: vi.fn(),
principal: vi.fn().mockReturnValue({ role: 'admin' }), principal: vi.fn().mockReturnValue({ role: 'admin' }),
}; };
const { fixture } = setup({ auth: authMock }); TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
{ provide: AuthService, useValue: authMock },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges(); fixture.detectChanges();
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture.nativeElement.querySelector('.logout-button').click(); const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.clearSession).toHaveBeenCalled(); expect(authMock.clearSession).toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/login']); expect(navigateSpy).toHaveBeenCalledWith(['/login']);
}); });
it('affiche l’heure du dernier relevé et les indicateurs du parc', () => { it('distingue le ton des sévérités high et critical', () => {
vi.useFakeTimers(); const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const { fixture } = setup({ const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
stats: of({
timestamp: '2026-09-18T09:00:00Z', TestBed.configureTestingModule({
total_sites: 7, imports: [Dashboard],
total_consumption_kw: 1234.5, providers: [
total_capacity_kw: 5000, { provide: StatsService, useValue: statsMock },
average_load_percent: 24.7, { provide: AlertsService, useValue: alertsMock },
sites: [], { provide: PredictionsService, useValue: predictionsMock() },
}), provideRouter([]),
],
}); });
fixture.detectChanges(); const fixture = TestBed.createComponent(Dashboard);
vi.advanceTimersByTime(1);
fixture.detectChanges();
const texte = fixture.nativeElement.textContent as string;
expect(texte).toContain('Actualisé à');
expect(texte).toContain('7 sites suivis');
expect(texte).toContain('Marge confortable');
expect(fixture.nativeElement.querySelector('.progress-bar__fill--success')).not.toBeNull();
});
it('présente les prévisions en tableau avec un lien vers chaque site', () => {
const { fixture } = setup({
predictions: predictionsMock([
{
site_id: 'SITE001',
site_name: 'Usine Nantes',
prediction: {
target_at: '2026-09-18T10:00:00Z',
target_metric: 'consumption_kwh',
period_minutes: 60,
predicted_value: 118.4,
status: 'available',
failure_reason: null,
model_reference: 'lightgbm-v1',
created_at: '2026-09-18T09:00:00Z',
},
},
{ site_id: 'SITE002', site_name: 'Bureau Lille', prediction: null },
]),
});
fixture.detectChanges();
const table = fixture.nativeElement.querySelector('table.ev-table');
expect(table).not.toBeNull();
expect(table.textContent).toContain('118.4 kWh');
expect(table.textContent).toContain('Pas encore de prévision');
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE001"]')).not.toBeNull();
});
it('colore la charge moyenne selon les seuils 70 % et 90 %', () => {
const { fixture } = setup();
const dashboard = fixture.componentInstance; const dashboard = fixture.componentInstance;
expect(dashboard.loadTone(69.9)).toBe('success'); expect(dashboard.badgeToneForSeverity('low')).toBe('success');
expect(dashboard.loadTone(70)).toBe('warning'); expect(dashboard.badgeToneForSeverity('medium')).toBe('warning');
expect(dashboard.loadTone(89.9)).toBe('warning'); expect(dashboard.badgeToneForSeverity('high')).toBe('danger');
expect(dashboard.loadTone(90)).toBe('danger'); expect(dashboard.badgeToneForSeverity('critical')).toBe('critical');
expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc'); expect(dashboard.badgeToneForSeverity('high')).not.toBe(
dashboard.badgeToneForSeverity('critical'),
);
}); });
it('distingue le ton des statuts de prévision', () => { it('distingue le ton des statuts de prévision', () => {
const { fixture } = setup(); const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: PredictionsService, useValue: predictionsMock() },
provideRouter([]),
],
});
const fixture = TestBed.createComponent(Dashboard);
const dashboard = fixture.componentInstance; const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success'); expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
@@ -6,10 +6,11 @@ import { Router, RouterLink } from '@angular/router';
import { StatsService } from '../../core/services/stats.service'; import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
import { AlertFeed } from '../../shared/components/alert-feed/alert-feed'; import { AlertsService } from '../../core/services/alerts.service';
import { PredictionsService } from '../../core/services/predictions.service'; import { PredictionsService } from '../../core/services/predictions.service';
import { AuthService } from '../../core/services/auth.service'; import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model'; import { StatsSummary } from '../../shared/models/stats.model';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model'; import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
import { Card } from '../../shared/components/ui/card/card'; import { Card } from '../../shared/components/ui/card/card';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
@@ -21,19 +22,22 @@ const REFRESH_INTERVAL_MS = 10000;
const UNAVAILABLE_MESSAGE = const UNAVAILABLE_MESSAGE =
'Données indisponibles, les valeurs affichées datent du dernier relevé.'; 'Données indisponibles, les valeurs affichées datent du dernier relevé.';
// `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` : const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
// mieux vaut un ton défini que `undefined` le jour où ce statut apparaît. low: 'success',
medium: 'warning',
high: 'danger',
critical: 'critical',
};
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
// tomber sur `undefined` si ce statut apparaît un jour.
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = { const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
available: 'success', available: 'success',
insufficient_data: 'warning', insufficient_data: 'warning',
error: 'danger', error: 'danger',
}; };
const SEUIL_CHARGE_SOUTENUE = 70;
const SEUIL_CHARGE_CRITIQUE = 90;
export type LoadTone = 'success' | 'warning' | 'danger';
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
standalone: true, standalone: true,
@@ -43,7 +47,6 @@ export type LoadTone = 'success' | 'warning' | 'danger';
RouterLink, RouterLink,
ConsumptionGauge, ConsumptionGauge,
SiteLoadChart, SiteLoadChart,
AlertFeed,
Card, Card,
EvAlert, EvAlert,
Badge, Badge,
@@ -55,20 +58,32 @@ export type LoadTone = 'success' | 'warning' | 'danger';
}) })
export class Dashboard implements OnInit { export class Dashboard implements OnInit {
private statsService = inject(StatsService); private statsService = inject(StatsService);
private alertsService = inject(AlertsService);
public auth = inject(AuthService); public auth = inject(AuthService);
private predictionsService = inject(PredictionsService); private predictionsService = inject(PredictionsService);
private router = inject(Router); private router = inject(Router);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
stats = signal<StatsSummary | null>(null); stats = signal<StatsSummary | null>(null);
alerts = signal<Alert[]>([]);
predictions = signal<SitePredictionSummary[]>([]); predictions = signal<SitePredictionSummary[]>([]);
// Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en // Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
// silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur. // efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
statsError = signal<string | null>(null); statsError = signal<string | null>(null);
alertsError = signal<string | null>(null);
predictionsError = signal<string | null>(null); predictionsError = signal<string | null>(null);
ngOnInit(): void { ngOnInit(): void {
this.alertsService
.getAlerts()
.pipe(catchError(() => this.reportUnavailable(this.alertsError)))
.subscribe((alerts) => {
this.alertsError.set(null);
this.alerts.set(alerts);
});
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul // Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`. // chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
this.predictionsService this.predictionsService
@@ -84,9 +99,7 @@ export class Dashboard implements OnInit {
timer(0, REFRESH_INTERVAL_MS) timer(0, REFRESH_INTERVAL_MS)
.pipe( .pipe(
switchMap(() => switchMap(() =>
this.statsService this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
.getSummary()
.pipe(catchError(() => this.reportUnavailable(this.statsError))),
), ),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
) )
@@ -96,28 +109,14 @@ export class Dashboard implements OnInit {
}); });
} }
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
return TON_PAR_SEVERITE[severity];
}
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone { badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
return TON_PAR_STATUT_PREDICTION[status]; return TON_PAR_STATUT_PREDICTION[status];
} }
loadTone(percent: number): LoadTone {
if (percent >= SEUIL_CHARGE_CRITIQUE) {
return 'danger';
}
return percent >= SEUIL_CHARGE_SOUTENUE ? 'warning' : 'success';
}
loadHint(percent: number): string {
switch (this.loadTone(percent)) {
case 'danger':
return 'Proche de la capacité du parc';
case 'warning':
return 'Charge soutenue';
default:
return 'Marge confortable';
}
}
onLogout(): void { onLogout(): void {
this.auth.logout().subscribe({ this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']), next: () => this.router.navigate(['/login']),
@@ -17,8 +17,8 @@
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert> <ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
} }
<ev-card class="ev-table-card"> <ev-card class="table-card">
<table class="ev-table"> <table class="sites-table">
<thead> <thead>
<tr> <tr>
<th>Nom</th> <th>Nom</th>
@@ -36,9 +36,7 @@
<td>{{ site.site_type }}</td> <td>{{ site.site_type }}</td>
<td>{{ site.location || '-' }}</td> <td>{{ site.location || '-' }}</td>
<td>{{ site.capacity_kw ?? '-' }}</td> <td>{{ site.capacity_kw ?? '-' }}</td>
<td> <td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td>
<ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge>
</td>
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td> <td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
</tr> </tr>
} }
@@ -32,3 +32,32 @@
display: block; display: block;
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
} }
.table-card {
padding: 0;
overflow: hidden;
}
.sites-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.85rem 1.25rem;
text-align: left;
border-bottom: 1px solid var(--color-border-light);
}
th {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
font-weight: 600;
}
tr:last-child td {
border-bottom: none;
}
}
@@ -1,95 +0,0 @@
<section class="alert-feed">
<header class="alert-feed__header">
<div>
<h2 class="alert-feed__title">Alertes actives</h2>
@if (!loading() || alerts().length > 0) {
<p class="alert-feed__count">
{{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }}
</p>
}
</div>
<div class="alert-feed__filters">
@if (!siteId()) {
<label class="alert-feed__filter">
<span class="form-label">Site</span>
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
<option value="">Tous les sites</option>
@for (site of sites(); track site.site_id) {
<option [value]="site.site_id">{{ site.site_name }}</option>
}
</select>
</label>
}
<label class="alert-feed__filter">
<span class="form-label">Sévérité</span>
<select
class="form-select"
data-testid="severity-filter"
(change)="onSeverityChange($event)"
>
<option value="">Toutes</option>
@for (severite of severites; track severite) {
<option [value]="severite">{{ severityLabel(severite) }}</option>
}
</select>
</label>
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="alert-feed__banner">{{ message }}</ev-alert>
}
@if (loading() && alerts().length === 0 && !error()) {
<p class="alert-feed__state" aria-live="polite">Chargement des alertes…</p>
} @else if (alerts().length === 0 && !error()) {
<ev-alert severity="success" class="alert-feed__banner"
>Aucune alerte pour ces critères.</ev-alert
>
}
<ul class="alert-feed__list" [attr.aria-busy]="loading()">
@for (alert of visibleAlerts(); track alert.alert_id) {
<li class="alert-feed__item" [class]="'alert-feed__item--' + alert.severity">
<span class="alert-feed__icon">
<ev-icon [name]="alert.type" [label]="typeLabel(alert.type)" />
</span>
<div class="alert-feed__body">
<div class="alert-feed__meta">
<ev-badge [tone]="toneFor(alert.severity)">{{
severityLabel(alert.severity)
}}</ev-badge>
<span class="alert-feed__type">{{ typeLabel(alert.type) }}</span>
<span class="alert-feed__site">{{ siteName(alert.site_id) }}</span>
<time class="alert-feed__time" [attr.datetime]="alert.timestamp">
{{ alert.timestamp | date: 'dd/MM/yyyy HH:mm' }}
</time>
</div>
<p class="alert-feed__message">{{ alert.message }}</p>
@if (alert.value !== null) {
<p class="alert-feed__values">
<strong>{{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }}</strong>
@if (alert.threshold !== null) {
<span
>seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }}</span
>
}
</p>
}
</div>
</li>
}
</ul>
@if (hiddenCount() > 0) {
<ev-button
variant="secondary"
[fullWidth]="false"
class="alert-feed__more"
data-testid="show-more"
(click)="showMore()"
>
Afficher plus ({{ hiddenCount() }} restantes)
</ev-button>
}
</section>
@@ -1,154 +0,0 @@
:host {
display: block;
}
.alert-feed__header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.alert-feed__title {
margin: 0;
font-size: var(--font-size-lg);
font-weight: 600;
}
.alert-feed__count {
margin: 0.15rem 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.alert-feed__filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.alert-feed__filter {
display: flex;
flex-direction: column;
min-width: 10rem;
.form-label {
margin-top: 0;
}
}
.alert-feed__banner {
display: block;
margin-bottom: var(--space-3);
}
.alert-feed__state {
margin: 0 0 var(--space-3);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.alert-feed__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.alert-feed__item {
display: flex;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border-light);
border-left: 4px solid var(--color-border);
}
.alert-feed__item--medium {
border-left-color: var(--color-warning);
background: var(--color-warning-bg);
.alert-feed__icon {
color: var(--color-warning-text);
}
}
.alert-feed__item--high {
border-left-color: var(--color-danger);
background: var(--color-danger-bg);
.alert-feed__icon {
color: var(--color-danger);
}
}
.alert-feed__item--critical {
border-left-color: var(--color-critical);
background: var(--color-danger-bg);
.alert-feed__icon {
color: var(--color-critical);
}
}
.alert-feed__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: var(--font-size-lg);
box-shadow: var(--shadow-card);
}
.alert-feed__body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.alert-feed__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.alert-feed__type {
font-weight: 600;
color: var(--color-text);
}
.alert-feed__message {
margin: 0;
font-size: var(--font-size-sm);
}
.alert-feed__values {
display: flex;
gap: var(--space-2);
margin: 0;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
strong {
color: var(--color-text);
}
}
.alert-feed__more {
display: inline-block;
margin-top: var(--space-3);
}
@@ -1,218 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { of, throwError } from 'rxjs';
import { AlertFeed } from './alert-feed';
import { AlertsService } from '../../../core/services/alerts.service';
import { SitesService } from '../../../core/services/sites.service';
import { Alert } from '../../models/alert.model';
import { Site } from '../../models/site.model';
const SITES: Site[] = [
{
site_id: 'SITE001',
site_name: 'Usine Nantes',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
];
function alerte(surcharges: Partial<Alert> = {}): Alert {
return {
alert_id: 1,
site_id: 'SITE001',
timestamp: '2026-09-15T11:12:00Z',
type: 'spike',
severity: 'critical',
message: 'Variation brutale entre deux lectures consécutives',
value: 812.5,
threshold: 400,
metric: 'consumption_kw',
prediction_id: null,
...surcharges,
};
}
function setup(
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
sitesMock: { getSites: ReturnType<typeof vi.fn> } = {
getSites: vi.fn().mockReturnValue(of(SITES)),
},
) {
TestBed.configureTestingModule({
imports: [AlertFeed],
providers: [
{ provide: AlertsService, useValue: alertsMock },
{ provide: SitesService, useValue: sitesMock },
],
});
return TestBed.createComponent(AlertFeed);
}
function premierChargement(fixture: ComponentFixture<AlertFeed>) {
fixture.detectChanges();
vi.advanceTimersByTime(1);
fixture.detectChanges();
}
function texte(fixture: ComponentFixture<AlertFeed>): string {
return (fixture.nativeElement as HTMLElement).textContent ?? '';
}
function choisir(fixture: ComponentFixture<AlertFeed>, testId: string, value: string) {
const select = fixture.nativeElement.querySelector(
`[data-testid="${testId}"]`,
) as HTMLSelectElement;
select.value = value;
select.dispatchEvent(new Event('change'));
fixture.detectChanges();
vi.advanceTimersByTime(1);
fixture.detectChanges();
}
describe('AlertFeed', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('charge les alertes au démarrage sans filtre et les affiche avec leur contexte', () => {
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
const fixture = setup({ getAlerts });
premierChargement(fixture);
expect(getAlerts).toHaveBeenCalledTimes(1);
expect(getAlerts.mock.calls[0][0]).toEqual({});
const contenu = texte(fixture);
expect(contenu).toContain('Usine Nantes');
expect(contenu).toContain('Critique');
expect(contenu).toContain('Pic de consommation');
expect(contenu).toContain('15/09/2026');
expect(contenu).toContain('812.5 kW');
expect(contenu).toContain('seuil 400 kW');
expect(fixture.nativeElement.querySelector('ev-icon svg')).not.toBeNull();
expect(fixture.nativeElement.querySelector('.alert-feed__item--critical')).not.toBeNull();
});
it('annonce le chargement avant la première réponse', () => {
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
fixture.detectChanges();
expect(fixture.componentInstance.loading()).toBe(true);
expect(texte(fixture)).toContain('Chargement des alertes');
});
it("annonce l'absence d'alerte pour les critères choisis", () => {
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
premierChargement(fixture);
expect(texte(fixture)).toContain('Aucune alerte pour ces critères.');
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(0);
});
it('relance la requête avec la sévérité choisie et revient à la première page', () => {
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
const fixture = setup({ getAlerts });
premierChargement(fixture);
fixture.componentInstance.showMore();
choisir(fixture, 'severity-filter', 'high');
expect(getAlerts).toHaveBeenCalledTimes(2);
expect(getAlerts.mock.calls[1][0]).toEqual({ severity: 'high' });
expect(fixture.componentInstance.visibleCount()).toBe(10);
});
it('relance la requête avec le site choisi dans le filtre', () => {
const getAlerts = vi.fn().mockReturnValue(of([]));
const fixture = setup({ getAlerts });
premierChargement(fixture);
choisir(fixture, 'site-filter', 'SITE001');
expect(getAlerts.mock.calls[1][0]).toEqual({ site_id: 'SITE001' });
});
it('masque le filtre site et force site_id quand le parent fixe le site', () => {
const getAlerts = vi.fn().mockReturnValue(of([]));
const fixture = setup({ getAlerts });
fixture.componentRef.setInput('siteId', 'SITE001');
premierChargement(fixture);
expect(getAlerts.mock.calls[0][0]).toEqual({ site_id: 'SITE001' });
expect(fixture.nativeElement.querySelector('[data-testid="site-filter"]')).toBeNull();
expect(fixture.nativeElement.querySelector('[data-testid="severity-filter"]')).not.toBeNull();
});
it("signale l'indisponibilité en gardant la liste, puis repart au rafraîchissement suivant", () => {
const getAlerts = vi
.fn()
.mockReturnValueOnce(of([alerte()]))
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
.mockReturnValue(of([alerte(), alerte({ alert_id: 2 })]));
const fixture = setup({ getAlerts });
premierChargement(fixture);
vi.advanceTimersByTime(60_000);
fixture.detectChanges();
expect(getAlerts).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.alerts().length).toBe(1);
expect(texte(fixture)).toContain('Alertes indisponibles');
vi.advanceTimersByTime(60_000);
fixture.detectChanges();
expect(getAlerts).toHaveBeenCalledTimes(3);
expect(fixture.componentInstance.error()).toBeNull();
expect(fixture.componentInstance.alerts().length).toBe(2);
});
it('pagine côté client par dix et dévoile le reste à la demande', () => {
const alertes = Array.from({ length: 25 }, (_, i) => alerte({ alert_id: i + 1 }));
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(alertes)) });
premierChargement(fixture);
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(10);
expect(texte(fixture)).toContain('Afficher plus (15 restantes)');
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(20);
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(25);
expect(fixture.nativeElement.querySelector('[data-testid="show-more"]')).toBeNull();
});
it("replie sur l'identifiant quand le site est inconnu ou que la liste des sites échoue", () => {
const fixture = setup(
{ getAlerts: vi.fn().mockReturnValue(of([alerte({ site_id: 'SITE999' })])) },
{ getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
);
premierChargement(fixture);
expect(texte(fixture)).toContain('SITE999');
});
it("n'affiche pas de mesure pour une alerte sans valeur", () => {
const fixture = setup({
getAlerts: vi
.fn()
.mockReturnValue(
of([alerte({ type: 'outage', value: null, threshold: null, metric: null })]),
),
});
premierChargement(fixture);
expect(fixture.nativeElement.querySelector('.alert-feed__values')).toBeNull();
expect(texte(fixture)).toContain('Coupure');
});
});
@@ -1,130 +0,0 @@
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { DatePipe, DecimalPipe } from '@angular/common';
import { catchError, EMPTY, Observable, of, switchMap, tap, timer } from 'rxjs';
import { AlertFilters, AlertsService } from '../../../core/services/alerts.service';
import { SitesService } from '../../../core/services/sites.service';
import { Alert, AlertMetric, AlertSeverity, AlertType } from '../../models/alert.model';
import { Site } from '../../models/site.model';
import {
LIBELLE_PAR_SEVERITE,
LIBELLE_PAR_TYPE,
SEVERITES,
TON_PAR_SEVERITE,
UNITE_PAR_METRIQUE,
} from '../../models/alert-presentation';
import { Badge, BadgeTone } from '../ui/badge/badge';
import { Button } from '../ui/button/button';
import { Alert as EvAlert } from '../ui/alert/alert';
import { Icon } from '../ui/icon/icon';
// Le DAG de détection tourne toutes les heures : une minute suffit largement pour suivre le flux.
const REFRESH_INTERVAL_MS = 60_000;
const PAGE_SIZE = 10;
const UNAVAILABLE_MESSAGE = 'Alertes indisponibles, la liste affichée date du dernier chargement.';
@Component({
selector: 'app-alert-feed',
standalone: true,
imports: [DatePipe, DecimalPipe, Badge, Button, EvAlert, Icon],
templateUrl: './alert-feed.html',
styleUrl: './alert-feed.scss',
})
export class AlertFeed {
private alertsService = inject(AlertsService);
private sitesService = inject(SitesService);
private destroyRef = inject(DestroyRef);
siteId = input<string | null>(null);
readonly severites = SEVERITES;
severity = signal<AlertSeverity | null>(null);
siteFilter = signal<string | null>(null);
alerts = signal<Alert[]>([]);
loading = signal(true);
error = signal<string | null>(null);
visibleCount = signal(PAGE_SIZE);
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
initialValue: [] as Site[],
});
private filters = computed<AlertFilters>(() => ({
site_id: this.siteId() ?? this.siteFilter() ?? undefined,
severity: this.severity() ?? undefined,
}));
private siteNameById = computed(
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
);
visibleAlerts = computed(() => this.alerts().slice(0, this.visibleCount()));
hiddenCount = computed(() => Math.max(this.alerts().length - this.visibleCount(), 0));
constructor() {
toObservable(this.filters)
.pipe(
tap(() => {
this.loading.set(true);
this.visibleCount.set(PAGE_SIZE);
}),
// Piège : catchError sur l'observable interne ; sur le flux externe il terminerait le
// timer et le rafraîchissement ne repartirait jamais.
switchMap((filters) =>
timer(0, REFRESH_INTERVAL_MS).pipe(
switchMap(() =>
this.alertsService
.getAlerts(filters)
.pipe(catchError(() => this.reportUnavailable())),
),
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((alerts) => {
this.loading.set(false);
this.error.set(null);
this.alerts.set(alerts);
});
}
onSiteChange(event: Event): void {
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
}
onSeverityChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value;
this.severity.set(value ? (value as AlertSeverity) : null);
}
showMore(): void {
this.visibleCount.update((count) => count + PAGE_SIZE);
}
toneFor(severity: AlertSeverity): BadgeTone {
return TON_PAR_SEVERITE[severity];
}
severityLabel(severity: AlertSeverity): string {
return LIBELLE_PAR_SEVERITE[severity];
}
typeLabel(type: AlertType): string {
return LIBELLE_PAR_TYPE[type];
}
siteName(siteId: string): string {
return this.siteNameById().get(siteId) ?? siteId;
}
unitFor(metric: AlertMetric | null): string {
return metric ? UNITE_PAR_METRIQUE[metric] : '';
}
private reportUnavailable(): Observable<never> {
this.loading.set(false);
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -1,37 +0,0 @@
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
focusable="false"
[attr.role]="label() ? 'img' : null"
[attr.aria-label]="label()"
[attr.aria-hidden]="label() ? null : 'true'"
>
@switch (name()) {
@case ('spike') {
<polyline points="3 17 8 12 11 15 21 5" />
<polyline points="15 5 21 5 21 11" />
}
@case ('threshold') {
<line x1="3" y1="10" x2="21" y2="10" stroke-dasharray="3 3" />
<polyline points="4 19 9 13 13 15 20 6" />
}
@case ('anomaly') {
<polyline points="3 12 7 12 9.5 6 12.5 18 15 12 21 12" />
}
@case ('outage') {
<path d="M7.5 6.5a7 7 0 1 0 9 0" />
<line x1="12" y1="3" x2="12" y2="11" />
}
@case ('sensor') {
<circle cx="12" cy="13" r="2" />
<path d="M8.5 9.5a5 5 0 0 0 0 7" />
<path d="M15.5 9.5a5 5 0 0 1 0 7" />
<path d="M5.5 6.5a9 9 0 0 0 0 13" />
<path d="M18.5 6.5a9 9 0 0 1 0 13" />
}
}
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,12 +0,0 @@
:host {
display: inline-flex;
flex-shrink: 0;
width: 1em;
height: 1em;
vertical-align: -0.125em;
}
svg {
width: 100%;
height: 100%;
}
@@ -1,49 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { Icon, IconName } from './icon';
const NOMS: IconName[] = ['spike', 'threshold', 'anomaly', 'outage', 'sensor'];
function rendre(name: IconName, label: string | null = null) {
const fixture = TestBed.createComponent(Icon);
fixture.componentRef.setInput('name', name);
fixture.componentRef.setInput('label', label);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
describe('Icon', () => {
beforeEach(() => {
TestBed.configureTestingModule({ imports: [Icon] });
});
it('dessine un tracé distinct pour chacun des cinq types', () => {
const traces = NOMS.map((name) => rendre(name).querySelector('svg')?.innerHTML.trim());
for (const trace of traces) {
expect(trace).toBeTruthy();
}
expect(new Set(traces).size).toBe(NOMS.length);
});
it('reste décoratif sans libellé', () => {
const svg = rendre('spike').querySelector('svg');
expect(svg?.getAttribute('aria-hidden')).toBe('true');
expect(svg?.hasAttribute('role')).toBe(false);
});
it('expose un rôle image et un libellé accessible quand on lui en donne un', () => {
const svg = rendre('outage', 'Coupure').querySelector('svg');
expect(svg?.getAttribute('role')).toBe('img');
expect(svg?.getAttribute('aria-label')).toBe('Coupure');
expect(svg?.hasAttribute('aria-hidden')).toBe(false);
});
it('hérite de la couleur du parent via currentColor', () => {
const svg = rendre('sensor').querySelector('svg');
expect(svg?.getAttribute('stroke')).toBe('currentColor');
expect(svg?.getAttribute('fill')).toBe('none');
});
});
@@ -1,14 +0,0 @@
import { Component, input } from '@angular/core';
export type IconName = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
@Component({
selector: 'ev-icon',
standalone: true,
templateUrl: './icon.html',
styleUrl: './icon.scss',
})
export class Icon {
name = input.required<IconName>();
label = input<string | null>(null);
}
@@ -1,37 +0,0 @@
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');
});
});
@@ -1,41 +0,0 @@
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,16 +1,13 @@
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'; export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor'; export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
export interface Alert { export interface Alert {
alert_id: number; alert_id: string;
site_id: string;
timestamp: string; timestamp: string;
type: AlertType; site_id: string;
severity: AlertSeverity; severity: AlertSeverity;
type: AlertType;
message: string; message: string;
value: number | null; value: number;
threshold: number | null; threshold: number;
metric: AlertMetric | null;
prediction_id: number | null;
} }
-1
View File
@@ -2,7 +2,6 @@
@use 'styles/forms'; @use 'styles/forms';
@use 'styles/auth-page'; @use 'styles/auth-page';
@use 'styles/links'; @use 'styles/links';
@use 'styles/tables';
body { body {
margin: 0; margin: 0;
-15
View File
@@ -29,18 +29,3 @@
color: var(--color-disabled); color: var(--color-disabled);
margin-top: 0.25rem; 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;
}
-43
View File
@@ -1,43 +0,0 @@
// Piège : `ev-card` pose son padding sur `:host`, compilé en `[_nghost-…]` (même spécificité
// qu'une classe) et injecté après la feuille globale ; il faut le sélecteur d'élément pour gagner.
ev-card.ev-table-card {
padding: 0;
overflow: hidden;
}
.ev-table {
width: 100%;
border-collapse: collapse;
th,
td {
padding: 0.85rem 1.25rem;
text-align: left;
border-bottom: 1px solid var(--color-border-light);
}
th {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: var(--color-bg);
}
}
.ev-table__number {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ev-table__muted {
color: var(--color-text-muted);
}
-7
View File
@@ -29,17 +29,10 @@
// Typo, rayons, ombre // Typo, rayons, ombre
--font-family: 'Segoe UI', system-ui, sans-serif; --font-family: 'Segoe UI', system-ui, sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.85rem;
--font-size-md: 1rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.75rem;
--font-size-2xl: 2.25rem;
--radius-sm: 8px; --radius-sm: 8px;
--radius-md: 12px; --radius-md: 12px;
--radius-pill: 999px; --radius-pill: 999px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06); --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-card-hover: 0 6px 16px rgba(0, 0, 0, 0.08);
// Espacements // Espacements
--space-1: 0.35rem; --space-1: 0.35rem;
-1
View File
@@ -3,7 +3,6 @@
{ {
"compileOnSave": false, "compileOnSave": false,
"compilerOptions": { "compilerOptions": {
"strict": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true, "noImplicitReturns": true,
+1 -1
View File
@@ -1,4 +1,4 @@
-- Base de metadonnees Airflow (webserver + scheduler, LocalExecutor). Separee de la base -- Base de metadonnees Airflow (api-server + scheduler + dag-processor, LocalExecutor). Separee de la base
-- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire -- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire
-- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un -- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un
-- service dedie, pour ne pas ajouter un conteneur de plus (issue #115). -- service dedie, pour ne pas ajouter un conteneur de plus (issue #115).
+2 -2
View File
@@ -17,7 +17,7 @@ services:
ports: !override ports: !override
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025" - "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
airflow-webserver: airflow-apiserver:
ports: !override ports: !override
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080" - "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
@@ -44,7 +44,7 @@ services:
ports: !reset null ports: !reset null
proxy: proxy:
image: nginx:1.28-alpine image: nginx:1.31-alpine
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
+31 -13
View File
@@ -5,9 +5,9 @@
name: enervision name: enervision
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais du # Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais de
# webserver. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte # l'api-server. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte
# que sur `airflow-scheduler` en pratique, mais reste partage avec le webserver pour que ce # que sur `airflow-scheduler` en pratique, mais reste partage avec l'api-server pour que ce
# dernier puisse au besoin l'inspecter sans en devenir dependant. # dernier puisse au besoin l'inspecter sans en devenir dependant.
x-airflow-common: &airflow-common x-airflow-common: &airflow-common
build: build:
@@ -19,9 +19,16 @@ x-airflow-common: &airflow-common
# Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de # Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de
# filtrer les services : une variable requise manquante casserait aussi `make db-up`, # filtrer les services : une variable requise manquante casserait aussi `make db-up`,
# `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par # `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par
# `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent. # `airflow-init` (ci-dessous), dont `api-server`, `dag-processor` et `scheduler` dependent.
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-} AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-} AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-}
# Signe les jetons entre scheduler, tâches et api-server. Conteneurs distincts : un secret
# généré au démarrage ne serait pas partagé, il doit venir du .env.
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-}
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
# FabAuthManager plutôt que le SimpleAuthManager par défaut d'Airflow 3 : seul le provider
# FAB sait créer le compte admin que `airflow-init` pose via `_AIRFLOW_WWW_USER_*`.
AIRFLOW__CORE__AUTH_MANAGER: airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) : # Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) :
# memes identifiants que le backend en attendant. # memes identifiants que le backend en attendant.
@@ -110,11 +117,11 @@ services:
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont # Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
# portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte # portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte
# aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume # aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume
# `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son # `pgdata` deja peuple) fait echouer ce service, et api-server, dag-processor et scheduler,
# succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement, # qui attendent son succes, ne demarrent pas sur une base non migree. Le mot de passe passe
# jamais par `argv` (ni `ps`, ni `docker compose config`). # par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
# Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous # Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous
# refuse en plus les deux cles de chiffrement vides. # refuse en plus les cles et secrets vides.
airflow-init: airflow-init:
<<: *airflow-common <<: *airflow-common
restart: "no" restart: "no"
@@ -134,13 +141,14 @@ services:
- | - |
set -euo pipefail set -euo pipefail
: "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}" : "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}"
: "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}" : "$${AIRFLOW__API__SECRET_KEY:?AIRFLOW_API_SECRET_KEY manquant dans .env}"
: "$${AIRFLOW__API_AUTH__JWT_SECRET:?AIRFLOW_JWT_SECRET manquant dans .env}"
: "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}" : "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}"
exec airflow version exec airflow version
airflow-webserver: airflow-apiserver:
<<: *airflow-common <<: *airflow-common
command: webserver command: api-server
ports: ports:
- "${AIRFLOW_PORT:-8080}:8080" - "${AIRFLOW_PORT:-8080}:8080"
depends_on: depends_on:
@@ -149,7 +157,7 @@ services:
airflow-init: airflow-init:
condition: service_completed_successfully condition: service_completed_successfully
healthcheck: healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"] test: ["CMD", "curl", "--fail", "http://localhost:8080/api/v2/monitor/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@@ -164,6 +172,16 @@ services:
airflow-init: airflow-init:
condition: service_completed_successfully condition: service_completed_successfully
# Obligatoire depuis Airflow 3 : le scheduler ne parse plus les fichiers de dags/ lui-même.
airflow-dag-processor:
<<: *airflow-common
command: dag-processor
depends_on:
db:
condition: service_healthy
airflow-init:
condition: service_completed_successfully
volumes: volumes:
pgdata: pgdata:
airflow_logs: airflow_logs:
+16 -8
View File
@@ -48,20 +48,28 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin
- `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de - `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
le compose. Voir [`db/README.md`](../../db/README.md). le compose. Voir [`db/README.md`](../../db/README.md).
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver : - `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais de l'api-server :
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow). c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
### Airflow (issues #115 et #116) ### Airflow (issues #115 et #116)
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make Quatre services (Airflow 3.3), `docker compose profiles` non utilisés (démarrage explicite via `make
airflow-up`, pas dans `make dev`) : airflow-up`, pas dans `make dev`) :
| Service | Rôle | Points notables | | Service | Rôle | Points notables |
|---|---|---| |---|---|---|
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `webserver`/`scheduler` attendent qu'il se termine avec succès | | `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `api-server`, `dag-processor` et `scheduler` attendent qu'il se termine avec succès |
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même | | `airflow-apiserver` | UI et API REST (`/api/v2`), port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même. Sert aussi l'Execution API que les tâches appellent, d'où le secret JWT partagé |
| `airflow-dag-processor` | Parse `dags/` et publie les DAGs sérialisés | Composant à part entière depuis Airflow 3 : le scheduler ne lit plus les fichiers de DAG |
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` | | `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` |
Airflow 3 impose deux choses que le compose reflète : les tâches ne touchent plus la base de
métadonnées et passent par l'Execution API de l'`api-server`, avec un jeton signé par
`AIRFLOW_JWT_SECRET` (secret partagé entre conteneurs, jamais celui généré au démarrage) ; et
l'authentification par défaut (`SimpleAuthManager`) ne sait pas créer de compte, d'où le
`FabAuthManager` qui garde le compte admin posé par `airflow-init`. Pas de `triggerer` : aucun
opérateur déférable dans les DAGs.
Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) : Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) :
l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser
deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à
@@ -95,14 +103,14 @@ rend contraignant.
`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`,
`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une `_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une
migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et
`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin `api-server`, `dag-processor` et `scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`). passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier
entier avant de filtrer les services, une variable requise manquante casserait `make db-up`, entier avant de filtrer les services, une variable requise manquante casserait `make db-up`,
`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est `make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est
`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou `airflow-init` qui refuse de démarrer (clé Fernet, clé de session de l'API, secret JWT, mot de
`AIRFLOW_APP_SECRET_KEY` vides). passe ou `AIRFLOW_APP_SECRET_KEY` vides).
Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en
dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est
@@ -242,7 +250,7 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
| Base applicative | `enervision` | Variable `POSTGRES_DB` | | Base applicative | `enervision` | Variable `POSTGRES_DB` |
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` | | Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` | | Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre | | API server Airflow | `8080` | `make airflow-up`. Api-server, scheduler et dag-processor ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
## Le trou vers k3s ## Le trou vers k3s
+23 -31
View File
@@ -4,47 +4,39 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
## État actuel ## État actuel
Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, la Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée
supervision des capteurs (admin) et le flux des alertes actives, tous branchés sur l'API réelle. par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API.
Ce qui est en place : Ce qui est en place :
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. - Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
`provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`. `provideHttpClient(withInterceptors([mockApiInterceptor]))`.
- Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`, - Une route `/dashboard` en composant différé, et une redirection depuis la racine.
`/monitoring/sensors` réservée au rôle `admin`) et une redirection depuis la racine. - `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et
- `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses `AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification
filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`, (jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`,
`SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur `features/dashboard` la page principale, `shared/components` la jauge de consommation et le
d'authentification (jeton porteur, rafraîchissement sur 401), `core/guards` la garde `authGuard`. graphique de charge par site, tous deux construits sur Chart.js.
- `features/` porte une page par domaine. `shared/components` porte la jauge de consommation et
les graphiques Chart.js, le widget `app-alert-feed` (flux d'alertes filtrable par site et
sévérité, rafraîchi toutes les 60 s, première vue de l'application avec des états chargement /
vide / indisponible) et, dans `shared/models`, des types alignés sur les schémas Pydantic du
backend, plus les tables de présentation partagées (`alert-presentation.ts` : ton, libellé et
unité par sévérité, type et métrique).
- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation, - Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation,
changement de mot de passe, garde de route sur toute la zone authentifiée. Détail : changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail :
[31-contrat-authentification.md](31-contrat-authentification.md). [31-contrat-authentification.md](31-contrat-authentification.md).
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`, - Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
`ev-badge`, `ev-brand`, `ev-icon`, tokens CSS dans `styles/_tokens.scss`, classes globales de `ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit
formulaire, de navigation et de tableau) que toute nouvelle page doit réutiliser plutôt que réutiliser plutôt que redéfinir ses propres styles. Détail :
redéfinir ses propres styles. Détail :
[32-design-systeme-frontend.md](32-design-systeme-frontend.md). [32-design-systeme-frontend.md](32-design-systeme-frontend.md).
- L'état vit dans des signaux, sans bibliothèque dédiée. - L'état vit dans des signaux, sans bibliothèque dédiée.
- TypeScript en `"strict": true` ; `strictTemplates` n'est pas encore activé.
- Vitest via le builder `@angular/build:unit-test`, couverture activée. - Vitest via le builder `@angular/build:unit-test`, couverture activée.
- Prettier configuré, parser `angular` pour les gabarits HTML. - Prettier configuré, parser `angular` pour les gabarits HTML.
Ce qui n'existe pas encore : Ce qui n'existe pas encore :
- **Le mode fixtures est inactif.** `useMockFixtures` vaut `false` dans `environment.ts` comme dans - **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
`environment.development.ts` : `mockApiInterceptor` ne sert `/stats/summary` et `/alerts` que sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien
dans son propre spec. En développement, toutes les pages exigent un backend joignable et un jeton ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement.
valide. `GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette
- Un état de chargement généralisé : seul `app-alert-feed` en a un, les autres pages restent vides PR (voir plus bas).
tant que la première réponse n'est pas arrivée. - Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
- Aucun lint : ESLint n'est pas installé. - Aucun lint : ESLint n'est pas installé.
## Arborescence ## Arborescence
@@ -95,11 +87,11 @@ sequenceDiagram
``` ```
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si `mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
`environment.useMockFixtures` est vrai. Le drapeau vaut `false` dans les deux fichiers `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
d'environnement : en pratique toutes les requêtes suivent le chemin réel et l'intercepteur n'est production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
exercé que par son spec. `/predictions` et `/auth/*` ne sont de toute façon jamais mockés. En `/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il
développement, un jeton valide et un backend joignable sont donc nécessaires pour que le tableau de suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide
bord s'affiche. et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche.
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
@@ -20,18 +20,16 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
| `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) | | `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) |
| `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) | | `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) |
| `--font-family` | Police unique de l'application | | `--font-family` | Police unique de l'application |
| `--font-size-xs` à `--font-size-2xl` | Échelle typographique (0.75rem à 2.25rem) : libellés, corps, titres, grands nombres |
| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) | | `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) |
| `--shadow-card`, `--shadow-card-hover` | Ombre portée des cartes, au repos et au survol | | `--shadow-card` | Ombre portée des cartes |
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) | | `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`) Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles `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 s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
par un simple `(change)` : pas de composant `ControlValueAccessor` dédié, le gain n'en vaut pas la composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
complexité pour des formulaires aussi simples que ceux de ce projet. `.form-select` habille un aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
`<select>` natif avec la bordure et le focus de `.form-input`, plus un chevron. Les erreurs de `<ev-alert severity="danger">`, pas une classe dédiée.
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 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`, le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
@@ -85,23 +83,6 @@ dans le tableau `imports` du composant qui l'utilise.
```html ```html
<ev-brand /> <ev-brand />
``` ```
- **`<ev-icon>`** (`icon/`) : `name` (obligatoire : `spike` / `threshold` / `anomaly` / `outage` /
`sensor`, les types d'alerte du contrat) et `label` (facultatif). SVG inline en trait sur
`currentColor`, dimensionné par `font-size` comme `ev-brand`. Sans `label` l'icône est décorative
(`aria-hidden`) ; avec, elle porte `role="img"` et `aria-label`. Pas de bibliothèque d'icônes : la
CSP du reverse proxy (`script-src 'self'`) interdit les scripts tiers, pas le SVG inline.
```html
<ev-icon name="spike" label="Pic de consommation" />
```
- **`<app-alert-feed>`** (`shared/components/alert-feed/`) : widget métier plutôt qu'atome du kit,
mais réutilisable tel quel. Input `siteId` (facultatif : fige le site et masque son filtre). Il
porte ses filtres (`.form-select`), ses états et son rafraîchissement ; le parent ne fait que le
poser dans une section.
Les classes de tableau partagées sont dans `apps/frontend/src/styles/_tables.scss`, importées
globalement : `.ev-table-card` sur la `<ev-card>` qui enveloppe un tableau (padding nul),
`.ev-table` sur le `<table>`, `.ev-table__number` pour une cellule numérique en chiffres
tabulaires, `.ev-table__muted` pour une cellule sans valeur.
## Logo ## Logo
+4 -3
View File
@@ -81,9 +81,10 @@ rien changer), mais ce serait à borner sur un dépôt à forte fréquence de pu
`backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence `backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence
git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent. git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent.
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14, parce qu'Airflow 2.10 **Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14 : c'est l'interpréteur
ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image de l'image `apache/airflow:3.3.2-python3.12` retenue, et les tests d'intégrité doivent tourner sur
Docker et son propre environnement. le même. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image Docker et son propre
environnement.
## Ce qui bloque un merge ## Ce qui bloque un merge
+5 -6
View File
@@ -1,9 +1,9 @@
# Image Airflow EnerVision : ajoute ml/ et apps/backend/ dans leurs propres environnements Python # Image Airflow EnerVision : ajoute ml/ et apps/backend/ dans leurs propres environnements Python
# 3.14, distincts du Python 3.12 qui fait tourner Airflow lui-meme (apache-airflow 2.10 ne supporte # 3.14, distincts du Python 3.12 de l'image de base qui fait tourner Airflow lui-meme, pour que
# pas 3.14), pour que les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`, # les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`,
# `app.detection.internal_alerts` et `app.cli` en sous-processus. Airflow ne devient jamais un # `app.detection.internal_alerts` et `app.cli` en sous-processus. Airflow ne devient jamais un
# consommateur direct de LightGBM, de MLflow ou du SQLAlchemy du backend. Cf. ADR 0008. # consommateur direct de LightGBM, de MLflow ou du SQLAlchemy du backend. Cf. ADR 0008.
FROM apache/airflow:2.10.4-python3.12 FROM apache/airflow:3.3.2-python3.12
# LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans # LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans
# toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file` # toolchain de compilation). Sans lui : `OSError: libgomp.so.1: cannot open shared object file`
@@ -21,9 +21,8 @@ RUN apt-get update \
RUN mkdir -p /opt/ml/state /opt/backend && chown -R airflow:root /opt/ml /opt/backend RUN mkdir -p /opt/ml/state /opt/backend && chown -R airflow:root /opt/ml /opt/backend
USER airflow USER airflow
# L'image de base embarque deja un `uv`, mais trop ancien (0.4.29) pour le format de verrou de # L'image de base embarque deja un `uv`, mais pas celui que le depot epingle par ailleurs
# `ml/uv.lock`. On le remplace par la version deja pinnee ailleurs dans le depot # (apps/backend/Dockerfile) : on aligne, pour que le format de verrou lu soit le meme partout.
# (apps/backend/Dockerfile).
COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv COPY --from=ghcr.io/astral-sh/uv:0.11.26 /uv /home/airflow/.local/bin/uv
# Piege : pas de `UV_PROJECT_ENVIRONMENT` global. Il vaudrait pour les deux projets, et `uv run` # Piege : pas de `UV_PROJECT_ENVIRONMENT` global. Il vaudrait pour les deux projets, et `uv run`
+2 -2
View File
@@ -15,8 +15,8 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from airflow.models.dag import DAG from airflow.providers.standard.operators.bash import BashOperator
from airflow.operators.bash import BashOperator from airflow.sdk import DAG
# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et # Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et
# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement. # `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement.
+2 -2
View File
@@ -10,8 +10,8 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from airflow.models.dag import DAG from airflow.providers.standard.operators.bash import BashOperator
from airflow.operators.bash import BashOperator from airflow.sdk import DAG
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt" MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
+2 -2
View File
@@ -11,8 +11,8 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from airflow.models.dag import DAG from airflow.providers.standard.operators.bash import BashOperator
from airflow.operators.bash import BashOperator from airflow.sdk import DAG
MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt" MODEL_PATH = "/opt/ml/state/models/lightgbm-consumption.txt"
MLFLOW_TRACKING_URI = "sqlite:////opt/ml/state/mlflow.db" MLFLOW_TRACKING_URI = "sqlite:////opt/ml/state/mlflow.db"
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
description = "DAGs d'orchestration EnerVision (Airflow)" description = "DAGs d'orchestration EnerVision (Airflow)"
requires-python = ">=3.12,<3.13" requires-python = ">=3.12,<3.13"
dependencies = [ dependencies = [
"apache-airflow==2.10.4", "apache-airflow==3.3.2",
] ]
[dependency-groups] [dependency-groups]
+7 -7
View File
@@ -5,8 +5,8 @@ from datetime import timedelta
from pathlib import Path from pathlib import Path
import pytest import pytest
from airflow.models.baseoperator import BaseOperator from airflow.dag_processing.dagbag import DagBag
from airflow.models.dagbag import DagBag from airflow.sdk import BaseOperator
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags" DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
@@ -21,7 +21,7 @@ TACHES = [
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def dagbag() -> DagBag: def dagbag() -> DagBag:
return DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False) return DagBag(dag_folder=str(DAGS_FOLDER))
def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None: def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None:
@@ -33,18 +33,18 @@ def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None:
def test_ml_train_has_no_schedule(dagbag: DagBag) -> None: def test_ml_train_has_no_schedule(dagbag: DagBag) -> None:
assert dagbag.dags["ml_train"].timetable.summary == "None" assert dagbag.dags["ml_train"].schedule is None
def test_ml_score_runs_every_hour(dagbag: DagBag) -> None: def test_ml_score_runs_every_hour(dagbag: DagBag) -> None:
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend. # `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que la timetable le rend.
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *" assert dagbag.dags["ml_score"].timetable.expression == "0 * * * *"
def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None: def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None:
# Le decalage n'est pas cosmetique : la regle `anomaly` compare une lecture a la `prediction` # Le decalage n'est pas cosmetique : la regle `anomaly` compare une lecture a la `prediction`
# du meme instant, que `ml_score` ecrit a l'heure pile. # du meme instant, que `ml_score` ecrit a l'heure pile.
assert dagbag.dags["alertes"].timetable.summary == "15 * * * *" assert dagbag.dags["alertes"].timetable.expression == "15 * * * *"
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None: def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
+554 -653
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. - `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git.
- `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`. - `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`.
Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la Pas de `Dockerfile` : l'image officielle `nginx:1.31-alpine` est utilisée telle quelle et la
configuration est montée en volume par `docker-compose.prod.yml`. configuration est montée en volume par `docker-compose.prod.yml`.
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4 L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
@@ -82,7 +82,7 @@ docker run --rm \
-v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \ -v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \ -v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \
-v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \ -v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \
nginx:1.28-alpine nginx -t nginx:1.31-alpine nginx -t
``` ```
Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image. Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image.