Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e504bee72 | ||
|
|
32155cbfc5 | ||
|
|
d16de84559 | ||
|
|
90639b5618 | ||
|
|
e147ea69bc | ||
|
|
83caa9006e | ||
|
|
91752a2b0b | ||
|
|
3c378c177f | ||
|
|
2adfdf0eb0 | ||
|
|
44f3416ffe | ||
|
|
342128ccff | ||
|
|
62d81e901d | ||
|
|
31886944ab | ||
|
|
63cbeafe3b | ||
|
|
f8d08c8686 | ||
|
|
be44b97d7a | ||
|
|
306c5a52e5 | ||
|
|
801379f956 | ||
|
|
2686880185 | ||
|
|
ae58a896d9 | ||
|
|
5a29faaa16 | ||
|
|
bc75528616 | ||
|
|
d7457e9fd9 | ||
|
|
3cd9a6b272 | ||
|
|
26f834485c | ||
|
|
777cd0ac64 | ||
|
|
c528ed239b | ||
|
|
a88e51c92a | ||
|
|
f3ea2785b3 | ||
|
|
901ceffd72 | ||
|
|
5545c166fd | ||
|
|
f0ad8e9990 | ||
|
|
3c01ab3ecc | ||
|
|
0c487fa7be | ||
|
|
b3efb98208 | ||
|
|
2d7b4bd74d | ||
|
|
f9c2a4610c | ||
|
|
b5fa7b0010 | ||
|
|
7f710c9084 | ||
|
|
c3fd9327ea |
@@ -17,6 +17,11 @@ APP_LOG_LEVEL=INFO
|
|||||||
APP_SECRET_KEY=change_me
|
APP_SECRET_KEY=change_me
|
||||||
APP_CORS_ORIGINS=http://localhost:4200
|
APP_CORS_ORIGINS=http://localhost:4200
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
FRONTEND_PORT=3000
|
||||||
|
|
||||||
|
# Mailpit capture les courriels du backend, rien ne sort vers l'extérieur.
|
||||||
|
MAILPIT_SMTP_PORT=1025
|
||||||
|
MAILPIT_UI_PORT=8025
|
||||||
|
|
||||||
# API Mock EnerVision
|
# API Mock EnerVision
|
||||||
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
|
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
|
||||||
@@ -38,3 +43,13 @@ AIRFLOW_ADMIN_USERNAME=admin
|
|||||||
# comptes `app_user` d'EnerVision.
|
# comptes `app_user` d'EnerVision.
|
||||||
AIRFLOW_ADMIN_PASSWORD=change_me
|
AIRFLOW_ADMIN_PASSWORD=change_me
|
||||||
AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
|
AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
|
||||||
|
# `APP_SECRET_KEY` du backend, que le DAG `alertes` lance en sous-processus. Distincte de
|
||||||
|
# celle de l'API : la détection ne signe aucun jeton, et Airflow exécute du code depuis son
|
||||||
|
# interface (cf. ADR 0008). Générer la vôtre :
|
||||||
|
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
AIRFLOW_APP_SECRET_KEY=change_me
|
||||||
|
|
||||||
|
# Stack complète derrière le reverse proxy (docker-compose.prod.yml).
|
||||||
|
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
|
||||||
|
PUBLIC_HOST=enervision.local
|
||||||
|
ACME_EMAIL=
|
||||||
|
|||||||
@@ -38,3 +38,9 @@ updates:
|
|||||||
directory: "/apps/frontend"
|
directory: "/apps/frontend"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
|
||||||
|
# Images du reverse proxy et du compagnon ACME, épinglées dans les fichiers Compose
|
||||||
|
- package-ecosystem: "docker-compose"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
name: Airflow
|
||||||
|
|
||||||
|
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
|
||||||
|
# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de
|
||||||
|
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "etl/airflow/**"
|
||||||
|
- "ml/pyproject.toml"
|
||||||
|
- "ml/uv.lock"
|
||||||
|
- "ml/enervision_ml/**"
|
||||||
|
- "apps/backend/pyproject.toml"
|
||||||
|
- "apps/backend/uv.lock"
|
||||||
|
- "apps/backend/app/**"
|
||||||
|
- ".github/workflows/airflow.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "etl/airflow/**"
|
||||||
|
- "ml/pyproject.toml"
|
||||||
|
- "ml/uv.lock"
|
||||||
|
- "ml/enervision_ml/**"
|
||||||
|
- "apps/backend/pyproject.toml"
|
||||||
|
- "apps/backend/uv.lock"
|
||||||
|
- "apps/backend/app/**"
|
||||||
|
- ".github/workflows/airflow.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: airflow-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verification:
|
||||||
|
name: Lint et intégrité des DAGs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: etl/airflow
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: etl/airflow/uv.lock
|
||||||
|
|
||||||
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
|
run: uv python install
|
||||||
|
|
||||||
|
- name: Synchronise les dépendances sans dévier du verrou
|
||||||
|
run: uv sync --all-groups --frozen
|
||||||
|
|
||||||
|
- name: Vérifie le formatage
|
||||||
|
run: uv run ruff format --check .
|
||||||
|
|
||||||
|
- name: Analyse statique
|
||||||
|
run: uv run ruff check --output-format=github .
|
||||||
|
|
||||||
|
# Aucun test ne lance de tâche ni de scheduler : DagBag charge les fichiers de dags/ et
|
||||||
|
# vérifie import, planification, plafonds d'exécution et commande de chaque tâche.
|
||||||
|
- name: Tests d'intégrité des DAGs
|
||||||
|
run: uv run pytest
|
||||||
|
|
||||||
|
image:
|
||||||
|
name: Construction de l'image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
|
||||||
|
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
|
||||||
|
|
||||||
|
# Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent
|
||||||
|
# (`OSError: libgomp.so.1` au premier import) ou environnement ml/ non figé.
|
||||||
|
- name: Vérifie que le pipeline ML s'importe sans réseau
|
||||||
|
run: >
|
||||||
|
docker run --rm --network none enervision-airflow:ci
|
||||||
|
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
|
||||||
|
|
||||||
|
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
|
||||||
|
# l'import du module prouve que l'environnement /opt/backend est complet. Les deux
|
||||||
|
# commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui.
|
||||||
|
- name: Vérifie que les deux commandes du DAG alertes s'importent sans réseau
|
||||||
|
run: >
|
||||||
|
docker run --rm --network none enervision-airflow:ci
|
||||||
|
bash -c "cd /opt/backend
|
||||||
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
|
||||||
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help"
|
||||||
@@ -140,3 +140,29 @@ jobs:
|
|||||||
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
|
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
|
||||||
shell: bash
|
shell: bash
|
||||||
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
|
run: uv export --frozen --no-dev --no-emit-project --no-hashes | uvx pip-audit --requirement /dev/stdin --no-deps
|
||||||
|
|
||||||
|
sast:
|
||||||
|
name: Analyse statique de sécurité
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||||
|
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
|
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
|
||||||
|
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
|
||||||
|
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||||
|
run: uvx bandit==1.9.4 --recursive app --severity-level medium --confidence-level medium
|
||||||
|
|
||||||
|
# Piège : sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace.
|
||||||
|
- name: Rapport complet, tous niveaux
|
||||||
|
continue-on-error: true
|
||||||
|
run: uvx bandit==1.9.4 --recursive app
|
||||||
|
|||||||
@@ -57,3 +57,26 @@ jobs:
|
|||||||
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
|
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: uv run pytest
|
run: uv run pytest
|
||||||
|
|
||||||
|
sast:
|
||||||
|
name: Analyse statique de sécurité
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Récupère le dépôt
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||||
|
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
|
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||||
|
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
|
||||||
|
|
||||||
|
- name: Rapport complet, tous niveaux
|
||||||
|
continue-on-error: true
|
||||||
|
run: uvx bandit==1.9.4 --recursive enervision_ml
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "apps/frontend/**"
|
- "apps/frontend/**"
|
||||||
- "apps/backend/**"
|
- "apps/backend/**"
|
||||||
|
- "ml/**"
|
||||||
|
- "etl/airflow/**"
|
||||||
- ".github/workflows/sonarqube.yml"
|
- ".github/workflows/sonarqube.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "apps/frontend/**"
|
- "apps/frontend/**"
|
||||||
- "apps/backend/**"
|
- "apps/backend/**"
|
||||||
|
- "ml/**"
|
||||||
|
- "etl/airflow/**"
|
||||||
- ".github/workflows/sonarqube.yml"
|
- ".github/workflows/sonarqube.yml"
|
||||||
|
|
||||||
|
|
||||||
@@ -108,8 +112,36 @@ jobs:
|
|||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
path: apps/backend/coverage.xml
|
path: apps/backend/coverage.xml
|
||||||
|
|
||||||
|
test-ml:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: ml/uv.lock
|
||||||
|
|
||||||
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
|
run: uv python install
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Synchronise les dépendances sans dévier du verrou
|
||||||
|
run: uv sync --all-groups --frozen
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Lancement des tests et génération du rapport de couverture (ML)
|
||||||
|
run: uv run pytest --cov-report=xml
|
||||||
|
working-directory: ml
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ml-coverage
|
||||||
|
path: ml/coverage.xml
|
||||||
|
|
||||||
sonarqube:
|
sonarqube:
|
||||||
needs: [build-front, build-back, test-front, test-back]
|
needs: [build-front, build-back, test-front, test-back, test-ml]
|
||||||
name: SonarQube
|
name: SonarQube
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -126,6 +158,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
path: apps/backend
|
path: apps/backend
|
||||||
|
- name: Téléchargement du rapport de couverture (ML)
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ml-coverage
|
||||||
|
path: ml
|
||||||
- name: SonarQube Scan
|
- name: SonarQube Scan
|
||||||
uses: SonarSource/sonarqube-scan-action@v8
|
uses: SonarSource/sonarqube-scan-action@v8
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ ml/mlflow.db
|
|||||||
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
||||||
etl/airflow/tests/.airflow_home/
|
etl/airflow/tests/.airflow_home/
|
||||||
|
|
||||||
|
# TLS : certificats du reverse proxy, générés par script ou par certbot
|
||||||
|
infra/proxy/tls/*.pem
|
||||||
|
|
||||||
# IDE et OS
|
# IDE et OS
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -2,14 +2,25 @@ BACKEND := apps/backend
|
|||||||
FRONTEND := apps/frontend
|
FRONTEND := apps/frontend
|
||||||
ML := ml
|
ML := ml
|
||||||
AIRFLOW := etl/airflow
|
AIRFLOW := etl/airflow
|
||||||
|
COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml
|
||||||
|
|
||||||
|
# Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose.
|
||||||
|
# PUBLIC_HOST retombe sur le `.env`, que make ne lit pas, puis sur la valeur de `.env.example`.
|
||||||
|
PUBLIC_HOST ?= $(shell sed -n 's/^PUBLIC_HOST=//p' .env 2>/dev/null | tail -1)
|
||||||
|
PUBLIC_HOST := $(or $(strip $(PUBLIC_HOST)),enervision.local)
|
||||||
|
export PUBLIC_HOST
|
||||||
|
ifdef ACME_EMAIL
|
||||||
|
export ACME_EMAIL
|
||||||
|
endif
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
||||||
dev dev-backend dev-frontend \
|
dev dev-backend dev-frontend \
|
||||||
lint format typecheck test test-cov test-integration check \
|
lint format typecheck test test-cov test-integration check \
|
||||||
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
|
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
|
||||||
ml-lint ml-typecheck ml-test ml-check ml-train ml-score recommendations \
|
ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \
|
||||||
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs
|
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
|
||||||
|
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
|
||||||
|
|
||||||
help: ## Liste les cibles disponibles
|
help: ## Liste les cibles disponibles
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||||
@@ -83,6 +94,9 @@ ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DAT
|
|||||||
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
|
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
|
||||||
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
|
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
|
||||||
|
|
||||||
|
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
|
||||||
|
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
|
||||||
|
|
||||||
recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel
|
recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel
|
||||||
cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),)
|
cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),)
|
||||||
|
|
||||||
@@ -107,6 +121,35 @@ airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâche
|
|||||||
docker-build: ## Construit l'image du backend
|
docker-build: ## Construit l'image du backend
|
||||||
docker build -t enervision-backend:local $(BACKEND)
|
docker build -t enervision-backend:local $(BACKEND)
|
||||||
|
|
||||||
|
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
|
||||||
|
./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
|
||||||
|
|
||||||
|
stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin
|
||||||
|
@test -f infra/proxy/tls/fullchain.pem \
|
||||||
|
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
|
||||||
|
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|
||||||
|
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
|
||||||
|
$(COMPOSE_PROD) up -d --build
|
||||||
|
|
||||||
|
stack-down: ## Arrête la stack complète en conservant les données
|
||||||
|
$(COMPOSE_PROD) stop
|
||||||
|
|
||||||
|
stack-logs: ## Suit les journaux du reverse proxy
|
||||||
|
$(COMPOSE_PROD) logs -f proxy
|
||||||
|
|
||||||
|
tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST public et ACME_EMAIL requis
|
||||||
|
@test "$(PUBLIC_HOST)" != enervision.local \
|
||||||
|
|| { echo "PUBLIC_HOST doit être un domaine public résolvable, pas le nom de démonstration"; exit 1; }
|
||||||
|
$(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||||
|
-d $(PUBLIC_HOST) \
|
||||||
|
--email $${ACME_EMAIL:?ACME_EMAIL=... requis} \
|
||||||
|
--agree-tos --no-eff-email --deploy-hook /deploy-hook.sh
|
||||||
|
$(COMPOSE_PROD) exec proxy nginx -s reload
|
||||||
|
|
||||||
|
tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy
|
||||||
|
$(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh
|
||||||
|
$(COMPOSE_PROD) exec proxy nginx -s reload
|
||||||
|
|
||||||
db-up: ## Démarre la base PostgreSQL TimescaleDB
|
db-up: ## Démarre la base PostgreSQL TimescaleDB
|
||||||
docker compose up -d db
|
docker compose up -d db
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
|||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
||||||
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
| 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 |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
| CI/CD | GitHub Actions | `.github/workflows` | Backend 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` | Entrainement initialise |
|
||||||
@@ -47,11 +48,13 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
|
|||||||
│ ├── migrations/ Migrations SQL versionnees
|
│ ├── migrations/ Migrations SQL versionnees
|
||||||
│ └── seeds/ Jeux de donnees de reference
|
│ └── seeds/ Jeux de donnees de reference
|
||||||
├── etl/airflow/
|
├── etl/airflow/
|
||||||
│ ├── dags/ DAGs d'ingestion et d'agregation
|
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes)
|
||||||
│ ├── plugins/ Operateurs et hooks maison
|
│ ├── plugins/ Operateurs et hooks maison
|
||||||
│ ├── include/ Requetes SQL et ressources des DAGs
|
│ ├── include/ Requetes SQL et ressources des DAGs
|
||||||
│ └── tests/ Tests d'integrite des DAGs
|
│ └── tests/ Tests d'integrite des DAGs
|
||||||
├── infra/terraform/
|
├── infra/
|
||||||
|
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
|
||||||
|
│ └── terraform/
|
||||||
│ ├── modules/ Modules reutilisables
|
│ ├── modules/ Modules reutilisables
|
||||||
│ └── environments/ Racines Terraform, une par environnement
|
│ └── environments/ Racines Terraform, une par environnement
|
||||||
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
|
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
|
||||||
@@ -98,6 +101,21 @@ Verifier que la base repond et que l'extension est chargee :
|
|||||||
curl -s localhost:8000/api/v1/health/ready
|
curl -s localhost:8000/api/v1/health/ready
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Stack complète derrière le reverse proxy
|
||||||
|
|
||||||
|
Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine.
|
||||||
|
L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus récent** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration
|
||||||
|
make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, rien d'autre n'est publié
|
||||||
|
```
|
||||||
|
|
||||||
|
Le navigateur avertit d'un émetteur inconnu : Let's Encrypt reste hors d'atteinte tant qu'aucun
|
||||||
|
nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvellement dans
|
||||||
|
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
|
||||||
|
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ WORKDIR /app
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
uv sync --locked --no-install-project --no-dev
|
uv sync --locked --no-install-project --no-dev --no-build
|
||||||
|
|
||||||
|
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
|
||||||
|
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
|
||||||
|
# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que
|
||||||
|
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
||||||
uv sync --locked --no-dev
|
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.14-slim AS runtime
|
FROM python:3.14-slim AS runtime
|
||||||
|
|
||||||
|
|||||||
@@ -112,8 +112,10 @@ async def test_duplicate_reading_is_rejected_when_key_matches(
|
|||||||
)
|
)
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
@@ -147,9 +149,12 @@ async def test_invalid_reading_is_rejected_when_constraints_fail(
|
|||||||
}
|
}
|
||||||
values.update(changes)
|
values.update(changes)
|
||||||
|
|
||||||
|
statement = insert(Reading).values(**values)
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(insert(Reading).values(**values))
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
async def test_prediction_requires_period_when_energy_is_predicted(
|
async def test_prediction_requires_period_when_energy_is_predicted(
|
||||||
@@ -164,8 +169,10 @@ async def test_prediction_requires_period_when_energy_is_predicted(
|
|||||||
model_reference="test-model/1",
|
model_reference="test-model/1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
@@ -212,10 +219,7 @@ async def test_alert_rejects_prediction_when_site_differs(
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
statement = insert(Alert).values(
|
||||||
async with data_connection.begin_nested():
|
|
||||||
await data_connection.execute(
|
|
||||||
insert(Alert).values(
|
|
||||||
source_alert_id=str(uuid4()),
|
source_alert_id=str(uuid4()),
|
||||||
site_id=other_site,
|
site_id=other_site,
|
||||||
source="enervision",
|
source="enervision",
|
||||||
@@ -226,7 +230,11 @@ async def test_alert_rejects_prediction_when_site_differs(
|
|||||||
prediction_id=prediction_id,
|
prediction_id=prediction_id,
|
||||||
raw_data={},
|
raw_data={},
|
||||||
)
|
)
|
||||||
)
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
async with savepoint:
|
||||||
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
async def test_recommendation_is_unique_when_alert_and_rule_match(
|
async def test_recommendation_is_unique_when_alert_and_rule_match(
|
||||||
@@ -256,6 +264,8 @@ async def test_recommendation_is_unique_when_alert_and_rule_match(
|
|||||||
)
|
)
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|
||||||
|
savepoint = data_connection.begin_nested()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
async with data_connection.begin_nested():
|
async with savepoint:
|
||||||
await data_connection.execute(statement)
|
await data_connection.execute(statement)
|
||||||
|
|||||||
@@ -106,13 +106,15 @@ def test_validate_source_accepts_valid_dataset():
|
|||||||
def test_validate_source_rejects_missing_column():
|
def test_validate_source_rejects_missing_column():
|
||||||
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
||||||
|
|
||||||
|
metadata = make_metadata()
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="Colonnes obligatoires absentes",
|
match="Colonnes obligatoires absentes",
|
||||||
):
|
):
|
||||||
validate_source(
|
validate_source(
|
||||||
frame,
|
frame,
|
||||||
make_metadata(),
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,13 +126,15 @@ def test_validate_source_rejects_duplicates():
|
|||||||
"timestamp",
|
"timestamp",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
metadata = make_metadata()
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="doublons",
|
match="doublons",
|
||||||
):
|
):
|
||||||
validate_source(
|
validate_source(
|
||||||
frame,
|
frame,
|
||||||
make_metadata(),
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,13 +143,15 @@ def test_validate_source_rejects_unknown_site():
|
|||||||
|
|
||||||
frame.loc[1, "site_id"] = "SITE999"
|
frame.loc[1, "site_id"] = "SITE999"
|
||||||
|
|
||||||
|
metadata = make_metadata()
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="Sites incohérents",
|
match="Sites incohérents",
|
||||||
):
|
):
|
||||||
validate_source(
|
validate_source(
|
||||||
frame,
|
frame,
|
||||||
make_metadata(),
|
metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ async def test_the_database_refuses_to_mutate_the_audit_log(
|
|||||||
) -> None:
|
) -> None:
|
||||||
await une_ligne(session)
|
await une_ligne(session)
|
||||||
|
|
||||||
|
requete = text(instruction)
|
||||||
|
|
||||||
with pytest.raises(DBAPIError, match="ajout seul"):
|
with pytest.raises(DBAPIError, match="ajout seul"):
|
||||||
await session.execute(text(instruction))
|
await session.execute(requete)
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -131,11 +131,14 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
|||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
empreinte = fingerprint_refresh(secret)
|
||||||
|
expiration = datetime.now(UTC) + DUREE
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
await depot.create(
|
await depot.create(
|
||||||
user_id=compte,
|
user_id=compte,
|
||||||
token_hash=fingerprint_refresh(secret),
|
token_hash=empreinte,
|
||||||
expires_at=datetime.now(UTC) + DUREE,
|
expires_at=expiration,
|
||||||
client_ip=None,
|
client_ip=None,
|
||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -178,12 +178,16 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
|||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
famille = uuid.uuid4()
|
||||||
|
empreinte = fingerprint_refresh(secret)
|
||||||
|
expiration = datetime.now(UTC) + DUREE
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
await depot.create(
|
await depot.create(
|
||||||
user_id=compte,
|
user_id=compte,
|
||||||
family_id=uuid.uuid4(),
|
family_id=famille,
|
||||||
token_hash=fingerprint_refresh(secret),
|
token_hash=empreinte,
|
||||||
expires_at=datetime.now(UTC) + DUREE,
|
expires_at=expiration,
|
||||||
client_ip=None,
|
client_ip=None,
|
||||||
user_agent=None,
|
user_agent=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,14 +31,12 @@ async def test_the_database_refuses_an_email_written_in_upper_case(
|
|||||||
) -> None:
|
) -> None:
|
||||||
saisie = adresse().upper()
|
saisie = adresse().upper()
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
requete = text(
|
||||||
await session.execute(
|
"insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
|
||||||
text(
|
|
||||||
"insert into app_user (email, password_hash, role) "
|
|
||||||
"values (:e, '$argon2id$x', 'lecteur')"
|
|
||||||
),
|
|
||||||
{"e": saisie},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await session.execute(requete, {"e": saisie})
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,13 +116,11 @@ async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
|
|||||||
async def test_list_history_raises_when_start_is_after_end() -> None:
|
async def test_list_history_raises_when_start_is_after_end() -> None:
|
||||||
service = ReadingService(readings=FakeRepository([]))
|
service = ReadingService(readings=FakeRepository([]))
|
||||||
|
|
||||||
|
debut = datetime(2026, 9, 2, tzinfo=UTC)
|
||||||
|
fin = datetime(2026, 9, 1, tzinfo=UTC)
|
||||||
|
|
||||||
with pytest.raises(FenetreInverseeError):
|
with pytest.raises(FenetreInverseeError):
|
||||||
await service.list_history(
|
await service.list_history(start=debut, end=fin, limit=500, offset=0)
|
||||||
start=datetime(2026, 9, 2, tzinfo=UTC),
|
|
||||||
end=datetime(2026, 9, 1, tzinfo=UTC),
|
|
||||||
limit=500,
|
|
||||||
offset=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_history_raises_when_start_equals_end() -> None:
|
async def test_list_history_raises_when_start_equals_end() -> None:
|
||||||
|
|||||||
@@ -235,5 +235,7 @@ async def test_every_operation_refuses_an_unknown_account(action: str) -> None:
|
|||||||
if action == "set_active":
|
if action == "set_active":
|
||||||
arguments["is_active"] = False
|
arguments["is_active"] = False
|
||||||
|
|
||||||
|
methode = getattr(attirail.service, action)
|
||||||
|
|
||||||
with pytest.raises(UserNotFoundError):
|
with pytest.raises(UserNotFoundError):
|
||||||
await getattr(attirail.service, action)(**arguments)
|
await methode(**arguments)
|
||||||
|
|||||||
@@ -19,13 +19,17 @@ def test_build_parser_reads_the_create_admin_arguments() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_build_parser_requires_a_subcommand() -> None:
|
def test_build_parser_requires_a_subcommand() -> None:
|
||||||
|
parser = cli.build_parser()
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cli.build_parser().parse_args([])
|
parser.parse_args([])
|
||||||
|
|
||||||
|
|
||||||
def test_build_parser_requires_an_email() -> None:
|
def test_build_parser_requires_an_email() -> None:
|
||||||
|
parser = cli.build_parser()
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cli.build_parser().parse_args(["create-admin"])
|
parser.parse_args(["create-admin"])
|
||||||
|
|
||||||
|
|
||||||
def test_read_password_generates_a_long_secret_when_asked(
|
def test_read_password_generates_a_long_secret_when_asked(
|
||||||
|
|||||||
@@ -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,3 +83,6 @@ 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)
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"optimization": {
|
||||||
|
"styles": {
|
||||||
|
"inlineCritical": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
|
|||||||
@@ -1,21 +1,36 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import {authGuard} from './core/guards/auth-guard';
|
import { authGuard } from './core/guards/auth-guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
|
{
|
||||||
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
|
path: 'login',
|
||||||
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
|
loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
|
||||||
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
|
},
|
||||||
|
{
|
||||||
|
path: 'change-password',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/change-password/change-password').then((m) => m.ChangePassword),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgot-password',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reset-password',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
|
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'sites',
|
path: 'sites',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
|
loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'sites/:siteId',
|
path: 'sites/:siteId',
|
||||||
@@ -23,4 +38,17 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'recommendations',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/recommendations/recommendations').then((m) => m.RecommendationsView),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'monitoring/sensors',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
data: { role: 'admin' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
|
|||||||
|
|
||||||
export const ALERTS_FIXTURE: Alert[] = [
|
export const ALERTS_FIXTURE: Alert[] = [
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE002-1718458320',
|
alert_id: 5,
|
||||||
timestamp: '2026-09-15T11:12:00',
|
|
||||||
site_id: 'SITE002',
|
site_id: 'SITE002',
|
||||||
|
timestamp: '2026-09-15T11:12:00Z',
|
||||||
|
type: 'threshold',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
type: 'outage',
|
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
|
||||||
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: 'ALR-SITE003-1718458321',
|
alert_id: 4,
|
||||||
timestamp: '2026-09-15T11:05:00',
|
|
||||||
site_id: 'SITE003',
|
site_id: 'SITE003',
|
||||||
|
timestamp: '2026-09-15T11:05:00Z',
|
||||||
|
type: 'outage',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
type: 'sensor',
|
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
|
||||||
message: 'Perte réseau totale sur Data Center Marseille',
|
value: null,
|
||||||
value: 0,
|
threshold: null,
|
||||||
threshold: 0,
|
metric: null,
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE005-1718458322',
|
alert_id: 3,
|
||||||
timestamp: '2026-09-15T10:47:00',
|
|
||||||
site_id: 'SITE005',
|
site_id: 'SITE005',
|
||||||
|
timestamp: '2026-09-15T10:47:00Z',
|
||||||
|
type: 'spike',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
type: 'threshold',
|
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
|
||||||
message: 'Usine Toulouse approche de son seuil de capacité',
|
|
||||||
value: 410.0,
|
value: 410.0,
|
||||||
threshold: 480.0,
|
threshold: 260.0,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE006-1718458323',
|
alert_id: 2,
|
||||||
timestamp: '2026-09-15T10:30:00',
|
|
||||||
site_id: 'SITE006',
|
site_id: 'SITE006',
|
||||||
severity: 'medium',
|
timestamp: '2026-09-15T10:30:00Z',
|
||||||
type: 'sensor',
|
type: 'sensor',
|
||||||
message: 'Capteur de température défaillant sur Bureau Lille',
|
severity: 'medium',
|
||||||
value: 0,
|
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
|
||||||
threshold: 0,
|
value: null,
|
||||||
|
threshold: null,
|
||||||
|
metric: null,
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE004-1718458324',
|
alert_id: 1,
|
||||||
timestamp: '2026-09-15T09:58:00',
|
|
||||||
site_id: 'SITE004',
|
site_id: 'SITE004',
|
||||||
severity: 'low',
|
timestamp: '2026-09-15T09:58:00Z',
|
||||||
type: 'anomaly',
|
type: 'anomaly',
|
||||||
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
|
severity: 'low',
|
||||||
|
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
|
||||||
value: 62.0,
|
value: 62.0,
|
||||||
threshold: 55.0,
|
threshold: 55.0,
|
||||||
|
metric: 'consumption_kwh',
|
||||||
|
prediction_id: 42,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http';
|
|||||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
import { 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;
|
||||||
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
|
|||||||
|
|
||||||
afterEach(() => httpMock.verify());
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
|
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
|
||||||
let result: unknown;
|
let result: Alert[] = [];
|
||||||
service.getAlerts().subscribe((r) => (result = r));
|
service.getAlerts().subscribe((r) => (result = r));
|
||||||
|
|
||||||
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
|
const req = httpMock.expectOne(
|
||||||
expect(req.request.method).toBe('GET');
|
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
|
||||||
|
);
|
||||||
|
expect(req.request.params.keys()).toEqual([]);
|
||||||
|
req.flush([ALERT_API]);
|
||||||
|
|
||||||
req.flush([
|
expect(result.length).toBe(1);
|
||||||
{
|
expect(result[0].alert_id).toBe(1);
|
||||||
alert_id: 'ALR-TEST-1',
|
expect(result[0].prediction_id).toBeNull();
|
||||||
timestamp: '2026-09-15T12:00:00',
|
});
|
||||||
site_id: 'SITE001',
|
|
||||||
severity: 'high',
|
|
||||||
type: 'threshold',
|
|
||||||
message: 'Test',
|
|
||||||
value: 100,
|
|
||||||
threshold: 90,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect((result as unknown[]).length).toBe(1);
|
it('transmet les filtres site_id et severity en paramètres de requête', () => {
|
||||||
|
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||||
|
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||||
|
expect(req.request.params.get('severity')).toBe('high');
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne pose pas de paramètre pour un filtre omis', () => {
|
||||||
|
service.getAlerts({ site_id: 'SITE001' }).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||||
|
expect(req.request.params.has('severity')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { Service, inject } from '@angular/core';
|
import { Service, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { Alert } from '../../shared/models/alert.model';
|
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
||||||
|
|
||||||
|
export interface AlertFilters {
|
||||||
|
site_id?: string;
|
||||||
|
severity?: AlertSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class AlertsService {
|
export class AlertsService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
getAlerts() {
|
getAlerts(filters: AlertFilters = {}) {
|
||||||
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
|
let params = new HttpParams();
|
||||||
|
if (filters.site_id) {
|
||||||
|
params = params.set('site_id', filters.site_id);
|
||||||
|
}
|
||||||
|
if (filters.severity) {
|
||||||
|
params = params.set('severity', filters.severity);
|
||||||
|
}
|
||||||
|
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { RecommendationsService } from './recommendations.service';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Recommendation } from '../../shared/models/recommendation.model';
|
||||||
|
|
||||||
|
const RECOMMANDATION_API: Recommendation = {
|
||||||
|
recommendation_id: 1,
|
||||||
|
alert_id: 1,
|
||||||
|
action: 'Vérifier la consommation',
|
||||||
|
explanation: 'Pic détecté',
|
||||||
|
rule_reference: 'spike-v1',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RecommendationsService', () => {
|
||||||
|
let service: RecommendationsService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(RecommendationsService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('liste les recommandations depuis le bon endpoint', () => {
|
||||||
|
let result: Recommendation[] = [];
|
||||||
|
service.getRecommendations().subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush([RECOMMANDATION_API]);
|
||||||
|
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].alert_id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('décrit une recommandation par son identifiant', () => {
|
||||||
|
service.getRecommendation(42).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations/42`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush({ ...RECOMMANDATION_API, recommendation_id: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('déclenche la génération en POST avec le site en paramètre de requête', () => {
|
||||||
|
let result: unknown;
|
||||||
|
service.generate('SITE001').subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(
|
||||||
|
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
|
||||||
|
);
|
||||||
|
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||||
|
expect(req.request.body).toBeNull();
|
||||||
|
req.flush({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
|
||||||
|
|
||||||
|
expect(result).toEqual({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('génère pour tout le parc quand aucun site n’est donné', () => {
|
||||||
|
service.generate().subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(
|
||||||
|
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
|
||||||
|
);
|
||||||
|
expect(req.request.params.has('site_id')).toBe(false);
|
||||||
|
req.flush({ alerts_examined: 0, recommendations_created: 0, already_present: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
Recommendation,
|
||||||
|
RecommendationGenerationReport,
|
||||||
|
} from '../../shared/models/recommendation.model';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RecommendationsService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
getRecommendations() {
|
||||||
|
return this.http.get<Recommendation[]>(`${environment.apiUrl}/recommendations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecommendation(recommendationId: number) {
|
||||||
|
return this.http.get<Recommendation>(
|
||||||
|
`${environment.apiUrl}/recommendations/${recommendationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(siteId?: string) {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (siteId) {
|
||||||
|
params = params.set('site_id', siteId);
|
||||||
|
}
|
||||||
|
return this.http.post<RecommendationGenerationReport>(
|
||||||
|
`${environment.apiUrl}/recommendations/generate`,
|
||||||
|
null,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { SensorsService } from './sensors.service';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
describe('SensorsService', () => {
|
||||||
|
let service: SensorsService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(SensorsService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it("appelle l'endpoint /sensors/status et retourne la réponse", () => {
|
||||||
|
let result: unknown;
|
||||||
|
service.getStatus().subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/sensors/status`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
|
||||||
|
req.flush({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Test',
|
||||||
|
overall: 'ok',
|
||||||
|
sensors: {
|
||||||
|
consumption: { status: 'ok', since: null },
|
||||||
|
electrical: { status: 'ok', since: null },
|
||||||
|
temperature: { status: 'ok', since: null },
|
||||||
|
humidity: { status: 'ok', since: null },
|
||||||
|
network: { status: 'ok', since: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((result as { sites: unknown[] }).sites.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {SensorStatusResponse} from '../../shared/models/sensor-status.model';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class SensorsService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return this.http.get<SensorStatusResponse>(`${environment.apiUrl}/sensors/status`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard__actions">
|
<div class="dashboard__actions">
|
||||||
|
@if (auth.principal()?.role === 'admin') {
|
||||||
|
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
|
||||||
|
}
|
||||||
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
||||||
|
<a routerLink="/recommendations" class="ev-link">Recommandations</a>
|
||||||
<ev-button
|
<ev-button
|
||||||
class="logout-button"
|
class="logout-button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ h2 {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card--link {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card__label {
|
.card__label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -170,8 +170,11 @@ 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 statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||||
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
|
const authMock = {
|
||||||
|
logout: vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
|
};
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [Dashboard],
|
imports: [Dashboard],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -201,6 +204,7 @@ describe('Dashboard', () => {
|
|||||||
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' }),
|
||||||
};
|
};
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [Dashboard],
|
imports: [Dashboard],
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
|||||||
export class Dashboard implements OnInit {
|
export class Dashboard implements OnInit {
|
||||||
private statsService = inject(StatsService);
|
private statsService = inject(StatsService);
|
||||||
private alertsService = inject(AlertsService);
|
private alertsService = inject(AlertsService);
|
||||||
|
public auth = inject(AuthService);
|
||||||
private predictionsService = inject(PredictionsService);
|
private predictionsService = inject(PredictionsService);
|
||||||
private auth = inject(AuthService);
|
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<div class="sensor-status">
|
||||||
|
<nav class="ev-breadcrumb">
|
||||||
|
<a routerLink="/dashboard">Tableau de bord</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="sensor-status__header">
|
||||||
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="sensor-status__logo" />
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1>Supervision des capteurs</h1>
|
||||||
|
<p class="sensor-status__subtitle">État de santé par capteur et par site</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data(); as d) {
|
||||||
|
<div class="sites-grid">
|
||||||
|
@for (site of d.sites; track site.site_id) {
|
||||||
|
<ev-card class="site-card">
|
||||||
|
<div class="site-card__header">
|
||||||
|
<span class="site-card__name">{{ site.site_name }}</span>
|
||||||
|
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="sensor-list">
|
||||||
|
@for (entry of sensorEntries; track entry[0]) {
|
||||||
|
@let diagnostic = sensorOf(site.sensors, entry[0]);
|
||||||
|
<li class="sensor-item">
|
||||||
|
<span class="sensor-dot" [class]="'sensor-dot--' + diagnostic.status"></span>
|
||||||
|
<span class="sensor-item__label">{{ entry[1] }}</span>
|
||||||
|
@if (diagnostic.status === 'failing') {
|
||||||
|
<span class="sensor-item__since">
|
||||||
|
@if (diagnostic.since; as since) {
|
||||||
|
dernière lecture le {{ since | date: 'dd/MM/yyyy HH:mm' }}
|
||||||
|
} @else {
|
||||||
|
aucune lecture reçue
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</ev-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-status__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-status__logo {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-status__subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-error {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sites-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
&--failing {
|
||||||
|
background: var(--color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-item__label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-item__since {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { SensorStatusView } from './sensor-status';
|
||||||
|
import { SensorsService } from '../../../core/services/sensors.service';
|
||||||
|
import { SiteSensors } from '../../../shared/models/sensor-status.model';
|
||||||
|
import {provideRouter} from '@angular/router';
|
||||||
|
|
||||||
|
const OK_SENSORS: SiteSensors = {
|
||||||
|
consumption: { status: 'ok', since: null },
|
||||||
|
electrical: { status: 'ok', since: null },
|
||||||
|
temperature: { status: 'ok', since: null },
|
||||||
|
humidity: { status: 'ok', since: null },
|
||||||
|
network: { status: 'ok', since: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SensorStatusView', () => {
|
||||||
|
let sensorsMock: { getStatus: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sensorsMock = { getStatus: vi.fn() };
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SensorStatusView],
|
||||||
|
providers: [
|
||||||
|
{ provide: SensorsService, useValue: sensorsMock },
|
||||||
|
provideRouter([]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charge et affiche les données au démarrage', () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(
|
||||||
|
of({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [
|
||||||
|
{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'ok', sensors: OK_SENSORS },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.data()?.sites.length).toBe(1);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Bureau Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("affiche un message d'erreur si l'appel échoue", () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(throwError(() => new Error('boom')));
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.error()).toBe(
|
||||||
|
'État des capteurs indisponible, réessayez plus tard.'
|
||||||
|
);
|
||||||
|
expect(fixture.componentInstance.data()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('État des capteurs indisponible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associe le bon ton de badge à chaque statut global', () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
expect(component.badgeToneForOverall('ok')).toBe('success');
|
||||||
|
expect(component.badgeToneForOverall('degraded')).toBe('warning');
|
||||||
|
expect(component.badgeToneForOverall('critical')).toBe('critical');
|
||||||
|
expect(component.badgeToneForOverall('inconnu')).toBe('neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne le bon diagnostic via sensorOf', () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche la date de la dernière lecture reçue pour un capteur en panne', () => {
|
||||||
|
const sensors: SiteSensors = {
|
||||||
|
...OK_SENSORS,
|
||||||
|
temperature: { status: 'failing', since: '2026-09-18T08:00:00' },
|
||||||
|
};
|
||||||
|
sensorsMock.getStatus.mockReturnValue(
|
||||||
|
of({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('dernière lecture le');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('18/09/2026 08:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence de lecture quand un site n'en a jamais reçu", () => {
|
||||||
|
const sensors: SiteSensors = {
|
||||||
|
consumption: { status: 'failing', since: null },
|
||||||
|
electrical: { status: 'failing', since: null },
|
||||||
|
temperature: { status: 'failing', since: null },
|
||||||
|
humidity: { status: 'failing', since: null },
|
||||||
|
network: { status: 'failing', since: null },
|
||||||
|
};
|
||||||
|
sensorsMock.getStatus.mockReturnValue(
|
||||||
|
of({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'critical', sensors }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('aucune lecture reçue');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('dernière lecture le');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { catchError, EMPTY, Observable } from 'rxjs';
|
||||||
|
import {Badge, BadgeTone} from '../../../shared/components/ui/badge/badge';
|
||||||
|
import {Card} from '../../../shared/components/ui/card/card';
|
||||||
|
import {Alert} from '../../../shared/components/ui/alert/alert';
|
||||||
|
import {Brand} from '../../../shared/components/ui/brand/brand';
|
||||||
|
import {SensorsService} from '../../../core/services/sensors.service';
|
||||||
|
import {SensorDiagnostic, SensorStatusResponse} from '../../../shared/models/sensor-status.model';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
|
||||||
|
const UNAVAILABLE_MESSAGE = 'État des capteurs indisponible, réessayez plus tard.';
|
||||||
|
|
||||||
|
const SENSOR_LABELS: Record<string, string> = {
|
||||||
|
consumption: 'Consommation',
|
||||||
|
electrical: 'Électrique',
|
||||||
|
temperature: 'Température',
|
||||||
|
humidity: 'Humidité',
|
||||||
|
network: 'Réseau',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TON_PAR_OVERALL: Record<string, BadgeTone> = {
|
||||||
|
ok: 'success',
|
||||||
|
degraded: 'warning',
|
||||||
|
critical: 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sensor-status',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, Card, Alert, Badge, Brand, DatePipe],
|
||||||
|
templateUrl: './sensor-status.html',
|
||||||
|
styleUrl: './sensor-status.scss',
|
||||||
|
})
|
||||||
|
export class SensorStatusView implements OnInit {
|
||||||
|
private sensorsService = inject(SensorsService);
|
||||||
|
|
||||||
|
data = signal<SensorStatusResponse | null>(null);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly sensorEntries = Object.entries(SENSOR_LABELS);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.sensorsService
|
||||||
|
.getStatus()
|
||||||
|
.pipe(catchError(() => this.reportUnavailable()))
|
||||||
|
.subscribe((response) => {
|
||||||
|
this.error.set(null);
|
||||||
|
this.data.set(response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sensorOf(sensors: Record<string, SensorDiagnostic>, key: string): SensorDiagnostic {
|
||||||
|
return sensors[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeToneForOverall(overall: string): BadgeTone {
|
||||||
|
return TON_PAR_OVERALL[overall] ?? 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportUnavailable(): Observable<never> {
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<div class="recommendations">
|
||||||
|
<nav class="ev-breadcrumb">
|
||||||
|
<a routerLink="/dashboard">Tableau de bord</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="recommendations__header">
|
||||||
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="recommendations__logo" />
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1>Recommandations</h1>
|
||||||
|
<p class="recommendations__subtitle">
|
||||||
|
Actions proposées par le moteur de règles à partir des alertes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="recommendations__toolbar">
|
||||||
|
<label class="recommendations__filter">
|
||||||
|
<span class="form-label">Site</span>
|
||||||
|
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
|
||||||
|
<option value="" [selected]="!siteFilter()">Tous les sites</option>
|
||||||
|
@for (site of sites(); track site.site_id) {
|
||||||
|
<option [value]="site.site_id" [selected]="site.site_id === siteFilter()">
|
||||||
|
{{ site.site_name }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
@if (isAdmin()) {
|
||||||
|
<ev-button
|
||||||
|
variant="secondary"
|
||||||
|
[fullWidth]="false"
|
||||||
|
[disabled]="generating()"
|
||||||
|
data-testid="generate"
|
||||||
|
(click)="onGenerate()"
|
||||||
|
>
|
||||||
|
{{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }}
|
||||||
|
</ev-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (generationReport(); as report) {
|
||||||
|
<ev-alert severity="success" class="recommendations__banner">{{ bilan(report) }}.</ev-alert>
|
||||||
|
}
|
||||||
|
@if (generationError(); as message) {
|
||||||
|
<ev-alert severity="danger" class="recommendations__banner">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (alertId(); as id) {
|
||||||
|
<p class="recommendations__focus">
|
||||||
|
Alerte n° {{ id }} ·
|
||||||
|
<a routerLink="/recommendations" class="ev-link">Toutes les recommandations</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-recommendation-list [siteId]="siteFilter()" [alertId]="alertId()" [sites]="sites()" />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__logo {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 14rem;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__focus {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||||
|
import { RecommendationsView, parseAlertId } from './recommendations';
|
||||||
|
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
|
||||||
|
import { SitesService } from '../../core/services/sites.service';
|
||||||
|
import { AlertsService } from '../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../core/services/recommendations.service';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
|
||||||
|
const SITES = [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BILAN = { alerts_examined: 2, recommendations_created: 3, already_present: 1 };
|
||||||
|
|
||||||
|
function setup(options: { query?: Record<string, string>; role?: string } = {}) {
|
||||||
|
const query = options.query ?? {};
|
||||||
|
const queryParamMap = new BehaviorSubject(convertToParamMap(query));
|
||||||
|
const generate = vi.fn().mockReturnValue(of(BILAN));
|
||||||
|
const getRecommendations = vi.fn().mockReturnValue(of([]));
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [RecommendationsView],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: { queryParamMap, snapshot: { queryParamMap: convertToParamMap(query) } },
|
||||||
|
},
|
||||||
|
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of(SITES)) } },
|
||||||
|
{ provide: AlertsService, useValue: { getAlerts } },
|
||||||
|
{ provide: RecommendationsService, useValue: { getRecommendations, generate } },
|
||||||
|
{
|
||||||
|
provide: AuthService,
|
||||||
|
useValue: { principal: vi.fn().mockReturnValue({ role: options.role ?? 'lecteur' }) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(RecommendationsView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
return { fixture, queryParamMap, generate, getRecommendations, getAlerts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listeEnfant(fixture: ReturnType<typeof setup>['fixture']): RecommendationList {
|
||||||
|
return fixture.debugElement.query(By.directive(RecommendationList)).componentInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseAlertId', () => {
|
||||||
|
it("n'accepte qu'un entier strictement positif", () => {
|
||||||
|
expect(parseAlertId('12')).toBe(12);
|
||||||
|
expect(parseAlertId('0')).toBeNull();
|
||||||
|
expect(parseAlertId('-3')).toBeNull();
|
||||||
|
expect(parseAlertId('abc')).toBeNull();
|
||||||
|
expect(parseAlertId('12abc')).toBeNull();
|
||||||
|
expect(parseAlertId(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecommendationsView', () => {
|
||||||
|
it("cible l'alerte donnée par ?alert= et la transmet à la liste", () => {
|
||||||
|
const { fixture } = setup({ query: { alert: '12' } });
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.alertId()).toBe(12);
|
||||||
|
expect(listeEnfant(fixture).alertId()).toBe(12);
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Alerte n° 12');
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href="/recommendations"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore un paramètre alert invalide', () => {
|
||||||
|
const { fixture } = setup({ query: { alert: 'abc' } });
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.alertId()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Alerte n°');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applique le site donné par ?site= au filtre et à la liste', () => {
|
||||||
|
const { fixture, getAlerts } = setup({ query: { site: 'SITE001' } });
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||||
|
const option = fixture.nativeElement.querySelector(
|
||||||
|
'option[value="SITE001"]',
|
||||||
|
) as HTMLOptionElement;
|
||||||
|
expect(option.selected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relance la liste sur le site choisi dans le filtre', () => {
|
||||||
|
const { fixture, getAlerts } = setup();
|
||||||
|
const select = fixture.nativeElement.querySelector(
|
||||||
|
'[data-testid="site-filter"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
|
||||||
|
select.value = 'SITE001';
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenLastCalledWith({ site_id: 'SITE001' });
|
||||||
|
expect(listeEnfant(fixture).siteId()).toBe('SITE001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cache le bouton de génération aux lecteurs', () => {
|
||||||
|
const { fixture } = setup({ role: 'lecteur' });
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="generate"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permet à un admin de générer pour le site filtré, affiche le bilan et recharge la liste', () => {
|
||||||
|
const { fixture, generate, getRecommendations } = setup({
|
||||||
|
role: 'admin',
|
||||||
|
query: { site: 'SITE001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('[data-testid="generate"]').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(generate).toHaveBeenCalledWith('SITE001');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'3 recommandations créées, 1 déjà présente, 2 alertes examinées.',
|
||||||
|
);
|
||||||
|
expect(getRecommendations).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.componentInstance.generating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('génère pour tout le parc quand aucun site n’est filtré', () => {
|
||||||
|
const { fixture, generate } = setup({ role: 'admin' });
|
||||||
|
|
||||||
|
fixture.componentInstance.onGenerate();
|
||||||
|
|
||||||
|
expect(generate).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'échec de la génération sans casser la page", () => {
|
||||||
|
const { fixture, generate } = setup({ role: 'admin' });
|
||||||
|
generate.mockReturnValue(throwError(() => new Error('403')));
|
||||||
|
|
||||||
|
fixture.componentInstance.onGenerate();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.generationError()).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'La génération des recommandations a échoué',
|
||||||
|
);
|
||||||
|
expect(fixture.componentInstance.generating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accorde le bilan au singulier', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.componentInstance.bilan({
|
||||||
|
alerts_examined: 1,
|
||||||
|
recommendations_created: 1,
|
||||||
|
already_present: 0,
|
||||||
|
}),
|
||||||
|
).toBe('1 recommandation créée, 0 déjà présente, 1 alerte examinée');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Component, computed, inject, signal, viewChild } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { catchError, map, of } from 'rxjs';
|
||||||
|
import { SitesService } from '../../core/services/sites.service';
|
||||||
|
import { RecommendationsService } from '../../core/services/recommendations.service';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { Site } from '../../shared/models/site.model';
|
||||||
|
import { RecommendationGenerationReport } from '../../shared/models/recommendation.model';
|
||||||
|
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
|
||||||
|
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||||
|
import { Brand } from '../../shared/components/ui/brand/brand';
|
||||||
|
import { Button } from '../../shared/components/ui/button/button';
|
||||||
|
|
||||||
|
const GENERATION_FAILED_MESSAGE =
|
||||||
|
'La génération des recommandations a échoué, réessayez plus tard.';
|
||||||
|
|
||||||
|
export function parseAlertId(raw: string | null): number | null {
|
||||||
|
return raw !== null && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluriel(nombre: number, singulier: string, plurielForme: string): string {
|
||||||
|
return `${nombre} ${nombre > 1 ? plurielForme : singulier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-recommendations',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, RecommendationList, EvAlert, Brand, Button],
|
||||||
|
templateUrl: './recommendations.html',
|
||||||
|
styleUrl: './recommendations.scss',
|
||||||
|
})
|
||||||
|
export class RecommendationsView {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private sitesService = inject(SitesService);
|
||||||
|
private recommendationsService = inject(RecommendationsService);
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
|
||||||
|
alertId = toSignal(
|
||||||
|
this.route.queryParamMap.pipe(map((params) => parseAlertId(params.get('alert')))),
|
||||||
|
{ initialValue: null },
|
||||||
|
);
|
||||||
|
siteFilter = signal<string | null>(this.route.snapshot.queryParamMap.get('site'));
|
||||||
|
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
|
||||||
|
initialValue: [] as Site[],
|
||||||
|
});
|
||||||
|
|
||||||
|
list = viewChild.required(RecommendationList);
|
||||||
|
|
||||||
|
isAdmin = computed(() => this.auth.principal()?.role === 'admin');
|
||||||
|
generating = signal(false);
|
||||||
|
generationReport = signal<RecommendationGenerationReport | null>(null);
|
||||||
|
generationError = signal<string | null>(null);
|
||||||
|
|
||||||
|
onSiteChange(event: Event): void {
|
||||||
|
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onGenerate(): void {
|
||||||
|
if (this.generating()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.generating.set(true);
|
||||||
|
this.generationError.set(null);
|
||||||
|
this.recommendationsService.generate(this.siteFilter() ?? undefined).subscribe({
|
||||||
|
next: (report) => {
|
||||||
|
this.generating.set(false);
|
||||||
|
this.generationReport.set(report);
|
||||||
|
this.list().reload();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.generating.set(false);
|
||||||
|
this.generationError.set(GENERATION_FAILED_MESSAGE);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bilan(report: RecommendationGenerationReport): string {
|
||||||
|
return [
|
||||||
|
pluriel(report.recommendations_created, 'recommandation créée', 'recommandations créées'),
|
||||||
|
pluriel(report.already_present, 'déjà présente', 'déjà présentes'),
|
||||||
|
pluriel(report.alerts_examined, 'alerte examinée', 'alertes examinées'),
|
||||||
|
].join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,5 +81,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<section class="recommendations-section">
|
||||||
|
<h2>Recommandations</h2>
|
||||||
|
<app-recommendation-list [siteId]="siteId() ?? null" [sites]="siteAsList()" />
|
||||||
|
<a
|
||||||
|
routerLink="/recommendations"
|
||||||
|
[queryParams]="{ site: siteId() }"
|
||||||
|
class="ev-link recommendations-section__link"
|
||||||
|
>Voir dans la vue recommandations</a
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,3 +117,12 @@ h2 {
|
|||||||
.chart-section {
|
.chart-section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recommendations-section {
|
||||||
|
margin: 2.5rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations-section__link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { BehaviorSubject, of, throwError } from 'rxjs';
|
|||||||
import { SiteDetail } from './site-detail';
|
import { SiteDetail } from './site-detail';
|
||||||
import { SitesService } from '../../../core/services/sites.service';
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
import { ReadingsService } from '../../../core/services/readings.service';
|
import { ReadingsService } from '../../../core/services/readings.service';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||||
|
|
||||||
const SITE = {
|
const SITE = {
|
||||||
site_id: 'SITE001',
|
site_id: 'SITE001',
|
||||||
@@ -69,6 +71,7 @@ function setup(
|
|||||||
readingsMock: Partial<ReadingsService>,
|
readingsMock: Partial<ReadingsService>,
|
||||||
) {
|
) {
|
||||||
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
|
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [SiteDetail],
|
imports: [SiteDetail],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -76,9 +79,14 @@ function setup(
|
|||||||
{ provide: ActivatedRoute, useValue: { paramMap } },
|
{ provide: ActivatedRoute, useValue: { paramMap } },
|
||||||
{ provide: SitesService, useValue: sitesMock },
|
{ provide: SitesService, useValue: sitesMock },
|
||||||
{ provide: ReadingsService, useValue: readingsMock },
|
{ provide: ReadingsService, useValue: readingsMock },
|
||||||
|
{ provide: AlertsService, useValue: { getAlerts } },
|
||||||
|
{
|
||||||
|
provide: RecommendationsService,
|
||||||
|
useValue: { getRecommendations: vi.fn().mockReturnValue(of([])) },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
|
return { fixture: TestBed.createComponent(SiteDetail), paramMap, getAlerts };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('SiteDetail', () => {
|
describe('SiteDetail', () => {
|
||||||
@@ -261,6 +269,27 @@ describe('SiteDetail', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('demande les recommandations du site consulté à travers ses alertes', () => {
|
||||||
|
const { fixture, getAlerts } = setup(
|
||||||
|
'SITE001',
|
||||||
|
{
|
||||||
|
getSite: vi.fn().mockReturnValue(of(SITE)),
|
||||||
|
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
|
||||||
|
},
|
||||||
|
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||||
|
expect(fixture.nativeElement.querySelector('app-recommendation-list')).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Recommandations');
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('a[href="/recommendations?site=SITE001"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
|
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
|
||||||
const getHistory = vi.fn().mockReturnValue(of([]));
|
const getHistory = vi.fn().mockReturnValue(of([]));
|
||||||
const { fixture } = setup(
|
const { fixture } = setup(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
|
|||||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||||
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
|
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
|
||||||
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
||||||
|
import { RecommendationList } from '../../../shared/components/recommendation-list/recommendation-list';
|
||||||
|
|
||||||
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
||||||
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
||||||
@@ -96,7 +97,16 @@ export interface MetricView {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-site-detail',
|
selector: 'app-site-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
Card,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Brand,
|
||||||
|
ConsumptionGauge,
|
||||||
|
ReadingHistoryChart,
|
||||||
|
RecommendationList,
|
||||||
|
],
|
||||||
templateUrl: './site-detail.html',
|
templateUrl: './site-detail.html',
|
||||||
styleUrl: './site-detail.scss',
|
styleUrl: './site-detail.scss',
|
||||||
})
|
})
|
||||||
@@ -117,6 +127,11 @@ export class SiteDetail {
|
|||||||
|
|
||||||
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
||||||
|
|
||||||
|
siteAsList = computed<Site[]>(() => {
|
||||||
|
const site = this.site();
|
||||||
|
return site ? [site] : [];
|
||||||
|
});
|
||||||
|
|
||||||
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
|
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
|
||||||
|
|
||||||
consumptionLabel = computed(() => {
|
consumptionLabel = computed(() => {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="reco-list__banner">{{ message }}</ev-alert>
|
||||||
|
} @else if (loading() && !hasData()) {
|
||||||
|
<p class="reco-list__state" aria-live="polite">Chargement des recommandations…</p>
|
||||||
|
} @else if (visibleGroups().length === 0) {
|
||||||
|
<ev-alert severity="success" class="reco-list__banner">{{ emptyMessage() }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="reco-list" [attr.aria-busy]="loading()">
|
||||||
|
@for (group of visibleGroups(); track group.alert.alert_id) {
|
||||||
|
<ev-card
|
||||||
|
class="reco-group"
|
||||||
|
[class.reco-group--focus]="group.alert.alert_id === alertId()"
|
||||||
|
[id]="'alerte-' + group.alert.alert_id"
|
||||||
|
>
|
||||||
|
<header class="reco-group__alert">
|
||||||
|
<div class="reco-group__meta">
|
||||||
|
<ev-badge [tone]="toneFor(group.alert.severity)">{{
|
||||||
|
severityLabel(group.alert.severity)
|
||||||
|
}}</ev-badge>
|
||||||
|
<span class="reco-group__type">{{ typeLabel(group.alert.type) }}</span>
|
||||||
|
@if (!siteId()) {
|
||||||
|
<a [routerLink]="['/sites', group.alert.site_id]" class="ev-link">{{
|
||||||
|
group.siteName
|
||||||
|
}}</a>
|
||||||
|
}
|
||||||
|
<time [attr.datetime]="group.alert.timestamp">{{
|
||||||
|
group.alert.timestamp | date: 'dd/MM/yyyy HH:mm'
|
||||||
|
}}</time>
|
||||||
|
</div>
|
||||||
|
<p class="reco-group__message">{{ group.alert.message }}</p>
|
||||||
|
</header>
|
||||||
|
<ol class="reco-group__items">
|
||||||
|
@for (reco of group.recommendations; track reco.recommendation_id) {
|
||||||
|
<li class="reco">
|
||||||
|
<div class="reco__head">
|
||||||
|
<strong class="reco__action">{{ reco.action }}</strong>
|
||||||
|
<ev-badge [tone]="ruleTone(reco.rule_reference)">{{
|
||||||
|
ruleLabel(reco.rule_reference)
|
||||||
|
}}</ev-badge>
|
||||||
|
</div>
|
||||||
|
<p class="reco__explanation">{{ reco.explanation }}</p>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</ev-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-list__banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-list__state {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group {
|
||||||
|
padding: var(--space-4);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group--focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__alert {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__items {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco__action {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco__explanation {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { NEVER, of, throwError } from 'rxjs';
|
||||||
|
import { RecommendationList, joinByAlert } from './recommendation-list';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||||
|
import { Alert } from '../../models/alert.model';
|
||||||
|
import { Recommendation } from '../../models/recommendation.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
|
||||||
|
const SITES: Site[] = [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
site_id: 'SITE002',
|
||||||
|
site_name: 'Bureau Lille',
|
||||||
|
site_type: 'bureau',
|
||||||
|
location: 'Lille',
|
||||||
|
capacity_kw: 80,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function alerte(surcharges: Partial<Alert>): Alert {
|
||||||
|
return {
|
||||||
|
alert_id: 1,
|
||||||
|
site_id: 'SITE001',
|
||||||
|
timestamp: '2026-09-15T09:00:00Z',
|
||||||
|
type: 'threshold',
|
||||||
|
severity: 'high',
|
||||||
|
message: 'Puissance appelée au-dessus de la capacité du site',
|
||||||
|
value: 812.5,
|
||||||
|
threshold: 720,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reco(surcharges: Partial<Recommendation>): Recommendation {
|
||||||
|
return {
|
||||||
|
recommendation_id: 1,
|
||||||
|
alert_id: 1,
|
||||||
|
action: 'Ramener la puissance appelée sous le seuil contractuel',
|
||||||
|
explanation: 'Seuil de consommation dépassé sur le site SITE001.',
|
||||||
|
rule_reference: 'threshold-reduction-v1',
|
||||||
|
created_at: '2026-09-15T09:05:00Z',
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALERTES: Alert[] = [
|
||||||
|
alerte({ alert_id: 1, site_id: 'SITE001', timestamp: '2026-09-15T09:00:00Z' }),
|
||||||
|
alerte({
|
||||||
|
alert_id: 2,
|
||||||
|
site_id: 'SITE002',
|
||||||
|
timestamp: '2026-09-15T11:00:00Z',
|
||||||
|
severity: 'critical',
|
||||||
|
type: 'spike',
|
||||||
|
message: 'Variation brutale entre deux lectures consécutives',
|
||||||
|
}),
|
||||||
|
alerte({ alert_id: 3, site_id: 'SITE001', timestamp: '2026-09-15T10:00:00Z', severity: 'low' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const RECOMMANDATIONS: Recommendation[] = [
|
||||||
|
reco({
|
||||||
|
recommendation_id: 3,
|
||||||
|
alert_id: 2,
|
||||||
|
action: "Escalader à l'astreinte sous une heure",
|
||||||
|
rule_reference: 'escalade-astreinte-v1',
|
||||||
|
}),
|
||||||
|
reco({ recommendation_id: 1, alert_id: 1 }),
|
||||||
|
reco({
|
||||||
|
recommendation_id: 2,
|
||||||
|
alert_id: 2,
|
||||||
|
action: 'Délester les équipements non prioritaires sur le créneau du pic',
|
||||||
|
rule_reference: 'spike-delestage-v1',
|
||||||
|
}),
|
||||||
|
reco({ recommendation_id: 4, alert_id: 99, rule_reference: 'orpheline-v1' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
|
||||||
|
recosMock: { getRecommendations: ReturnType<typeof vi.fn> },
|
||||||
|
inputs: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [RecommendationList],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
|
{ provide: RecommendationsService, useValue: recosMock },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(RecommendationList);
|
||||||
|
for (const [nom, valeur] of Object.entries(inputs)) {
|
||||||
|
fixture.componentRef.setInput(nom, valeur);
|
||||||
|
}
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendre(fixture: ComponentFixture<RecommendationList>) {
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function texte(fixture: ComponentFixture<RecommendationList>): string {
|
||||||
|
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const recosOk = () => ({ getRecommendations: vi.fn().mockReturnValue(of(RECOMMANDATIONS)) });
|
||||||
|
|
||||||
|
describe('joinByAlert', () => {
|
||||||
|
it('groupe par alerte, du plus récent au plus ancien, recommandations par identifiant', () => {
|
||||||
|
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map([['SITE001', 'Usine Nantes']]));
|
||||||
|
|
||||||
|
expect(groupes.map((g) => g.alert.alert_id)).toEqual([2, 1]);
|
||||||
|
expect(groupes[0].recommendations.map((r) => r.recommendation_id)).toEqual([2, 3]);
|
||||||
|
expect(groupes[1].siteName).toBe('Usine Nantes');
|
||||||
|
expect(groupes[0].siteName).toBe('SITE002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore les alertes sans recommandation et les recommandations orphelines', () => {
|
||||||
|
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map());
|
||||||
|
|
||||||
|
expect(groupes.some((g) => g.alert.alert_id === 3)).toBe(false);
|
||||||
|
expect(groupes.flatMap((g) => g.recommendations).some((r) => r.alert_id === 99)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecommendationList', () => {
|
||||||
|
it('charge alertes et recommandations puis affiche les groupes avec leur contexte', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
|
||||||
|
const fixture = setup({ getAlerts }, recosOk(), { sites: SITES });
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({});
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(2);
|
||||||
|
const contenu = texte(fixture);
|
||||||
|
expect(contenu).toContain('Usine Nantes');
|
||||||
|
expect(contenu).toContain('Bureau Lille');
|
||||||
|
expect(contenu).toContain('Critique');
|
||||||
|
expect(contenu).toContain('Pic de consommation');
|
||||||
|
expect(contenu).toContain('Escalade astreinte');
|
||||||
|
expect(contenu).toContain('Délester les équipements');
|
||||||
|
expect(contenu).toContain('15/09/2026');
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE002"]')).not.toBeNull();
|
||||||
|
expect(fixture.componentInstance.total()).toBe(3);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filtre les alertes du site côté API et masque le lien vers le site', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of(ALERTES.filter((a) => a.site_id === 'SITE001')));
|
||||||
|
const fixture = setup({ getAlerts }, recosOk(), { siteId: 'SITE001', sites: SITES });
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(1);
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href^="/sites/"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne garde que le groupe de l'alerte ciblée et le met en évidence", () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
|
||||||
|
alertId: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
const groupes = fixture.nativeElement.querySelectorAll('.reco-group');
|
||||||
|
expect(groupes.length).toBe(1);
|
||||||
|
expect(groupes[0].classList.contains('reco-group--focus')).toBe(true);
|
||||||
|
expect(groupes[0].id).toBe('alerte-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence de recommandation pour une alerte inconnue", () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
|
||||||
|
alertId: 123,
|
||||||
|
});
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('Aucune recommandation pour cette alerte.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence de recommandation pour le site consulté", () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(of([])) },
|
||||||
|
{ getRecommendations: vi.fn().mockReturnValue(of([])) },
|
||||||
|
{ siteId: 'SITE001' },
|
||||||
|
);
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('Aucune recommandation pour ce site.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'indisponibilité et n'affiche aucun groupe si un des deux appels échoue", () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) },
|
||||||
|
{ getRecommendations: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
|
);
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||||
|
expect(fixture.componentInstance.groups()).toEqual([]);
|
||||||
|
expect(texte(fixture)).toContain('Recommandations indisponibles');
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annonce le chargement tant que la réponse ne vient pas', () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(NEVER) },
|
||||||
|
{ getRecommendations: vi.fn().mockReturnValue(NEVER) },
|
||||||
|
);
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.loading()).toBe(true);
|
||||||
|
expect(texte(fixture)).toContain('Chargement des recommandations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recharge les deux flux à la demande', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
|
||||||
|
const recos = recosOk();
|
||||||
|
const fixture = setup({ getAlerts }, recos);
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
fixture.componentInstance.reload();
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(recos.getRecommendations).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { catchError, EMPTY, forkJoin, Observable, switchMap, tap } from 'rxjs';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||||
|
import { Alert, AlertSeverity, AlertType } from '../../models/alert.model';
|
||||||
|
import { Recommendation } from '../../models/recommendation.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
import {
|
||||||
|
LIBELLE_PAR_SEVERITE,
|
||||||
|
LIBELLE_PAR_TYPE,
|
||||||
|
TON_PAR_SEVERITE,
|
||||||
|
} from '../../models/alert-presentation';
|
||||||
|
import { libelleRegle, tonRegle } from '../../models/recommendation-presentation';
|
||||||
|
import { Card } from '../ui/card/card';
|
||||||
|
import { Badge, BadgeTone } from '../ui/badge/badge';
|
||||||
|
import { Alert as EvAlert } from '../ui/alert/alert';
|
||||||
|
|
||||||
|
const UNAVAILABLE_MESSAGE = 'Recommandations indisponibles, réessayez plus tard.';
|
||||||
|
|
||||||
|
export interface RecommendedAlertView {
|
||||||
|
alert: Alert;
|
||||||
|
siteName: string;
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chargement {
|
||||||
|
alerts: Alert[];
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pourquoi : une recommandation ne porte que alert_id, jamais site_id, et /recommendations n'a
|
||||||
|
// aucun filtre ; la jointure se fait ici, en O(alertes), acceptable à la taille du jeu de données.
|
||||||
|
export function joinByAlert(
|
||||||
|
alerts: Alert[],
|
||||||
|
recommendations: Recommendation[],
|
||||||
|
siteNames: Map<string, string>,
|
||||||
|
): RecommendedAlertView[] {
|
||||||
|
const parAlerte = new Map<number, Recommendation[]>();
|
||||||
|
for (const recommandation of recommendations) {
|
||||||
|
const liste = parAlerte.get(recommandation.alert_id) ?? [];
|
||||||
|
liste.push(recommandation);
|
||||||
|
parAlerte.set(recommandation.alert_id, liste);
|
||||||
|
}
|
||||||
|
return alerts
|
||||||
|
.filter((alert) => parAlerte.has(alert.alert_id))
|
||||||
|
.map((alert) => ({
|
||||||
|
alert,
|
||||||
|
siteName: siteNames.get(alert.site_id) ?? alert.site_id,
|
||||||
|
recommendations: [...(parAlerte.get(alert.alert_id) ?? [])].sort(
|
||||||
|
(a, b) => a.recommendation_id - b.recommendation_id,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Date.parse(b.alert.timestamp) - Date.parse(a.alert.timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-recommendation-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DatePipe, RouterLink, Card, Badge, EvAlert],
|
||||||
|
templateUrl: './recommendation-list.html',
|
||||||
|
styleUrl: './recommendation-list.scss',
|
||||||
|
})
|
||||||
|
export class RecommendationList {
|
||||||
|
private alertsService = inject(AlertsService);
|
||||||
|
private recommendationsService = inject(RecommendationsService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
siteId = input<string | null>(null);
|
||||||
|
alertId = input<number | null>(null);
|
||||||
|
sites = input<Site[]>([]);
|
||||||
|
|
||||||
|
private data = signal<Chargement | null>(null);
|
||||||
|
private reloadTick = signal(0);
|
||||||
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
private trigger = computed(() => ({ siteId: this.siteId(), tick: this.reloadTick() }));
|
||||||
|
|
||||||
|
private siteNameById = computed(
|
||||||
|
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
|
||||||
|
);
|
||||||
|
|
||||||
|
hasData = computed(() => this.data() !== null);
|
||||||
|
|
||||||
|
groups = computed<RecommendedAlertView[]>(() => {
|
||||||
|
const data = this.data();
|
||||||
|
return data ? joinByAlert(data.alerts, data.recommendations, this.siteNameById()) : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
visibleGroups = computed(() => {
|
||||||
|
const alertId = this.alertId();
|
||||||
|
const groups = this.groups();
|
||||||
|
return alertId === null ? groups : groups.filter((group) => group.alert.alert_id === alertId);
|
||||||
|
});
|
||||||
|
|
||||||
|
total = computed(() =>
|
||||||
|
this.visibleGroups().reduce((somme, group) => somme + group.recommendations.length, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
emptyMessage = computed(() => {
|
||||||
|
if (this.alertId() !== null) {
|
||||||
|
return 'Aucune recommandation pour cette alerte.';
|
||||||
|
}
|
||||||
|
return this.siteId()
|
||||||
|
? 'Aucune recommandation pour ce site.'
|
||||||
|
: 'Aucune recommandation pour le moment.';
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
toObservable(this.trigger)
|
||||||
|
.pipe(
|
||||||
|
tap(() => this.loading.set(true)),
|
||||||
|
switchMap(({ siteId }) =>
|
||||||
|
forkJoin({
|
||||||
|
alerts: this.alertsService.getAlerts(siteId ? { site_id: siteId } : {}),
|
||||||
|
recommendations: this.recommendationsService.getRecommendations(),
|
||||||
|
}).pipe(catchError(() => this.reportUnavailable())),
|
||||||
|
),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((data) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(null);
|
||||||
|
this.data.set(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
this.reloadTick.update((tick) => tick + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
toneFor(severity: AlertSeverity): BadgeTone {
|
||||||
|
return TON_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
severityLabel(severity: AlertSeverity): string {
|
||||||
|
return LIBELLE_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
typeLabel(type: AlertType): string {
|
||||||
|
return LIBELLE_PAR_TYPE[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleLabel(reference: string): string {
|
||||||
|
return libelleRegle(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleTone(reference: string): BadgeTone {
|
||||||
|
return tonRegle(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Piège : vider les données avec l'erreur ; une demi-jointure (alertes sans recommandations,
|
||||||
|
// ou l'inverse) afficherait des groupes faux plutôt que rien.
|
||||||
|
private reportUnavailable(): Observable<never> {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
this.data.set(null);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
LIBELLE_PAR_SEVERITE,
|
||||||
|
LIBELLE_PAR_TYPE,
|
||||||
|
SEVERITES,
|
||||||
|
TON_PAR_SEVERITE,
|
||||||
|
TYPES_ALERTE,
|
||||||
|
UNITE_PAR_METRIQUE,
|
||||||
|
} from './alert-presentation';
|
||||||
|
|
||||||
|
describe('alert-presentation', () => {
|
||||||
|
it('distingue le ton des sévérités high et critical', () => {
|
||||||
|
expect(TON_PAR_SEVERITE.high).toBe('danger');
|
||||||
|
expect(TON_PAR_SEVERITE.critical).toBe('critical');
|
||||||
|
expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'affiche pas une alerte faible avec le ton de succès", () => {
|
||||||
|
expect(TON_PAR_SEVERITE.low).toBe('neutral');
|
||||||
|
expect(TON_PAR_SEVERITE.medium).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('donne un libellé français à chaque sévérité et à chaque type', () => {
|
||||||
|
for (const severite of SEVERITES) {
|
||||||
|
expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy();
|
||||||
|
}
|
||||||
|
for (const type of TYPES_ALERTE) {
|
||||||
|
expect(LIBELLE_PAR_TYPE[type]).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(SEVERITES.length).toBe(4);
|
||||||
|
expect(TYPES_ALERTE.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associe une unité à chaque métrique du contrat', () => {
|
||||||
|
expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW');
|
||||||
|
expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { BadgeTone } from '../components/ui/badge/badge';
|
||||||
|
import { AlertMetric, AlertSeverity, AlertType } from './alert.model';
|
||||||
|
|
||||||
|
// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se
|
||||||
|
// lisait comme « tout va bien » à côté des rouges.
|
||||||
|
export const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
||||||
|
low: 'neutral',
|
||||||
|
medium: 'warning',
|
||||||
|
high: 'danger',
|
||||||
|
critical: 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLE_PAR_SEVERITE: Record<AlertSeverity, string> = {
|
||||||
|
low: 'Faible',
|
||||||
|
medium: 'Moyenne',
|
||||||
|
high: 'Élevée',
|
||||||
|
critical: 'Critique',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLE_PAR_TYPE: Record<AlertType, string> = {
|
||||||
|
spike: 'Pic de consommation',
|
||||||
|
threshold: 'Seuil dépassé',
|
||||||
|
anomaly: 'Anomalie',
|
||||||
|
outage: 'Coupure',
|
||||||
|
sensor: 'Capteur',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UNITE_PAR_METRIQUE: Record<AlertMetric, string> = {
|
||||||
|
consumption_kw: 'kW',
|
||||||
|
consumption_kwh: 'kWh',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical'];
|
||||||
|
|
||||||
|
export const TYPES_ALERTE: readonly AlertType[] = [
|
||||||
|
'spike',
|
||||||
|
'threshold',
|
||||||
|
'anomaly',
|
||||||
|
'outage',
|
||||||
|
'sensor',
|
||||||
|
];
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
export type 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: string;
|
alert_id: number;
|
||||||
timestamp: string;
|
|
||||||
site_id: string;
|
site_id: string;
|
||||||
severity: AlertSeverity;
|
timestamp: string;
|
||||||
type: AlertType;
|
type: AlertType;
|
||||||
|
severity: AlertSeverity;
|
||||||
message: string;
|
message: string;
|
||||||
value: number;
|
value: number | null;
|
||||||
threshold: number;
|
threshold: number | null;
|
||||||
|
metric: AlertMetric | null;
|
||||||
|
prediction_id: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { libelleRegle, tonRegle } from './recommendation-presentation';
|
||||||
|
|
||||||
|
describe('recommendation-presentation', () => {
|
||||||
|
it('traduit les sept règles connues du moteur', () => {
|
||||||
|
expect(libelleRegle('spike-delestage-v1')).toBe('Délestage');
|
||||||
|
expect(libelleRegle('threshold-reduction-v1')).toBe('Réduction de puissance');
|
||||||
|
expect(libelleRegle('outage-secours-v1')).toBe('Alimentation de secours');
|
||||||
|
expect(libelleRegle('sensor-maintenance-v1')).toBe('Maintenance capteur');
|
||||||
|
expect(libelleRegle('anomaly-verification-v1')).toBe('Vérification');
|
||||||
|
expect(libelleRegle('escalade-astreinte-v1')).toBe('Escalade astreinte');
|
||||||
|
expect(libelleRegle('contrat-puissance-v1')).toBe('Contrat de puissance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche telle quelle une référence de règle inconnue', () => {
|
||||||
|
expect(libelleRegle('spike-delestage-v2')).toBe('spike-delestage-v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("réserve le ton critique à l'escalade vers l'astreinte", () => {
|
||||||
|
expect(tonRegle('escalade-astreinte-v1')).toBe('critical');
|
||||||
|
expect(tonRegle('spike-delestage-v1')).toBe('neutral');
|
||||||
|
expect(tonRegle('inconnue-v9')).toBe('neutral');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { BadgeTone } from '../components/ui/badge/badge';
|
||||||
|
|
||||||
|
// Contrainte : une règle dont le sens change reçoit un suffixe -v2 côté backend (ADR 0006) ;
|
||||||
|
// une référence inconnue s'affiche donc telle quelle plutôt que de casser la vue.
|
||||||
|
const LIBELLE_PAR_REGLE: Record<string, string> = {
|
||||||
|
'spike-delestage-v1': 'Délestage',
|
||||||
|
'threshold-reduction-v1': 'Réduction de puissance',
|
||||||
|
'outage-secours-v1': 'Alimentation de secours',
|
||||||
|
'sensor-maintenance-v1': 'Maintenance capteur',
|
||||||
|
'anomaly-verification-v1': 'Vérification',
|
||||||
|
'escalade-astreinte-v1': 'Escalade astreinte',
|
||||||
|
'contrat-puissance-v1': 'Contrat de puissance',
|
||||||
|
};
|
||||||
|
|
||||||
|
const REGLE_ESCALADE = 'escalade-astreinte-v1';
|
||||||
|
|
||||||
|
export function libelleRegle(reference: string): string {
|
||||||
|
return LIBELLE_PAR_REGLE[reference] ?? reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tonRegle(reference: string): BadgeTone {
|
||||||
|
return reference === REGLE_ESCALADE ? 'critical' : 'neutral';
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface Recommendation {
|
||||||
|
recommendation_id: number;
|
||||||
|
alert_id: number;
|
||||||
|
action: string;
|
||||||
|
explanation: string;
|
||||||
|
rule_reference: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecommendationGenerationReport {
|
||||||
|
alerts_examined: number;
|
||||||
|
recommendations_created: number;
|
||||||
|
already_present: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type SensorStatus = 'ok' | 'failing';
|
||||||
|
export type OverallStatus = 'ok' | 'degraded' | 'critical';
|
||||||
|
|
||||||
|
export interface SensorDiagnostic {
|
||||||
|
status: SensorStatus;
|
||||||
|
since: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSensors {
|
||||||
|
consumption: SensorDiagnostic;
|
||||||
|
electrical: SensorDiagnostic;
|
||||||
|
temperature: SensorDiagnostic;
|
||||||
|
humidity: SensorDiagnostic;
|
||||||
|
network: SensorDiagnostic;
|
||||||
|
[key: string]: SensorDiagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSensorStatus {
|
||||||
|
site_id: string;
|
||||||
|
site_name: string;
|
||||||
|
sensors: SiteSensors;
|
||||||
|
overall: OverallStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorStatusResponse {
|
||||||
|
timestamp: string;
|
||||||
|
sites: SiteSensorStatus[];
|
||||||
|
}
|
||||||
@@ -29,3 +29,18 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Piège : `APP_ENV` et `APP_DEBUG` sont en dur et non en `${APP_ENV:-prod}` : le `.env` du poste
|
||||||
|
# vaut `local` et reprendrait le dessus, ce qui laisserait le cookie sans `__Secure-` et
|
||||||
|
# rouvrirait `/docs`. Hors `local`, l'API exige en retour une origine CORS non vide.
|
||||||
|
# Piège : les listes de ports se cumulent à la fusion des deux fichiers. `!reset` est le seul
|
||||||
|
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
|
||||||
|
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
|
||||||
|
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
|
||||||
|
|
||||||
|
name: enervision
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
|
||||||
|
|
||||||
|
airflow-webserver:
|
||||||
|
ports: !override
|
||||||
|
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
ports: !reset null
|
||||||
|
command:
|
||||||
|
- uvicorn
|
||||||
|
- app.main:create_app
|
||||||
|
- --factory
|
||||||
|
- --host
|
||||||
|
- 0.0.0.0
|
||||||
|
- --port
|
||||||
|
- "8000"
|
||||||
|
- --proxy-headers
|
||||||
|
- --forwarded-allow-ips=*
|
||||||
|
environment:
|
||||||
|
APP_ENV: prod
|
||||||
|
APP_DEBUG: "false"
|
||||||
|
APP_TRUST_PROXY_HEADERS: "true"
|
||||||
|
APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local}
|
||||||
|
APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
ports: !reset null
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
image: nginx:1.28-alpine
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
frontend:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- ./infra/proxy/tls:/etc/nginx/tls:ro
|
||||||
|
- acme_webroot:/var/www/certbot
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot:v5.8.0
|
||||||
|
profiles: ["acme"]
|
||||||
|
volumes:
|
||||||
|
- letsencrypt:/etc/letsencrypt
|
||||||
|
- acme_webroot:/var/www/certbot
|
||||||
|
- ./infra/proxy/tls:/tls
|
||||||
|
- ./infra/proxy/acme-deploy-hook.sh:/deploy-hook.sh:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
acme_webroot:
|
||||||
|
letsencrypt:
|
||||||
+37
-17
@@ -16,17 +16,26 @@ x-airflow-common: &airflow-common
|
|||||||
environment: &airflow-common-env
|
environment: &airflow-common-env
|
||||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||||
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:?}
|
# Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de
|
||||||
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:?}
|
# filtrer les services : une variable requise manquante casserait aussi `make db-up`,
|
||||||
|
# `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par
|
||||||
|
# `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent.
|
||||||
|
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
|
||||||
|
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-}
|
||||||
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/CLAUDE.md) :
|
# 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.
|
||||||
ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db
|
MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db
|
||||||
|
# Le DAG `alertes` lance le backend en sous-processus : il lit `DATABASE_URL`, en
|
||||||
|
# dialecte asyncpg, là où le pipeline ML lit `ML_DATABASE_URL`.
|
||||||
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
# Clé distincte de celle de l'API : la détection ne signe ni ne vérifie aucun jeton, et
|
||||||
|
# Airflow permet d'exécuter du code depuis son interface (cf. ADR 0008).
|
||||||
|
APP_SECRET_KEY: ${AIRFLOW_APP_SECRET_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./etl/airflow/dags:/opt/airflow/dags
|
- ./etl/airflow/dags:/opt/airflow/dags
|
||||||
- ./etl/airflow/plugins:/opt/airflow/plugins
|
- ./etl/airflow/plugins:/opt/airflow/plugins
|
||||||
- ./etl/airflow/include:/opt/airflow/include
|
|
||||||
- airflow_logs:/opt/airflow/logs
|
- airflow_logs:/opt/airflow/logs
|
||||||
- airflow_ml_state:/opt/ml/state
|
- airflow_ml_state:/opt/ml/state
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -95,28 +104,39 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build: ./apps/frontend
|
build: ./apps/frontend
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:80"
|
- "${FRONTEND_PORT:-3000}:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Conteneur unique, jamais redemarre : migre la base de metadonnees puis cree le premier compte
|
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
|
||||||
# (idempotent, `|| true` sur la creation qui echoue si le compte existe deja). `webserver` et
|
# portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte
|
||||||
# `scheduler` attendent qu'il se termine avec succes avant de demarrer.
|
# aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume
|
||||||
|
# `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son
|
||||||
|
# succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement,
|
||||||
|
# jamais par `argv` (ni `ps`, ni `docker compose config`).
|
||||||
|
# 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.
|
||||||
airflow-init:
|
airflow-init:
|
||||||
<<: *airflow-common
|
<<: *airflow-common
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
<<: *airflow-common-env
|
||||||
|
_AIRFLOW_DB_MIGRATE: "true"
|
||||||
|
_AIRFLOW_WWW_USER_CREATE: "true"
|
||||||
|
_AIRFLOW_WWW_USER_USERNAME: ${AIRFLOW_ADMIN_USERNAME:-admin}
|
||||||
|
_AIRFLOW_WWW_USER_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-}
|
||||||
|
_AIRFLOW_WWW_USER_EMAIL: ${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
command:
|
command:
|
||||||
- bash
|
- bash
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
airflow db migrate
|
set -euo pipefail
|
||||||
airflow users create \
|
: "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}"
|
||||||
--username "${AIRFLOW_ADMIN_USERNAME:-admin}" \
|
: "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}"
|
||||||
--password "${AIRFLOW_ADMIN_PASSWORD:?}" \
|
: "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}"
|
||||||
--firstname Admin \
|
exec airflow version
|
||||||
--lastname EnerVision \
|
|
||||||
--role Admin \
|
|
||||||
--email "${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}" \
|
|
||||||
|| true
|
|
||||||
|
|
||||||
airflow-webserver:
|
airflow-webserver:
|
||||||
<<: *airflow-common
|
<<: *airflow-common
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# ML-START : accès aux données, scoring, frontière API et ML
|
||||||
|
|
||||||
|
Document de référence du module `ml/`, cité par le code (`enervision_ml/config.py`, `data.py`,
|
||||||
|
`train.py`, `score.py`, `features.py`), par l'[ADR 0005](adr/0005-modele-prediction-lightgbm.md)
|
||||||
|
et par les vues d'architecture. Il répond à trois questions, et à elles seules :
|
||||||
|
|
||||||
|
1. **comment le pipeline accède aux données**, et pourquoi pas par l'API ;
|
||||||
|
2. **ce que fait un run de scoring**, étape par étape ;
|
||||||
|
3. **où passe la frontière entre l'API et le ML**, et pourquoi elle est là.
|
||||||
|
|
||||||
|
Le mode d'emploi (installation, commandes, options) est dans [`ml/README.md`](../ml/README.md).
|
||||||
|
Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mécanisme d'accès aux données
|
||||||
|
|
||||||
|
### Deux sources, un seul schéma de sortie
|
||||||
|
|
||||||
|
`enervision_ml.data` expose trois chargeurs qui produisent **exactement les mêmes neuf colonnes**
|
||||||
|
(`site_id`, `timestamp`, `consumption_kwh`, `temperature_celsius`, `humidity_percent`,
|
||||||
|
`solar_irradiance_wm2`, `is_working_hours`, `site_type`, `capacity_kw`) :
|
||||||
|
|
||||||
|
| Fonction | Source | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée |
|
||||||
|
| `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement |
|
||||||
|
| `load_recent_from_database(connection, since=…)` | `reading` joint à `site`, **borné par `since`** | Scoring |
|
||||||
|
|
||||||
|
L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur
|
||||||
|
CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une
|
||||||
|
divergence entre les deux chemins ne se verrait pas au chargement, elle se verrait en production
|
||||||
|
sous forme de prédictions silencieusement fausses.
|
||||||
|
|
||||||
|
### Connexion directe à PostgreSQL, pas l'API
|
||||||
|
|
||||||
|
Le pipeline lit `reading` et `site` **en SQL direct**, jamais par `GET /api/v1/readings`. Trois
|
||||||
|
raisons, à défendre telles quelles :
|
||||||
|
|
||||||
|
- **Volume.** L'entraînement lit l'historique complet d'une hypertable TimescaleDB. Le faire
|
||||||
|
passer par une API REST paginée, sérialisée en JSON et contrôlée route par route, c'est payer
|
||||||
|
trois fois pour un `SELECT`.
|
||||||
|
- **Couplage.** Le pipeline n'est pas un client de l'application, c'est un consommateur du
|
||||||
|
schéma. Passer par l'API le rendrait dépendant du contrat HTTP, de l'authentification et de la
|
||||||
|
disponibilité du service, pour lire des données dont il connaît déjà la forme.
|
||||||
|
- **Droits.** Un rôle de lecture sur deux tables est une surface plus petite qu'un compte
|
||||||
|
applicatif porteur d'un rôle métier.
|
||||||
|
|
||||||
|
### `ML_DATABASE_URL`, et pourquoi ce n'est pas `DATABASE_URL`
|
||||||
|
|
||||||
|
La chaîne de connexion est lue dans **`ML_DATABASE_URL`**, jamais dans `DATABASE_URL`. Ce n'est
|
||||||
|
pas une préférence de nommage : `DATABASE_URL` est celle du backend applicatif, **propriétaire du
|
||||||
|
schéma**, avec les droits d'écriture complets. Réutiliser cette variable par défaut ferait tourner
|
||||||
|
l'entraînement et le scoring avec ces droits, **en silence**. `enervision_ml.config.database_url()`
|
||||||
|
lève donc plutôt que de retomber sur une valeur par défaut.
|
||||||
|
|
||||||
|
**Dette assumée, à dire à l'oral et non à masquer** : le rôle PostgreSQL dédié `enervision_ml`,
|
||||||
|
restreint en lecture sur `reading` et `site`, **n'est pas provisionné**. En développement,
|
||||||
|
`ML_DATABASE_URL` pointe sur la même base que le backend. La cible est un rôle séparé, cohérente
|
||||||
|
avec le principe de moindre privilège posé par l'[ADR 0003](adr/0003-autorisation-rbac-a-trois-roles.md).
|
||||||
|
|
||||||
|
### Le seul endroit qui construit les features
|
||||||
|
|
||||||
|
`enervision_ml.features.build_features` est **l'unique** constructeur de features, à
|
||||||
|
l'entraînement comme au scoring. Le piège que cela évite : si les deux divergent, même d'une
|
||||||
|
fenêtre de moyenne glissante, le modèle reçoit en service des features qui ne ressemblent plus à
|
||||||
|
ce qu'il a appris, et ses prédictions se dégradent **sans qu'aucune erreur ne se déclenche**.
|
||||||
|
Ne jamais réécrire cette logique ailleurs : importer le module.
|
||||||
|
|
||||||
|
Conséquence sur la validation : la coupure entraînement / validation est **chronologique**, jamais
|
||||||
|
un tirage aléatoire de lignes. Un tirage aléatoire laisserait des lignes de validation voir des
|
||||||
|
lignes d'entraînement à travers leurs lags et leurs moyennes glissantes, une fuite qui masquerait
|
||||||
|
un surapprentissage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Les étapes d'un run de scoring
|
||||||
|
|
||||||
|
`python -m enervision_ml.score` calcule, pour chaque site ou pour un seul avec `--site-id`, la
|
||||||
|
consommation prévue de **l'heure suivant sa dernière lecture connue**, et écrit une ligne dans
|
||||||
|
`prediction`.
|
||||||
|
|
||||||
|
| # | Étape | Point de vigilance |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Charger une **fenêtre récente** de `reading` joint à `site` : 21 jours par défaut | Une marge au-dessus des 168 h qu'exige le lag hebdomadaire. Un `SELECT` non borné sur l'hypertable serait la même erreur que celle corrigée sur `GET /readings` |
|
||||||
|
| 2 | Ajouter **une ligne future par site**, l'heure suivante, et calculer ses features par `build_features` | La même fonction qu'à l'entraînement, cf. section 1 |
|
||||||
|
| 3 | Si le **lag de 168 h est absent** (moins d'une semaine d'historique) : écrire `status = "insufficient_data"` | **LightGBM n'est jamais appelé.** Un modèle interrogé sans son lag principal rendrait un nombre, et ce nombre serait faux sans le dire |
|
||||||
|
| 4 | Sinon : `booster.predict(...)`, puis écrire `status = "available"` et la valeur prévue | |
|
||||||
|
|
||||||
|
### Ce que le run écrit, et ce qu'il n'écrase pas
|
||||||
|
|
||||||
|
La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run
|
||||||
|
insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra
|
||||||
|
possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui
|
||||||
|
n'existe pas encore.
|
||||||
|
|
||||||
|
Trois contraintes de cohérence sont portées par la base et non par le code applicatif :
|
||||||
|
`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ;
|
||||||
|
`insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à
|
||||||
|
`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`.
|
||||||
|
|
||||||
|
### `model_reference` est un hachage, pas un nom de fichier
|
||||||
|
|
||||||
|
`train.py` réécrit **toujours le même chemin** (`models/lightgbm-consumption.txt`) à chaque
|
||||||
|
entraînement. Le nom de fichier ne distinguerait donc pas deux versions du modèle. `prediction`
|
||||||
|
porte pour cela le **SHA-256 tronqué du fichier modèle**. C'est ce qui permet, devant une
|
||||||
|
prédiction douteuse, de savoir quel modèle l'a produite.
|
||||||
|
|
||||||
|
### Mode CSV : rien n'est écrit en base
|
||||||
|
|
||||||
|
En `--csv`, le run ne touche pas la base. L'heure future calculée depuis la fin du CSV n'existe
|
||||||
|
dans aucune base réelle : ce serait inscrire une prévision pour un instant déjà passé. Le mode
|
||||||
|
sert à valider le pipeline sans base joignable.
|
||||||
|
|
||||||
|
### Limite assumée
|
||||||
|
|
||||||
|
La feature `is_working_hours` de la ligne future est **recopiée** depuis la dernière lecture
|
||||||
|
réelle, pas recalculée : il n'existe aucune règle d'heures ouvrables dans ce dépôt, elle vit dans
|
||||||
|
le générateur du jeu de données d'origine. L'approximation n'est fausse qu'aux heures de bascule,
|
||||||
|
sur une feature parmi une dizaine, pour une prévision à un seul pas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. La frontière entre l'API et le ML
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph ml["ml/ · projet Python indépendant"]
|
||||||
|
train["enervision_ml.train<br/>LightGBM + MLflow"]
|
||||||
|
score["enervision_ml.score<br/>prévision à un pas"]
|
||||||
|
end
|
||||||
|
subgraph db["PostgreSQL + TimescaleDB"]
|
||||||
|
reading[("reading, site")]
|
||||||
|
prediction[("prediction")]
|
||||||
|
end
|
||||||
|
subgraph api["apps/backend · FastAPI"]
|
||||||
|
route["GET /api/v1/predictions"]
|
||||||
|
end
|
||||||
|
|
||||||
|
reading -- "SQL direct, ML_DATABASE_URL" --> train
|
||||||
|
reading -- "fenêtre récente" --> score
|
||||||
|
train -- "models/*.txt + run MLflow" --> score
|
||||||
|
score -- "INSERT" --> prediction
|
||||||
|
prediction -- "lecture seule" --> route
|
||||||
|
```
|
||||||
|
|
||||||
|
**La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.**
|
||||||
|
`GET /api/v1/predictions` lit la dernière prévision par site dans `prediction`, jamais un recalcul
|
||||||
|
à la volée. Ce qui en découle, et qui est l'argument à tenir devant le jury :
|
||||||
|
|
||||||
|
- **La latence de l'API ne dépend pas du modèle.** Une route de lecture indexée
|
||||||
|
(`ix_prediction_site_target`) répond en temps constant, qu'un run de scoring dure une seconde
|
||||||
|
ou une minute.
|
||||||
|
- **Le service de production n'embarque ni LightGBM ni MLflow.** `ml/` est un projet Python
|
||||||
|
séparé, avec son propre `uv.lock`. Le backend n'a aucune raison de porter ces dépendances, ni
|
||||||
|
leur surface de vulnérabilités, pour un script lancé hors du chemin de requête.
|
||||||
|
- **Une panne du pipeline dégrade, elle n'interrompt pas.** Si le scoring ne tourne plus, l'API
|
||||||
|
continue de servir la dernière prévision connue, avec son `created_at` et son
|
||||||
|
`model_reference`, au lieu de rendre une erreur.
|
||||||
|
- **Le contrat est la table, pas un appel.** Ce qui traverse la frontière, ce sont des lignes de
|
||||||
|
`prediction` et leurs contraintes de cohérence, vérifiables en SQL.
|
||||||
|
|
||||||
|
Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est
|
||||||
|
celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly`
|
||||||
|
(issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme
|
||||||
|
l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la
|
||||||
|
surveillance de dérive, portée par les issues #44 et #45.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voir aussi
|
||||||
|
|
||||||
|
- [`ml/README.md`](../ml/README.md) : installation, commandes, options, où écrire les tests
|
||||||
|
- [ADR 0005](adr/0005-modele-prediction-lightgbm.md) : pourquoi LightGBM, et les 6 candidats écartés
|
||||||
|
- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions
|
||||||
|
- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions`
|
||||||
|
- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données
|
||||||
@@ -11,3 +11,7 @@
|
|||||||
| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
|
| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
|
||||||
| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête |
|
| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête |
|
||||||
| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
||||||
|
| [0005](adr/0005-modele-prediction-lightgbm.md) | LightGBM pour la prédiction de consommation, un modèle global |
|
||||||
|
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||||
|
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||||
|
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 0007 - Terminaison TLS par un reverse proxy Nginx, en Docker Compose
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-21
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Quatre documents désignaient le même trou. `10-infra.md` ouvrait ses questions par « Quel ingress
|
||||||
|
remplace Traefik, et qui termine le TLS ». `00-vue-ensemble.md` rangeait « TLS, HSTS et CSP » dans
|
||||||
|
« Absent, et assumé ». `owasp-traceabilite.md` laissait la ligne API8 transport ouverte.
|
||||||
|
`31-contrat-authentification.md` listait deux corrections « à faire avant la démonstration » :
|
||||||
|
servir le SPA et l'API sous la même origine, et servir en HTTPS.
|
||||||
|
|
||||||
|
Ce n'est pas un durcissement facultatif, c'est une condition de fonctionnement. Les deux fichiers
|
||||||
|
`apps/frontend/src/environments/environment*.ts` portent `apiUrl: '/api/v1'`, en relatif. En
|
||||||
|
développement, `proxy.conf.json` route `/api` vers l'API. Une fois en conteneur, plus rien ne le
|
||||||
|
fait : l'application déployée ne peut pas appeler son API. Et le cookie de rafraîchissement prend
|
||||||
|
le préfixe `__Secure-` dès que `APP_ENV` sort de `local`, donc sans HTTPS il n'est jamais posé et
|
||||||
|
l'authentification ne tient pas au rechargement de page.
|
||||||
|
|
||||||
|
La contrainte qui cadre tout le reste : **aucun nom de domaine public n'existe**. La cible
|
||||||
|
documentée est le serveur on-premise de l'école, `ssh_host = "10.0.0.10"` dans le
|
||||||
|
`terraform.tfvars.example`. Sur une adresse privée, le défi HTTP-01 de Let's Encrypt ne peut pas
|
||||||
|
aboutir, faute de DNS public et de port 80 entrant.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Un service `proxy` dans Docker Compose**, image officielle `nginx:1.28-alpine`, seul composant à
|
||||||
|
publier des ports sur la machine : 80 et 443. Backend et frontend ne sont plus publiés du tout, la
|
||||||
|
base et l'interface Mailpit sont ramenées sur la boucle locale. La stack complète est décrite par
|
||||||
|
l'overlay `docker-compose.prod.yml`, le `docker-compose.yml` restant la boucle de développement.
|
||||||
|
|
||||||
|
**Le SPA et l'API sont servis sous la même origine** : `/` vers le conteneur frontend, `/api/` vers
|
||||||
|
l'API en préservant le préfixe `/api/v1`. Le CORS cesse d'être un mécanisme de production et
|
||||||
|
redevient ce qu'il est, un filet pour les appels croisés qui ne devraient plus exister.
|
||||||
|
|
||||||
|
**nginx lit toujours les deux mêmes fichiers**, `/etc/nginx/tls/fullchain.pem` et `privkey.pem`.
|
||||||
|
Seule leur fabrication varie : un script `openssl` pour la démonstration, le `--deploy-hook` de
|
||||||
|
certbot quand un domaine existera. La configuration nginx ne connaît pas la différence et n'aura
|
||||||
|
pas à changer le jour de la bascule.
|
||||||
|
|
||||||
|
**Le proxy pose HSTS et CSP**, que l'application refuse de poser. Ce refus est verrouillé par
|
||||||
|
`tests/api/test_hardening.py::test_the_application_never_sets_hsts_itself` : l'application ne peut
|
||||||
|
pas savoir si elle est jointe en HTTPS, le terminateur, si.
|
||||||
|
|
||||||
|
## Pourquoi Compose et pas l'ingress k3s
|
||||||
|
|
||||||
|
Le module `infra/terraform/modules/k3s/` installe un cluster et rien d'autre. Il ne déclare que le
|
||||||
|
provider `null`, aucun namespace, aucun déploiement, aucun service, aucun ingress, et il n'a jamais
|
||||||
|
été appliqué. Passer par un ingress supposait d'abord de combler tout ce qui manque entre les deux
|
||||||
|
topologies : un registre d'images alimenté, des manifestes pour le front, l'API et la base, un
|
||||||
|
stockage persistant pour PostgreSQL. C'est le chantier que `10-infra.md` nomme « le trou entre les
|
||||||
|
deux topologies », et il ne tient pas dans le jalon.
|
||||||
|
|
||||||
|
Compose, lui, fait déjà tourner les quatre services sur un réseau commun. Le proxy y entre comme un
|
||||||
|
cinquième service, sans rien déplacer. La décision de désactiver Traefik reste valable : le choix
|
||||||
|
d'ingress n'est pas tranché ici, il est repoussé avec le reste de la bascule Kubernetes.
|
||||||
|
|
||||||
|
## Ce que le proxy n'expose pas, et pourquoi c'est structurel
|
||||||
|
|
||||||
|
`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**,
|
||||||
|
pas sous le préfixe `/api`. Avec un routage où seul `/api/` part vers l'API, ils tombent dans
|
||||||
|
`location /`, donc sur le SPA, donc hors d'atteinte publique. Aucune règle de blocage n'est
|
||||||
|
nécessaire, et il n'y en a pas : le jour où quelqu'un routera la racine vers l'API pour « réparer »
|
||||||
|
Swagger, il publiera les métriques avec.
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- `APP_ENV`, `APP_DEBUG`, `APP_CORS_ORIGINS`, `APP_TRUST_PROXY_HEADERS` et le TLS changent
|
||||||
|
ensemble, dans le même fichier. Hors `local`, la configuration refuse de démarrer sans origine
|
||||||
|
CORS, et le cookie devient `__Secure-ev_refresh`.
|
||||||
|
- `APP_TRUST_PROXY_HEADERS` passe à vrai, et le proxy écrit `X-Forwarded-For` avec
|
||||||
|
`$proxy_add_x_forwarded_for`, qui ajoute l'IP réelle en fin de chaîne. C'est exactement ce que
|
||||||
|
lit `get_client_ip()`. Toute autre forme ferait compter la limitation de débit par IP sur l'IP
|
||||||
|
du proxy, c'est-à-dire globalement.
|
||||||
|
- `--forwarded-allow-ips=*` reste sans conséquence : uvicorn s'en sert pour réécrire
|
||||||
|
`request.client` depuis `X-Forwarded-For`, et `get_client_ip()` est le seul lecteur de
|
||||||
|
`request.client` du backend, en dernier recours quand l'en-tête est absent.
|
||||||
|
- Une limitation de débit au frontal existe désormais, distincte de celle de l'application : 20
|
||||||
|
requêtes par seconde sur l'API, et 30 par minute sur les seules routes qui vérifient un secret,
|
||||||
|
`login`, `password`, `forgot-password` et `reset-password`. `/auth/me` et `/auth/refresh` en
|
||||||
|
sont exclues : elles partent à chaque chargement de page, et le NAT de l'école donnant une seule
|
||||||
|
adresse à toute la promotion, la zone resserrée les aurait transformées en 429 en démonstration.
|
||||||
|
- **La CSP contraint le build du frontend.** `script-src 'self'` interdit les gestionnaires
|
||||||
|
d'événements en ligne, et l'inlining du CSS critique d'Angular produisait exactement cela :
|
||||||
|
`<link rel="stylesheet" media="print" onload="this.media='all'">`. La feuille serait restée en
|
||||||
|
`media="print"`, donc l'application entière sans style. D'où `styles.inlineCritical: false` dans
|
||||||
|
`angular.json`. `style-src` garde `'unsafe-inline'`, dont Angular a besoin pour les styles de
|
||||||
|
composants injectés à l'exécution.
|
||||||
|
- **La redirection 80 vers 443 conserve `$host`.** Un client qui forge son en-tête `Host` obtient
|
||||||
|
donc une redirection vers l'hôte de son choix. Risque accepté : un navigateur ne peut pas être
|
||||||
|
amené à envoyer un `Host` étranger, aucun cache ne s'intercale, et figer un nom canonique
|
||||||
|
couperait l'accès par adresse IP, seule voie ouverte sur `10.0.0.10`.
|
||||||
|
- **Aucun `:?` dans l'overlay.** Compose interpole tout le fichier avant n'importe quelle
|
||||||
|
sous-commande : une garde y casserait `stop` et `logs` autant que `up`. `PUBLIC_HOST` retombe
|
||||||
|
donc sur `enervision.local`, et `make stack-up` vérifie à la place que le certificat présent
|
||||||
|
couvre l'hôte demandé, ce qui est la condition réelle à tenir.
|
||||||
|
- Le proxy attend une API saine et pas seulement démarrée : le `HEALTHCHECK` de l'image du backend
|
||||||
|
sert de condition à `depends_on`, faute de quoi les premiers appels à `/api/` répondent 502.
|
||||||
|
- La ligne API8 transport de `owasp-traceabilite.md` se referme.
|
||||||
|
- **Let's Encrypt n'est pas prouvé.** Le chemin ACME est livré, monté et documenté ; il n'a pas
|
||||||
|
été exercé faute de domaine. Le certificat de démonstration est auto-signé, le navigateur
|
||||||
|
avertit, et c'est la situation réelle du projet, pas un raccourci.
|
||||||
|
- Le proxy résout ses cibles par le résolveur interne de Docker plutôt que par un bloc `upstream`,
|
||||||
|
sans quoi recréer le seul conteneur backend suffirait à produire des 502 jusqu'au rechargement.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
- **Ingress k3s avec cert-manager** : la bonne cible, et elle reste la cible. Elle suppose un
|
||||||
|
registre et des manifestes qui n'existent pas, à quatre jours du rendu.
|
||||||
|
- **Étendre le `nginx.conf` du conteneur frontend** avec un `location /api` et l'écoute TLS :
|
||||||
|
moins de pièces, mais les certificats entrent dans l'image du front et tout rebuild du front
|
||||||
|
redéploie le terminateur TLS. La séparation des cycles de vie vaut le conteneur supplémentaire.
|
||||||
|
- **Traefik ou Caddy**, qui automatisent ACME : ils déplacent le problème sans le résoudre, le
|
||||||
|
défi HTTP-01 échouant pour la même raison. Et l'issue nomme Nginx.
|
||||||
|
- **Let's Encrypt par défi DNS-01** : fonctionne derrière une IP privée, mais exige un domaine
|
||||||
|
possédé et un jeton d'API chez le fournisseur DNS. Rouvrable sans rien changer à la
|
||||||
|
configuration nginx le jour où ces deux éléments existent.
|
||||||
|
- **Un `Dockerfile` de proxy** : inutile, la configuration est montée en volume. Cela évite aussi
|
||||||
|
la dépendance à un registre authentifié, piège déjà présent dans `apps/frontend/Dockerfile`.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 0008 - Airflow exécute le code du backend en sous-processus
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-21
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'issue #116 demande un DAG d'alertes. Ce qu'il a à ordonnancer existe déjà et n'est pas à
|
||||||
|
réécrire : `AlertService.detect()` et ses cinq règles (#104), puis le moteur de recommandations
|
||||||
|
(#38). Les deux vivent dans `apps/backend/app/`, et
|
||||||
|
l'[ADR 0006](0006-moteur-de-regles-dans-le-backend.md) a précisément décidé qu'ils y restent parce
|
||||||
|
qu'ils s'appuient sur les repositories ORM de l'API plutôt que sur du SQL brut. Les deux
|
||||||
|
sont décrits par la documentation comme « lancés à la main ».
|
||||||
|
|
||||||
|
L'image Airflow livrée par #115 ne porte que `ml/`, dans un environnement `uv` distinct
|
||||||
|
(`/opt/ml/.venv`, Python 3.14) de celui d'Airflow lui-même (Python 3.12, contraint par
|
||||||
|
apache-airflow 2.10). Les DAGs `ml_train` et `ml_score` shellent vers cet environnement. Rien
|
||||||
|
d'équivalent n'existe pour `apps/backend` : un `BashOperator` sur
|
||||||
|
`python -m app.detection.internal_alerts` échouerait en `ModuleNotFoundError`.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**L'image Airflow porte un troisième environnement, `/opt/backend/.venv`**, construit depuis le
|
||||||
|
`pyproject.toml`, le `uv.lock` et le paquet `app/` du backend. Le DAG `alertes` shelle vers lui
|
||||||
|
exactement comme `ml_score` shelle vers `/opt/ml/.venv`.
|
||||||
|
|
||||||
|
Trois raisons :
|
||||||
|
|
||||||
|
- **Le patron existe et vient d'être revu.** #115 a posé `BashOperator` + `uv run --no-sync` +
|
||||||
|
`env -u VIRTUAL_ENV`, avec les tests d'intégrité qui le verrouillent. Introduire une seconde
|
||||||
|
forme d'appel dans le même dossier `dags/` coûterait plus cher à lire qu'un second environnement
|
||||||
|
dans le même `Dockerfile`.
|
||||||
|
- **Aucune surface réseau n'est ajoutée.** La détection n'a pas de route HTTP, contrairement à la
|
||||||
|
génération de recommandations (`POST /recommendations/generate`, rôle `admin`). En créer une pour
|
||||||
|
qu'Airflow l'appelle donnerait à l'ordonnanceur un compte administrateur de l'API, en plus des
|
||||||
|
identifiants PostgreSQL complets qu'il détient déjà, et ferait dépendre la production d'alertes
|
||||||
|
de la disponibilité du conteneur `backend`.
|
||||||
|
- **La logique reste où l'ADR 0006 l'a mise.** Le DAG n'apprend rien du domaine : ni les seuils, ni
|
||||||
|
les cinq règles, ni les clés d'idempotence. Il ne sait que l'heure à laquelle appeler.
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- **Airflow reçoit une `APP_SECRET_KEY` délibérément distincte de celle de l'API.** La
|
||||||
|
configuration du backend refuse de se construire sans elle (`app/core/config.py`), et
|
||||||
|
`internal_alerts.main()` appelle `get_settings()` avant toute requête pour échouer tôt. Mais la
|
||||||
|
détection ne signe ni ne vérifie aucun jeton, et Airflow permet d'exécuter du code arbitraire
|
||||||
|
depuis son interface : un Airflow compromis ne doit pas livrer la clé de signature des JWT. D'où
|
||||||
|
`AIRFLOW_APP_SECRET_KEY`, avec sa propre garde dans `airflow-init`.
|
||||||
|
- **`DATABASE_URL`, en dialecte asyncpg, rejoint `ML_DATABASE_URL`** dans l'environnement du
|
||||||
|
conteneur. Le cantonnement des rôles PostgreSQL reste la dette de
|
||||||
|
l'[ADR 0003](0003-autorisation-rbac-a-trois-roles.md), et cette décision l'alourdit d'un
|
||||||
|
consommateur de plus.
|
||||||
|
- **La CI Airflow se déclenche sur les changements du backend.** L'image le `COPY` : sans
|
||||||
|
`apps/backend/app/**`, `pyproject.toml` et `uv.lock` dans les déclencheurs du workflow, une
|
||||||
|
dépendance modifiée casserait la construction sans que rien ne le signale avant le déploiement.
|
||||||
|
En contrepartie, l'image grossit de ce que pèsent SQLAlchemy, asyncpg et pandas.
|
||||||
|
- **Aucune variable ne départage les deux environnements, et c'est voulu.** `uv` place par défaut
|
||||||
|
le venv d'un projet dans `<projet>/.venv` : `cd /opt/ml` ou `cd /opt/backend` suffit à choisir le
|
||||||
|
bon. L'image ne pose donc plus de `UV_PROJECT_ENVIRONMENT` global, hérité de #115 : il vaudrait
|
||||||
|
pour les deux projets, et `uv run` dans l'un résoudrait le venv de l'autre. Le symptôme n'est pas
|
||||||
|
une construction ratée mais un `ModuleNotFoundError` à la première tâche, d'où la vérification
|
||||||
|
d'import sans réseau que la CI fait maintenant sur chacun des deux.
|
||||||
|
- Airflow lui-même reste étranger au domaine : ni LightGBM, ni SQLAlchemy, ni FastAPI n'entrent
|
||||||
|
dans son interpréteur. C'est la propriété que #115 avait établie, et elle tient toujours.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
- **Route HTTP `POST /alerts/detect` réservée `admin`, appelée par le DAG.** L'image ne bougeait
|
||||||
|
pas, mais Airflow détenait alors un compte administrateur de l'API, la détection devenait
|
||||||
|
tributaire du conteneur `backend`, et l'API gagnait une route d'écriture dont aucun client
|
||||||
|
humain n'a l'usage. À rouvrir si un jour un tiers doit déclencher la détection.
|
||||||
|
- **`DockerOperator` lançant l'image du backend.** Demande la socket Docker de l'hôte dans le
|
||||||
|
conteneur Airflow, c'est-à-dire un équivalent root sur la machine, pour un service qui permet
|
||||||
|
déjà d'exécuter du code depuis son interface. Le provider n'est d'ailleurs pas installé.
|
||||||
|
- **Réécrire les cinq règles en SQL dans le DAG.** Contredit frontalement l'ADR 0006, duplique le
|
||||||
|
domaine, et fait diverger les deux copies au premier changement de seuil.
|
||||||
|
- **Monter `apps/backend` en volume plutôt que le copier.** L'environnement ne serait plus figé à
|
||||||
|
la construction, `uv` resynchroniserait au premier lancement, et la CI ne prouverait plus rien
|
||||||
|
de ce qui tourne réellement.
|
||||||
@@ -46,6 +46,7 @@ flowchart TB
|
|||||||
navigateur["Navigateur"]
|
navigateur["Navigateur"]
|
||||||
|
|
||||||
subgraph machine["Machine on-premise"]
|
subgraph machine["Machine on-premise"]
|
||||||
|
proxy["Reverse proxy Nginx<br/>:80 et :443"]
|
||||||
front["Frontend Angular 22<br/>apps/frontend"]
|
front["Frontend Angular 22<br/>apps/frontend"]
|
||||||
api["API FastAPI<br/>apps/backend"]
|
api["API FastAPI<br/>apps/backend"]
|
||||||
db[("PostgreSQL 17<br/>TimescaleDB")]
|
db[("PostgreSQL 17<br/>TimescaleDB")]
|
||||||
@@ -54,7 +55,9 @@ flowchart TB
|
|||||||
grafana["Grafana"]
|
grafana["Grafana"]
|
||||||
end
|
end
|
||||||
|
|
||||||
navigateur --> front
|
navigateur --> proxy
|
||||||
|
proxy --> front
|
||||||
|
proxy --> api
|
||||||
front -.-> api
|
front -.-> api
|
||||||
api --> db
|
api --> db
|
||||||
airflow --> db
|
airflow --> db
|
||||||
@@ -67,9 +70,10 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
|
|||||||
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||||
[30-frontend.md](30-frontend.md).
|
[30-frontend.md](30-frontend.md).
|
||||||
|
|
||||||
Le lien `airflow --> db` est maintenant en trait plein : deux DAGs orchestrent l'entraînement et
|
Le lien `airflow --> db` est maintenant en trait plein : trois DAGs tournent, deux pour
|
||||||
le scoring du modèle ML (issue #115), cf. plus bas et [20-backend.md](20-backend.md). Le reste du
|
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
|
||||||
périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit.
|
génération des recommandations (issue #116), cf. plus bas et [20-backend.md](20-backend.md). Le
|
||||||
|
reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit.
|
||||||
|
|
||||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
||||||
collecteur ne vient le lire.
|
collecteur ne vient le lire.
|
||||||
@@ -81,17 +85,18 @@ collecteur ne vient le lire.
|
|||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, contrat OpenAPI versionné, routes `sites`, `alerts`, `recommendations`, `stats/summary`, `readings`, `sensors/status` et `predictions` en lecture (endpoints → services → repositories → models) |
|
||||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, authentification complète (garde de route, intercepteur de jeton), cinq services HTTP, graphiques Chart.js. `stats`/`alerts` sur fixtures, `predictions` branché sur l'API réelle |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
||||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
|
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
|
||||||
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
|
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Deux DAGs (`ml_train` manuel, `ml_score` `@hourly`) orchestrent le pipeline ML existant en sous-processus `uv run` (issue #115). L'ingestion (issues #15/#16) n'a pas encore de DAG |
|
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
|
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 5 workflows, 16 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Détail dans [50-cicd.md](50-cicd.md). **Aucun job de déploiement** (#21) |
|
||||||
|
|
||||||
## Flux bout en bout
|
## Flux bout en bout
|
||||||
|
|
||||||
Statut : `Cible`. Ce flux d'ingestion (Source → Airflow → hypertable) n'existe pas encore : les
|
Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin
|
||||||
deux DAGs livrés à ce jour (`ml_train`/`ml_score`, issue #115) orchestrent le pipeline ML, pas
|
d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`,
|
||||||
l'ingestion. Seule la base tourne réellement parmi les maillons ci-dessous.
|
issue #115 ; `alertes`, issue #116) orchestrent le pipeline ML et la détection d'alertes, pas
|
||||||
|
l'ingestion, qui reste lancée à la main par les scripts d'import (issues #15 et #16).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -143,6 +148,11 @@ consolidée.
|
|||||||
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
|
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
|
||||||
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
|
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
|
||||||
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
|
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
|
||||||
|
- **Terminaison TLS au frontal** : un reverse proxy Nginx est le seul service publié, il redirige
|
||||||
|
80 vers 443, sert le SPA et l'API sous la même origine, pose **HSTS** et **CSP** que
|
||||||
|
l'application refuse délibérément de poser, et ajoute une **limitation de débit au frontal**
|
||||||
|
distincte de celle de l'application. Voir
|
||||||
|
[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
- **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root`
|
- **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root`
|
||||||
sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les
|
sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les
|
||||||
`.example`.
|
`.example`.
|
||||||
@@ -156,13 +166,12 @@ consolidée.
|
|||||||
arrêteraient une application compromise. Même raison de report.
|
arrêteraient une application compromise. Même raison de report.
|
||||||
- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut
|
- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut
|
||||||
agir sur le site B. C'est la limite connue du modèle.
|
agir sur le site B. C'est la limite connue du modèle.
|
||||||
- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore.
|
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi
|
||||||
- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas
|
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME
|
||||||
l'infrastructure.
|
est livré et documenté mais pas exercé.
|
||||||
- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD.
|
- **Analyse des images de conteneur** dans la CI. Celle des dépendances, elle, est en place
|
||||||
- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP
|
(`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir
|
||||||
simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir
|
[50-cicd.md](50-cicd.md).
|
||||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
|
||||||
|
|
||||||
## Décisions structurantes
|
## Décisions structurantes
|
||||||
|
|
||||||
@@ -176,3 +185,4 @@ Elles vivent dans `../adr/`, pas ici.
|
|||||||
| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
||||||
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
|
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
|
||||||
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||||
|
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||||
|
|||||||
+116
-14
@@ -1,12 +1,13 @@
|
|||||||
# Infrastructure
|
# Infrastructure
|
||||||
|
|
||||||
Deux topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
|
Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
|
||||||
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre les deux.
|
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.
|
||||||
|
|
||||||
| Topologie | Sert à | Statut |
|
| Topologie | Sert à | Statut |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
||||||
| k3s single-node | Déployer sur le serveur on-premise | `En cours` |
|
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
|
||||||
|
| k3s single-node | Cible à terme | `En cours` |
|
||||||
|
|
||||||
## Poste de développement
|
## Poste de développement
|
||||||
|
|
||||||
@@ -40,15 +41,17 @@ seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste ave
|
|||||||
des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le
|
des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le
|
||||||
port 8000, ils ne se lancent donc pas ensemble.
|
port 8000, ils ne se lancent donc pas ensemble.
|
||||||
|
|
||||||
Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
|
Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
|
||||||
|
|
||||||
- `PGDATA` vaut `/home/postgres/pgdata/data` pour l'image `-ha`, et non le chemin habituel de
|
- `PGDATA` vaut `/home/postgres/pgdata/data` pour l'image `-ha`, et non le chemin habituel de
|
||||||
l'image `postgres`. Monté ailleurs, le volume ne retient rien, sans le moindre message.
|
l'image `postgres`. Monté ailleurs, le volume ne retient rien, sans le moindre message.
|
||||||
- `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 :
|
||||||
|
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
|
||||||
|
|
||||||
### Airflow (`ml_train`/`ml_score`, issue #115)
|
### Airflow (issues #115 et #116)
|
||||||
|
|
||||||
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
|
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
|
||||||
airflow-up`, pas dans `make dev`) :
|
airflow-up`, pas dans `make dev`) :
|
||||||
@@ -57,13 +60,67 @@ airflow-up`, pas dans `make dev`) :
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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. `webserver`/`scheduler` attendent qu'il se termine avec succès |
|
||||||
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même |
|
| `airflow-webserver` | UI, port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même |
|
||||||
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --frozen --no-dev python -m enervision_ml...`), 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` |
|
||||||
|
|
||||||
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` `ml/pyproject.toml`/`ml/uv.lock`/`ml/enervision_ml` pour se
|
l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser
|
||||||
synchroniser un second environnement Python **3.14** (`/opt/ml/.venv`, `uv sync --locked` à la
|
deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à
|
||||||
construction), distinct du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent vers
|
la construction), distincts du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent
|
||||||
ce venv plutôt que d'importer LightGBM/MLflow dans le process Airflow.
|
vers ces venvs plutôt que d'importer LightGBM, MLflow ou SQLAlchemy dans le process Airflow.
|
||||||
|
Le choix et ses contreparties sont dans
|
||||||
|
l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
|
||||||
|
|
||||||
|
| DAG | Planification | Ce qu'il lance, et où |
|
||||||
|
|---|---|---|
|
||||||
|
| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` |
|
||||||
|
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
||||||
|
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
||||||
|
|
||||||
|
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
|
||||||
|
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
|
||||||
|
finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni
|
||||||
|
tâche greffée : quatre règles de détection sur cinq ne touchent pas au modèle, et un modèle jamais
|
||||||
|
entraîné ne doit pas priver le parc de ses alertes. Le décalage est donc une convention et non une
|
||||||
|
garantie : le plafond de `ml_score` est de 30 minutes, et un scoring qui déborde de `:15` prive
|
||||||
|
`anomaly` de la `prediction` de l'heure, qu'elle ne retrouvera au passage suivant que si sa fenêtre
|
||||||
|
la couvre encore. Les quatre autres règles ne s'en aperçoivent pas.
|
||||||
|
|
||||||
|
Ses deux tâches s'enchaînent en revanche (`recommendation.alert_id` est une clé étrangère `NOT
|
||||||
|
NULL`), et toutes deux sont rejouables sans risque : l'idempotence est portée par la base,
|
||||||
|
`uq_alert_source_reference` et `uq_recommendation_alert_rule`. Chacune a 2 tentatives, 2 minutes
|
||||||
|
d'attente entre elles et un plafond de 5 minutes **par tentative** : au pire, reprises comprises,
|
||||||
|
l'enchaînement occupe 38 minutes, ce qui le garde sous le pas horaire qu'un `max_active_runs=1`
|
||||||
|
rend contraignant.
|
||||||
|
|
||||||
|
`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`,
|
||||||
|
`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une
|
||||||
|
migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et
|
||||||
|
`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
|
||||||
|
passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
|
||||||
|
|
||||||
|
Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier
|
||||||
|
entier avant de filtrer les services, une variable requise manquante casserait `make db-up`,
|
||||||
|
`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est
|
||||||
|
`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask, mot de passe ou
|
||||||
|
`AIRFLOW_APP_SECRET_KEY` vides).
|
||||||
|
|
||||||
|
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
|
||||||
|
**délibérément différente** de celle de l'API. La configuration du backend refuse de se construire
|
||||||
|
sans clé, mais la détection ne signe ni ne vérifie aucun jeton : un Airflow compromis, qui permet
|
||||||
|
déjà d'exécuter du code depuis son interface, ne doit pas livrer par-dessus la clé de signature
|
||||||
|
des JWT.
|
||||||
|
|
||||||
|
**Pourquoi `ml_train` est manuel.** Réentraîner est coûteux et sa cadence n'est pas une décision
|
||||||
|
prise. Surtout, `train.py` écrase le modèle sans comparer ses métriques à celles de l'ancien : un
|
||||||
|
cron déploierait silencieusement un modèle dégradé. Tant que ce garde-fou n'existe pas, le
|
||||||
|
déclenchement reste humain. `ml_score`, lui, est planifié à l'heure, avec `max_active_runs=1`
|
||||||
|
(pas deux scorings simultanés dans `prediction`), 2 tentatives et un plafond de 30 minutes.
|
||||||
|
|
||||||
|
CI : `.github/workflows/airflow.yml` (Python 3.12 via `etl/airflow/.python-version`) lance lint et
|
||||||
|
tests d'intégrité des DAGs, et construit l'image (elle `COPY` `ml/` et `apps/backend/`, une
|
||||||
|
modification de l'un ou de l'autre peut donc la casser, d'où leurs chemins dans les déclencheurs)
|
||||||
|
avant de vérifier que les deux environnements s'y importent sans réseau.
|
||||||
|
|
||||||
Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier
|
Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier
|
||||||
`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute
|
`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute
|
||||||
@@ -75,7 +132,44 @@ est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans qu
|
|||||||
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
||||||
construction de l'image.
|
construction de l'image.
|
||||||
|
|
||||||
## Cible de déploiement
|
## Machine cible, exécution Docker
|
||||||
|
|
||||||
|
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
|
||||||
|
`docker-compose.yml`. Écrit et validé sur le poste, **jamais encore lancé sur le serveur de
|
||||||
|
l'école**. Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
navigateur["Navigateur"]
|
||||||
|
|
||||||
|
subgraph machine["Machine on-premise"]
|
||||||
|
proxy["service proxy<br/>nginx:1.28-alpine<br/>:80 et :443"]
|
||||||
|
front["service frontend<br/>nginx statique :3000"]
|
||||||
|
api["service backend<br/>uvicorn :8000"]
|
||||||
|
db[("service db<br/>:5432")]
|
||||||
|
mail["service mailpit"]
|
||||||
|
end
|
||||||
|
|
||||||
|
navigateur -->|"HTTPS"| proxy
|
||||||
|
proxy -->|"/"| front
|
||||||
|
proxy -->|"/api/"| api
|
||||||
|
api --> db
|
||||||
|
api --> mail
|
||||||
|
```
|
||||||
|
|
||||||
|
Le proxy est **le seul service à publier des ports** sur le réseau. Backend et frontend ne sont
|
||||||
|
plus publiés du tout, la base et l'interface Mailpit sont ramenées sur `127.0.0.1`, donc joignables
|
||||||
|
par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et
|
||||||
|
la commande de validation hors exécution sont dans [`infra/proxy/README.md`](../../infra/proxy/README.md).
|
||||||
|
|
||||||
|
Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent pas :
|
||||||
|
|
||||||
|
- Servir le SPA et l'API sous la même origine est ce qui rend le cookie `__Secure-ev_refresh`
|
||||||
|
utilisable. Sans cela, `apiUrl: '/api/v1'` ne mène nulle part une fois en conteneur.
|
||||||
|
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
|
||||||
|
compte sur l'IP du proxy et devient globale.
|
||||||
|
|
||||||
|
## Cible à terme, k3s
|
||||||
|
|
||||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
||||||
été appliqué.
|
été appliqué.
|
||||||
@@ -131,6 +225,8 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
|||||||
| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` |
|
| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` |
|
||||||
| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` |
|
| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` |
|
||||||
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
|
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
|
||||||
|
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
|
||||||
|
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
|
||||||
|
|
||||||
## Ports et noms
|
## Ports et noms
|
||||||
|
|
||||||
@@ -139,14 +235,16 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
|||||||
| PostgreSQL, côté hôte | `5433` | Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris |
|
| PostgreSQL, côté hôte | `5433` | Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris |
|
||||||
| PostgreSQL, côté réseau Compose | `db:5432` | Nom de service, utilisé par `DATABASE_URL` du service `backend` |
|
| PostgreSQL, côté réseau Compose | `db:5432` | Nom de service, utilisé par `DATABASE_URL` du service `backend` |
|
||||||
| API | `8000` | Identique en conteneur et hors conteneur |
|
| API | `8000` | Identique en conteneur et hors conteneur |
|
||||||
| Frontend, `ng serve` | `4200` | Valeur par défaut d'`APP_CORS_ORIGINS`. Le compose n'a aucun service frontend |
|
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
|
||||||
|
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
|
||||||
|
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`. 80 ne sert que la redirection et le défi ACME |
|
||||||
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
||||||
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
||||||
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
|
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
|
||||||
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
|
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
|
||||||
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
|
| Webserver Airflow | `8080` | `make airflow-up`. Scheduler et webserver ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
|
||||||
|
|
||||||
## Le trou entre les deux topologies
|
## Le trou vers k3s
|
||||||
|
|
||||||
Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose
|
Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose
|
||||||
construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première
|
construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première
|
||||||
@@ -154,7 +252,11 @@ question à trancher, avant toute ressource Kubernetes.
|
|||||||
|
|
||||||
## Questions ouvertes
|
## Questions ouvertes
|
||||||
|
|
||||||
- **Quel ingress** remplace Traefik, et qui termine le TLS.
|
- **Quel ingress** remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par
|
||||||
|
l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), mais la réponse vaut pour la
|
||||||
|
topologie Compose, pas pour Kubernetes.
|
||||||
|
- **Quel nom de domaine public**, sans lequel Let's Encrypt reste hors d'atteinte et le certificat
|
||||||
|
reste auto-signé.
|
||||||
- **Quel registre d'images**, et comment il est alimenté sans CI.
|
- **Quel registre d'images**, et comment il est alimenté sans CI.
|
||||||
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
|
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
|
||||||
cluster ou à côté.
|
cluster ou à côté.
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend
|
|||||||
plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte
|
plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte
|
||||||
`ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais
|
`ck_prediction_status` n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais
|
||||||
LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf.
|
LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf.
|
||||||
[ML-START.md](../../ML-START.md) section 3.
|
[ML-START.md](../ML-START.md) section 3.
|
||||||
|
|
||||||
`POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique
|
`POST /recommendations/generate` est la seule route d'écriture métier du contrat. Elle applique
|
||||||
le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni
|
le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni
|
||||||
@@ -256,12 +256,19 @@ auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spi
|
|||||||
explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul
|
explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul
|
||||||
instant réel, pas une variation).
|
instant réel, pas une variation).
|
||||||
|
|
||||||
Comme `enervision_ml.score`, la détection est un script lancé à la main, pas encore ordonnancé par
|
La détection s'exécute dans `apps/backend`, puisque les règles s'appuient sur les repositories ORM
|
||||||
Airflow : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans
|
de l'API plutôt que sur une connexion SQL directe (contrairement à
|
||||||
`apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une
|
`app/etl/historical_import.py`) : `uv run python -m app.detection.internal_alerts [--site-id ...]
|
||||||
connexion SQL directe (contrairement à `app/etl/historical_import.py`). Cette issue (#104)
|
[--now ...]`, ou `make detect-alerts`. Cette issue (#104) débloquait #38 (moteur de règles pour
|
||||||
débloquait #38 (moteur de règles pour recommandations), dont la FK `alert_id` `NOT NULL` n'avait
|
recommandations), dont la FK `alert_id` `NOT NULL` n'avait jusqu'ici rien à référencer côté
|
||||||
jusqu'ici rien à référencer côté `source="enervision"`.
|
`source="enervision"`.
|
||||||
|
|
||||||
|
Depuis l'issue #116, le lancement n'est plus manuel : le DAG Airflow `alertes` enchaîne cette
|
||||||
|
détection et la génération des recommandations, toutes les heures à la quinzième minute. Airflow
|
||||||
|
exécute le code du backend en sous-processus, dans son propre environnement, ce que décide
|
||||||
|
l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md) ; le détail de l'ordonnancement est
|
||||||
|
dans [10-infra.md](10-infra.md). La ligne de commande reste le moyen de rejouer une fenêtre
|
||||||
|
passée, ce que `--now` permet et que le DAG ne fait pas.
|
||||||
|
|
||||||
### `/health/ready`
|
### `/health/ready`
|
||||||
|
|
||||||
@@ -390,9 +397,12 @@ Le reste, par ordre de surface :
|
|||||||
de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL.
|
de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL.
|
||||||
- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`,
|
- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`,
|
||||||
`Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au
|
`Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au
|
||||||
terminateur TLS, que l'application ne connaît pas.
|
terminateur TLS, que l'application ne connaît pas : le reverse proxy les pose
|
||||||
|
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)).
|
||||||
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
|
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
|
||||||
- Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative.
|
- TLS, limitation de débit au frontal et journal d'accès sont portés par le reverse proxy.
|
||||||
|
`APP_TRUST_PROXY_HEADERS` doit alors valoir vrai, sinon le compteur par IP devient global.
|
||||||
|
- Pas de journalisation des accès applicative.
|
||||||
|
|
||||||
## Observabilité
|
## Observabilité
|
||||||
|
|
||||||
|
|||||||
@@ -97,11 +97,11 @@ En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost
|
|||||||
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
|
||||||
`apiUrl` relatif, `/api/v1`.
|
`apiUrl` relatif, `/api/v1`.
|
||||||
|
|
||||||
En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif
|
En production, `environment.ts` porte lui aussi un `apiUrl` relatif (`/api/v1`) plutôt qu'une URL
|
||||||
(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur
|
absolue : la dette qui pointait en dur sur `http://localhost:8000/api/v1` a été corrigée. Un build
|
||||||
`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...`
|
de production sert donc l'appel `/api/v1/...` sur son propre origin, et c'est le **reverse proxy**
|
||||||
sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le
|
qui route `/api` vers le backend : `location /api/` dans `infra/proxy/conf.d/enervision.conf`, voir
|
||||||
backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md).
|
[10-infra.md](10-infra.md) et l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
|
|
||||||
## Exécution
|
## Exécution
|
||||||
|
|
||||||
@@ -118,13 +118,14 @@ le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.1
|
|||||||
tester ni construire le frontend.
|
tester ni construire le frontend.
|
||||||
|
|
||||||
Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`,
|
Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`,
|
||||||
englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en
|
englobées par `install` et `dev`). En développement il tourne directement via `npm`, depuis
|
||||||
développement il tourne toujours directement via `npm`, depuis `apps/frontend`. Le port 4200
|
`apps/frontend` : le port 4200 n'apparaît dans le compose que comme valeur par défaut
|
||||||
n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend.
|
d'`APP_CORS_ORIGINS`, côté backend.
|
||||||
|
|
||||||
Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans
|
Le service `frontend` du `docker-compose.yml` sert le build statique par le nginx de
|
||||||
`CMD` : il construit sans rien servir. Le `README.md` de l'application demande un multi-étage
|
`apps/frontend/Dockerfile`, multi-étage, qui **écoute sur 3000**. En déploiement il n'est plus
|
||||||
avec un service statique, il reste à écrire.
|
publié du tout : le reverse proxy est seul à sortir sur le réseau, et l'atteint par le réseau
|
||||||
|
Compose.
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
@@ -133,11 +134,41 @@ avec un service statique, il reste à écrire.
|
|||||||
`/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
|
`/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
|
||||||
rafraîchissement sur 401. Détail complet dans
|
rafraîchissement sur 401. Détail complet dans
|
||||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||||
|
- **La CSP posée par le reverse proxy contraint le build.** `script-src 'self'` interdit les
|
||||||
|
gestionnaires d'événements en ligne ; l'inlining du CSS critique en produisait un
|
||||||
|
(`<link media="print" onload="this.media='all'">`), ce qui aurait laissé l'application sans
|
||||||
|
style derrière le proxy. D'où `optimization.styles.inlineCritical: false` dans la configuration
|
||||||
|
de production d'`angular.json`. La contrepartie est un rendu non stylé très bref au premier
|
||||||
|
affichage. `style-src` conserve `'unsafe-inline'` : Angular injecte les styles de composants à
|
||||||
|
l'exécution, et s'en passer demanderait un `ngCspNonce` que le SPA statique ne peut pas produire.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md).
|
Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md).
|
||||||
|
|
||||||
|
## Recommandations
|
||||||
|
|
||||||
|
Statut : `Fait`. La vue `/recommendations` (`features/recommendations`, derrière `authGuard`, tous
|
||||||
|
rôles) présente les recommandations du moteur de règles groupées par alerte, du plus récent au plus
|
||||||
|
ancien, avec le contexte de l'alerte (sévérité, type, site, horodatage, message) puis chaque action,
|
||||||
|
son explication et la règle qui l'a produite.
|
||||||
|
|
||||||
|
- **Jointure côté client.** Une recommandation ne porte que `alert_id`, jamais `site_id`, et
|
||||||
|
`GET /recommendations` n'a aucun filtre. `app-recommendation-list` (`shared/components/`) charge
|
||||||
|
donc en parallèle `GET /alerts` (filtré par `site_id` quand un site est fixé) et
|
||||||
|
`GET /recommendations`, puis les joint par `alert_id` (`joinByAlert`, fonction pure testée à
|
||||||
|
part). Les recommandations dont l'alerte n'est pas dans le jeu chargé sont ignorées : c'est ainsi
|
||||||
|
que le filtre site s'applique. `/alerts` n'étant pas paginé, un seul appel suffit.
|
||||||
|
- **Paramètres d'URL.** `?site=<site_id>` présélectionne le filtre site ; `?alert=<alert_id>`
|
||||||
|
réduit la vue à une alerte et la met en évidence (entier strictement positif, sinon ignoré).
|
||||||
|
- **Génération.** Le bouton « Générer les recommandations » n'apparaît que pour le rôle `admin`
|
||||||
|
(`POST /recommendations/generate?site_id=`, réservé admin côté API) et affiche le bilan renvoyé
|
||||||
|
(créées, déjà présentes, alertes examinées) avant de recharger la liste. La voie normale reste le
|
||||||
|
DAG Airflow `alertes` ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
|
||||||
|
- **Entrées.** Lien « Recommandations » dans l'en-tête du tableau de bord ; section
|
||||||
|
« Recommandations » sur la vue détail d'un site (liste restreinte au site, lien vers la vue
|
||||||
|
complète préfiltrée).
|
||||||
|
|
||||||
## Questions ouvertes
|
## Questions ouvertes
|
||||||
|
|
||||||
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
|
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
|
||||||
|
|||||||
@@ -129,18 +129,18 @@ n'est pas envoyé et le rafraîchissement échoue toujours.
|
|||||||
En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est
|
En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est
|
||||||
**même origine** et le cookie marche sans rien configurer.
|
**même origine** et le cookie marche sans rien configurer.
|
||||||
|
|
||||||
En production, `src/environments/environment.ts` contient encore le gabarit
|
En déploiement, les deux conditions sont désormais remplies par le reverse proxy
|
||||||
`http://localhost:8000/api/v1`, en HTTP simple et sur une autre origine. **Dans cet état, aucun
|
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)) : `environment.ts` porte un
|
||||||
cookie `Secure` ne sera posé et l'authentification ne fonctionnera pas.**
|
`apiUrl` relatif, `/api/v1`, et le proxy sert le SPA sur `/` et l'API sur `/api/` **sous la même
|
||||||
|
origine, en HTTPS**. C'est cela, et rien d'autre, qui rend le cookie `__Secure-ev_refresh`
|
||||||
|
utilisable : servi en HTTP simple ou depuis une autre origine, il n'est jamais posé et
|
||||||
|
l'authentification ne survit pas à un rechargement de page.
|
||||||
|
|
||||||
Deux corrections, à faire avant la démonstration :
|
Ce qui reste à surveiller : le certificat est auto-signé tant qu'aucun domaine public ne résout
|
||||||
|
vers la machine. Un navigateur qui refuse l'exception refusera aussi le cookie.
|
||||||
1. passer `apiUrl` à `/api/v1` et servir le SPA et l'API sous la même origine, via un
|
|
||||||
`location /api` dans le `nginx.conf` du conteneur frontend ou via l'ingress ;
|
|
||||||
2. servir en HTTPS.
|
|
||||||
|
|
||||||
Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin
|
Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin
|
||||||
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que le proxy masque.
|
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que la même origine masque.
|
||||||
|
|
||||||
## Origines autorisées
|
## Origines autorisées
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
|
|||||||
| `--shadow-card` | Ombre portée des cartes |
|
| `--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-hint`) sont dans
|
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`)
|
||||||
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
|
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
|
||||||
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
|
s'appliquent directement à des `<label>`/`<input>`/`<select>` natifs, liés par `formControlName` ou
|
||||||
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
|
par un simple `(change)` : pas de composant `ControlValueAccessor` dédié, le gain n'en vaut pas la
|
||||||
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
|
complexité pour des formulaires aussi simples que ceux de ce projet. `.form-select` habille un
|
||||||
`<ev-alert severity="danger">`, pas une classe dédiée.
|
`<select>` natif avec la bordure et le focus de `.form-input`, plus un chevron. Les erreurs de
|
||||||
|
formulaire, elles, s'affichent via `<ev-alert severity="danger">`, pas une classe dédiée.
|
||||||
|
|
||||||
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
|
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`,
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ décrivent les éléments prévus mais pas encore réalisés.
|
|||||||
|
|
||||||
L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le dataset CSV/JSON et
|
L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le dataset CSV/JSON et
|
||||||
l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir
|
l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir
|
||||||
l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). L'orchestration Airflow, les
|
l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). Les alertes `source='enervision'`,
|
||||||
agrégats continus, la compression et la rétention restent des cibles.
|
elles, sont produites par la détection interne, désormais ordonnancée par le DAG Airflow `alertes`
|
||||||
|
(issue #116). L'orchestration de l'ingestion, les agrégats continus, la compression et la
|
||||||
|
rétention restent des cibles.
|
||||||
|
|
||||||
## Trois emplacements, trois rôles
|
## Trois emplacements, trois rôles
|
||||||
|
|
||||||
@@ -290,10 +292,10 @@ Les anomalies historiques décrites dans les JSON sont conservées dans `dataset
|
|||||||
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
|
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
|
||||||
|
|
||||||
Les lignes de `recommendation` sont écrites par le moteur de règles du backend
|
Les lignes de `recommendation` sont écrites par le moteur de règles du backend
|
||||||
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`
|
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`,
|
||||||
ou par `make recommendations`, à partir des alertes déjà en base. Le couple
|
par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en
|
||||||
`(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes n'ajoute aucune
|
base. Le couple `(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes
|
||||||
ligne.
|
n'ajoute aucune ligne.
|
||||||
|
|
||||||
### Relations entre les tables
|
### Relations entre les tables
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Intégration et livraison continues
|
||||||
|
|
||||||
|
Ce document décrit la chaîne qui s'exécute entre un `git push` et un merge autorisé : ce qui est
|
||||||
|
vérifié, ce qui bloque, et ce qui ne l'est pas.
|
||||||
|
|
||||||
|
| Étage | Sert à | Statut |
|
||||||
|
|---|---|---|
|
||||||
|
| Intégration continue | Interdire le merge d'un code qui casse la qualité, les tests ou la sécurité | `Fait` |
|
||||||
|
| Livraison continue | Porter un artefact vérifié jusqu'à la machine de déploiement | `Cible` |
|
||||||
|
|
||||||
|
Le **D** de CI/CD n'existe pas encore : aucun job de déploiement, aucune construction d'image
|
||||||
|
publiée, aucun environnement GitHub. L'issue #21 le porte. C'est la limite principale de cet
|
||||||
|
étage, et elle est nommée ici plutôt que découverte en soutenance.
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
push["push ou pull_request"]
|
||||||
|
|
||||||
|
subgraph back["Backend · .github/workflows/backend.yml"]
|
||||||
|
bv["verification<br/>ruff, mypy, pytest --cov-fail-under=85"]
|
||||||
|
bi["integration<br/>TimescaleDB réel + alembic upgrade head"]
|
||||||
|
bd["security-audit<br/>uv export | pip-audit"]
|
||||||
|
bs["sast<br/>bandit"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph front["Frontend · frontend.yml"]
|
||||||
|
fb["build<br/>npm ci, npm run build"]
|
||||||
|
ft["test<br/>couverture lcov"]
|
||||||
|
fd["security-audit<br/>npm audit --audit-level=high"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph mlw["ML · ml.yml"]
|
||||||
|
mv["verification<br/>ruff, mypy, pytest"]
|
||||||
|
ms["sast<br/>bandit"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph afw["Airflow · airflow.yml"]
|
||||||
|
av["verification<br/>ruff, intégrité des DAGs"]
|
||||||
|
ab["image<br/>construction de l'image"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph sq["SonarQube · sonarqube.yml"]
|
||||||
|
sb1["build-front / test-front"]
|
||||||
|
sb2["build-back / test-back"]
|
||||||
|
sb3["test-ml"]
|
||||||
|
sscan["sonarqube<br/>quality gate SonarCloud"]
|
||||||
|
end
|
||||||
|
|
||||||
|
push --> bv & bi & bd & bs
|
||||||
|
push --> fb --> ft
|
||||||
|
push --> fd
|
||||||
|
push --> mv & ms
|
||||||
|
push --> av & ab
|
||||||
|
push --> sb1 & sb2 --> sscan
|
||||||
|
sscan -.-> cd["deploy<br/>issue #21"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Déclenchement
|
||||||
|
|
||||||
|
Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** :
|
||||||
|
`backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`,
|
||||||
|
`airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun
|
||||||
|
incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline
|
||||||
|
déclenche le pipeline.
|
||||||
|
|
||||||
|
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
|
||||||
|
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
|
||||||
|
`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux
|
||||||
|
modules : celles du ML pour `ml_train`/`ml_score`, celles du backend depuis que le DAG `alertes`
|
||||||
|
y exécute les commandes de détection ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
|
||||||
|
Une modification de l'un ou l'autre peut donc casser la construction de cette image, et le filtre
|
||||||
|
le voit.
|
||||||
|
|
||||||
|
**Piège à connaître** : il n'y a **aucun filtre de branche**. Une branche de travail déclenche la
|
||||||
|
CI complète à chaque push, et un merge vers n'importe quelle branche la déclenche aussi. C'est
|
||||||
|
délibéré pendant le projet (retour au plus tôt, et la CI tournera sur `main` dès la remontée sans
|
||||||
|
rien changer), mais ce serait à borner sur un dépôt à forte fréquence de push.
|
||||||
|
|
||||||
|
`backend.yml`, `ml.yml` et `airflow.yml` déclarent en plus un groupe de concurrence par référence
|
||||||
|
git avec `cancel-in-progress`, ce qui annule un run devenu obsolète par un push plus récent.
|
||||||
|
|
||||||
|
**Piège de version** : `etl/airflow` tourne en **Python 3.12** et non 3.14, parce qu'Airflow 2.10
|
||||||
|
ne supporte pas encore 3.14. Le 3.14 du module ML ne vit, dans ce contexte, que dans l'image
|
||||||
|
Docker et son propre environnement.
|
||||||
|
|
||||||
|
## Ce qui bloque un merge
|
||||||
|
|
||||||
|
| Gate | Où | Seuil | Effet d'un échec |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Formatage `ruff format --check` | backend, ml | zéro écart | Bloque |
|
||||||
|
| Analyse statique `ruff check` | backend, ml | zéro constat | Bloque |
|
||||||
|
| Typage `mypy` | backend (`app`), ml (strict) | zéro erreur | Bloque |
|
||||||
|
| Tests unitaires `pytest` | backend, ml | **`--cov-fail-under=85`** côté backend | Bloque |
|
||||||
|
| Tests d'intégration | backend | marqueur `integration`, base réelle | Bloque |
|
||||||
|
| Audit de dépendances `pip-audit` | backend | sur le **verrou figé** | Bloque |
|
||||||
|
| Audit de dépendances `npm audit` | frontend | `--audit-level=high` | Bloque |
|
||||||
|
| **SAST `bandit`** | backend (`app`), ml (`enervision_ml`) | **MEDIUM et au-dessus** | Bloque |
|
||||||
|
| Quality gate SonarCloud | tout le dépôt | gate par défaut, couverture du **code neuf** | Bloque |
|
||||||
|
| Build `npm run build` | frontend | compilation | Bloque |
|
||||||
|
| Intégrité des DAGs | airflow | chargement des DAGs sans erreur d'import | Bloque |
|
||||||
|
| Construction de l'image Airflow | airflow | `docker build` de `etl/airflow/Dockerfile` | Bloque |
|
||||||
|
|
||||||
|
Deux seuils portent une décision qu'il faut savoir défendre :
|
||||||
|
|
||||||
|
- **`npm audit --audit-level=high`** et non `moderate` : une vulnérabilité modérée dans une
|
||||||
|
dépendance de développement ne doit pas immobiliser une livraison. Le corollaire est que les
|
||||||
|
`moderate` sont invisibles en CI, et qu'elles se regardent à la main.
|
||||||
|
- **Bandit bloque à partir de MEDIUM**, et une seconde passe sans seuil publie les constats LOW
|
||||||
|
sans bloquer. Sans cette seconde passe, un constat LOW disparaîtrait du journal sans trace. Le
|
||||||
|
revers à connaître : cette seconde étape porte `continue-on-error`, donc le job reste **vert**
|
||||||
|
même quand elle relève quelque chose ; un LOW ne se voit qu'en ouvrant le journal. Au
|
||||||
|
21/09/2026, les deux modules sont à **zéro constat, tous niveaux confondus**, sur 5 904 lignes
|
||||||
|
analysées.
|
||||||
|
- **La version de Bandit est épinglée** (`uvx bandit==1.9.4`) dans les deux jobs. Sans épingle,
|
||||||
|
une nouvelle version passerait la CI au rouge sans qu'une seule ligne du dépôt ait changé, et
|
||||||
|
le rejeu à l'identique promis plus bas n'existerait pas.
|
||||||
|
|
||||||
|
## Le job d'intégration, et pourquoi il ne suffisait pas d'un `postgres`
|
||||||
|
|
||||||
|
`backend.yml` monte un service `timescale/timescaledb-ha:pg17`, **la même image que
|
||||||
|
`docker-compose.yml`**, et non une image `postgres` nue. La première migration s'arrête
|
||||||
|
volontairement si l'extension TimescaleDB manque : un écart d'image entre la CI et le poste
|
||||||
|
rendrait ce job vert sur une base qui n'est pas la nôtre.
|
||||||
|
|
||||||
|
Sur le poste, c'est `db/init/110-test-database.sql` qui pose l'extension. Ce fichier n'est pas
|
||||||
|
monté dans le service GitHub Actions, d'où l'étape `CREATE EXTENSION IF NOT EXISTS timescaledb`
|
||||||
|
avant `alembic upgrade head`.
|
||||||
|
|
||||||
|
La couverture est **désactivée** sur ce job (`pytest -m integration --no-cov`) : il ne joue qu'une
|
||||||
|
partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
|
||||||
|
|
||||||
|
## SonarCloud, et l'incident qui a immobilisé trois PR
|
||||||
|
|
||||||
|
Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`,
|
||||||
|
`build-back`, `test-back`, `test-ml`) dont les tests produisent chacun un rapport de couverture en
|
||||||
|
artefact, puis un dernier job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8`
|
||||||
|
avec le secret `SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine.
|
||||||
|
|
||||||
|
Le périmètre couvre `apps/frontend`, `apps/backend`, `ml/` et `etl/airflow` (les deux derniers
|
||||||
|
ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas
|
||||||
|
Sonar). `ml/` publie `ml/coverage.xml` (`pytest-cov`, même mécanisme que le backend, sans seuil
|
||||||
|
propre : la gate porte sur le code neuf). `etl/airflow` est exclu de la **couverture**
|
||||||
|
(`sonar.coverage.exclusions`) : ses tests ne font que charger les DAGs, ils ne mesurent rien.
|
||||||
|
Piège : tout nouveau dossier de tests doit être déclaré dans `sonar.tests`, faute de quoi il est
|
||||||
|
compté comme code de production non couvert (cf. l'incident ci-dessous).
|
||||||
|
|
||||||
|
**L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont
|
||||||
|
restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que
|
||||||
|
la couverture globale du backend dépassait 87 %. Le diagnostic était **hors du code de ces PR** :
|
||||||
|
`sonar.test.inclusions` ne reconnaissait que les fichiers `test_*.py`, si bien que
|
||||||
|
`tests/api/acces.py`, `tests/factories.py` et les `__init__.py` du dossier de tests étaient
|
||||||
|
comptés comme **code de production non couvert**. Le motif `tests` sans joker ne désignait par
|
||||||
|
ailleurs que la racine.
|
||||||
|
|
||||||
|
Deux commits ont corrigé la configuration (`9e6a5c0` classe tout `apps/backend/tests` comme test,
|
||||||
|
`af2b8cb` déclenche l'analyse quand `sonar-project.properties` change). La gate est verte sur
|
||||||
|
toutes les PR depuis. Ce qui compte pour la suite : **la cause a été traitée en configuration, pas
|
||||||
|
contournée** en désactivant la gate ou en excluant les fichiers gênants.
|
||||||
|
|
||||||
|
## Dependabot
|
||||||
|
|
||||||
|
`.github/dependabot.yml` déclare **six entrées hebdomadaires groupées, sur cinq écosystèmes** :
|
||||||
|
`npm` sur `/apps/frontend`, `uv` sur `/apps/backend`, `github-actions` sur `/`, `docker` sur les
|
||||||
|
deux dossiers d'application, et `docker-compose` sur `/`. Les mises à jour arrivent en PR, donc
|
||||||
|
elles traversent les mêmes gates que n'importe quel changement : une montée de version qui casse
|
||||||
|
les tests ne se merge pas.
|
||||||
|
|
||||||
|
## Stratégie de branche et conventions
|
||||||
|
|
||||||
|
| Règle | Détail |
|
||||||
|
|---|---|
|
||||||
|
| Préfixes de branche | `feat/`, `fix/`, `chore/`, `docs/`, `test/` |
|
||||||
|
| Messages de commit | Conventional Commits |
|
||||||
|
| Branche d'intégration | `dev` ; `main` est la branche par défaut du dépôt public |
|
||||||
|
| Revue | Toute PR passe par une revue écrite avant merge |
|
||||||
|
| ADR | Toute décision structurante porte son ADR dans la même PR |
|
||||||
|
| Vues d'architecture | Toute PR qui change un composant met à jour sa vue **dans la même PR** |
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Un seul secret est consommé par la CI : **`SONAR_TOKEN`**, porté par les dépôts GitHub Actions.
|
||||||
|
Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le
|
||||||
|
workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le
|
||||||
|
run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploiement : le job de
|
||||||
|
déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par
|
||||||
|
l'issue #22.
|
||||||
|
|
||||||
|
## Ce qui manque, et pourquoi
|
||||||
|
|
||||||
|
| Manque | Issue | Conséquence assumée |
|
||||||
|
|---|---|---|
|
||||||
|
| Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine |
|
||||||
|
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
|
||||||
|
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
|
||||||
|
| Tests de charge | #47 | Aucun garde-fou de performance |
|
||||||
|
| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés |
|
||||||
|
|
||||||
|
## Reproduire la CI en local
|
||||||
|
|
||||||
|
`make check` enchaîne formatage, analyse statique, typage et tests du backend, c'est à dire le job
|
||||||
|
`verification`. `make ml-check` fait la même chose pour le module ML. Les tests d'intégration
|
||||||
|
demandent une base : `make db-up` puis `uv run pytest -m integration`.
|
||||||
|
|
||||||
|
Le SAST se rejoue à l'identique : `uvx bandit==1.9.4 --recursive app --severity-level medium
|
||||||
|
--confidence-level medium` depuis `apps/backend`, et la même commande sur `enervision_ml` depuis
|
||||||
|
`ml`.
|
||||||
@@ -15,10 +15,15 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
|
|||||||
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
|
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
|
||||||
| [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur |
|
| [32-design-systeme-frontend.md](32-design-systeme-frontend.md) | Tokens CSS, composants `ev-*` partagés, règle anti-couleur-en-dur |
|
||||||
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
|
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
|
||||||
|
| [50-cicd.md](50-cicd.md) | Workflows, gates bloquantes, SonarCloud, Dependabot, ce qui manque |
|
||||||
|
|
||||||
L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents
|
La CI/CD a désormais son document : cinq workflows et seize jobs, c'est assez de matière pour
|
||||||
ci-dessus, tant que `monitoring/` et `etl/airflow/` ne contiennent que des `.gitkeep`. Elles en
|
qu'une section de plus dans une autre vue devienne illisible. L'observabilité, elle, n'en a
|
||||||
sortiront le jour où elles auront de la matière. Un fichier vide de plus n'aide personne.
|
toujours pas : `monitoring/` ne contient que des `.gitkeep`. Elle en sortira le jour où elle aura
|
||||||
|
de la matière. Un fichier vide de plus n'aide personne.
|
||||||
|
|
||||||
|
L'orchestration Airflow, elle, en a depuis les issues #115 et #116 : trois DAGs, leur image et
|
||||||
|
leurs contraintes sont décrits dans [10-infra.md](10-infra.md).
|
||||||
|
|
||||||
La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans
|
La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans
|
||||||
[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la
|
[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la
|
||||||
|
|||||||
@@ -43,21 +43,25 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
|
|||||||
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
||||||
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
|
| Réponse de l'API Mock bornée avant écriture : timeout, plafond de sites et de mesures, bornes physiques par grandeur, recopie des seuls champs attendus | `app/etl/mock_api_import.py` | API10 Unsafe Consumption of APIs |
|
||||||
| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components |
|
| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components |
|
||||||
|
| Terminaison TLS au frontal, redirection 80 vers 443, HSTS et CSP posés par le proxy, limitation de débit au frontal | `infra/proxy/conf.d/enervision.conf`, ADR 0007 | API8 Security Misconfiguration, A05 |
|
||||||
|
|
||||||
Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des
|
Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des
|
||||||
règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01.
|
règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01.
|
||||||
|
|
||||||
|
Note sur API8 : le transport est couvert, le certificat ne l'est qu'à moitié. Tant qu'aucun nom de
|
||||||
|
domaine public ne résout vers la machine, le défi HTTP-01 de Let's Encrypt ne peut pas aboutir et
|
||||||
|
le certificat servi reste auto-signé. Le chemin ACME est livré et documenté, pas exercé.
|
||||||
|
|
||||||
## Non couvert, et pourquoi
|
## Non couvert, et pourquoi
|
||||||
|
|
||||||
| Item | État | Raison |
|
| Item | État | Raison |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` et `GET /recommendations/{recommendation_id}` répondent à tout compte `lecteur` pour n'importe quel site ou recommandation, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
||||||
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
|
| **API4, lectures de séries temporelles** | **partiel** | `GET /readings` plafonne la fenêtre temporelle (90 jours) et la pagination (`limit` ≤ 2000), voir plus haut. Reste ouvert : pagination en `limit`/`offset` simple plutôt qu'en curseur (un `offset` élevé sur une fenêtre dense reste coûteux), et aucun `statement_timeout` au niveau de la connexion pour borner une requête individuelle si les plafonds au-dessus s'avéraient insuffisants. |
|
||||||
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
|
|
||||||
| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
||||||
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |
|
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |
|
||||||
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
|
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
|
||||||
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. |
|
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. Plus coûteuse depuis Airflow (#115) : ce service publie le port 8080, détient les identifiants Postgres complets (`ML_DATABASE_URL`, mêmes que le backend) et permet de déclencher l'exécution de code depuis son interface. Un compte Airflow compromis atteint donc toute la base, pas seulement `reading`/`site`. Aggravée par #116 : le conteneur reçoit aussi `DATABASE_URL` et exécute le code du backend en sous-processus (ADR 0008). Atténuations en place : le compte admin Airflow est distinct des `app_user` et son mot de passe passe par l'environnement, jamais par `argv` ; et l'`APP_SECRET_KEY` donnée à Airflow est distincte de celle de l'API, pour qu'une compromission ne livre pas la clé de signature des JWT. |
|
||||||
| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. |
|
| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. |
|
||||||
|
|
||||||
## Ce qu'il faut répondre, et ne pas répondre
|
## Ce qu'il faut répondre, et ne pas répondre
|
||||||
|
|||||||
+2
-2
@@ -663,8 +663,8 @@ mock_api_import.py
|
|||||||
|
|
||||||
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
||||||
|
|
||||||
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`), mais il orchestre pour l'instant le pipeline ML (`ml_train`/`ml_score`, issue #115), pas encore ces deux imports : orchestrer `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) reste à faire.
|
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline ML (`ml_train`/`ml_score`, issue #115) ainsi que la détection d'alertes et la génération des recommandations (`alertes`, issue #116). Il n'orchestre pas encore ces deux imports : `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) restent à faire.
|
||||||
|
|
||||||
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py` et `ml_score.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel).
|
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
|
||||||
|
|
||||||
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
+19
-8
@@ -1,8 +1,8 @@
|
|||||||
# Image Airflow EnerVision : ajoute le projet ml/ dans son propre environnement Python 3.14,
|
# Image Airflow EnerVision : ajoute ml/ et apps/backend/ dans leurs propres environnements Python
|
||||||
# distinct du Python 3.12 qui fait tourner Airflow lui-meme, pour que les DAGs puissent lancer
|
# 3.14, distincts du Python 3.12 qui fait tourner Airflow lui-meme (apache-airflow 2.10 ne supporte
|
||||||
# `uv run python -m enervision_ml.train`/`.score` en sous-processus (cf. docs/architecture/
|
# pas 3.14), pour que les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score`,
|
||||||
# 20-backend.md, section Détection d'alertes internes pour le meme raisonnement applique a
|
# `app.detection.internal_alerts` et `app.cli` en sous-processus. Airflow ne devient jamais un
|
||||||
# app/detection). Airflow ne devient jamais un consommateur direct de LightGBM/MLflow.
|
# 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:2.10.4-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
|
||||||
@@ -17,7 +17,8 @@ RUN apt-get update \
|
|||||||
# `ml_train` et `ml_score` (le modele ecrit par l'un, lu par l'autre). Un volume nomme herite des
|
# `ml_train` et `ml_score` (le modele ecrit par l'un, lu par l'autre). Un volume nomme herite des
|
||||||
# permissions du repertoire qu'il recouvre a son premier montage ; sans ce chown prealable, il
|
# permissions du repertoire qu'il recouvre a son premier montage ; sans ce chown prealable, il
|
||||||
# serait cree root:root et illisible par le conteneur, qui tourne en `airflow` (uid 50000).
|
# serait cree root:root et illisible par le conteneur, qui tourne en `airflow` (uid 50000).
|
||||||
RUN mkdir -p /opt/ml/state && chown -R airflow:root /opt/ml
|
# `/opt/backend` ne porte aucun volume, mais `WORKDIR` le creerait root meme sous `USER airflow`.
|
||||||
|
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 trop ancien (0.4.29) pour le format de verrou de
|
||||||
@@ -25,9 +26,10 @@ USER airflow
|
|||||||
# (apps/backend/Dockerfile).
|
# (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`
|
||||||
|
# dans l'un resoudrait le venv de l'autre. Par defaut, uv prend `<projet>/.venv`, donc le bon.
|
||||||
ENV UV_COMPILE_BYTECODE=1 \
|
ENV UV_COMPILE_BYTECODE=1 \
|
||||||
UV_LINK_MODE=copy \
|
UV_LINK_MODE=copy
|
||||||
UV_PROJECT_ENVIRONMENT=/opt/ml/.venv
|
|
||||||
|
|
||||||
WORKDIR /opt/ml
|
WORKDIR /opt/ml
|
||||||
|
|
||||||
@@ -37,4 +39,13 @@ RUN uv sync --locked --no-install-project --no-dev
|
|||||||
COPY --chown=airflow:root ml/enervision_ml ./enervision_ml
|
COPY --chown=airflow:root ml/enervision_ml ./enervision_ml
|
||||||
RUN uv sync --locked --no-dev
|
RUN uv sync --locked --no-dev
|
||||||
|
|
||||||
|
WORKDIR /opt/backend
|
||||||
|
|
||||||
|
# `packages = ["app"]` : le reste de apps/backend (alembic, tests) n'a rien a faire dans l'image.
|
||||||
|
COPY --chown=airflow:root apps/backend/pyproject.toml apps/backend/uv.lock ./
|
||||||
|
RUN uv sync --locked --no-install-project --no-dev
|
||||||
|
|
||||||
|
COPY --chown=airflow:root apps/backend/app ./app
|
||||||
|
RUN uv sync --locked --no-dev
|
||||||
|
|
||||||
WORKDIR /opt/airflow
|
WORKDIR /opt/airflow
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""DAG de détection des alertes et de génération des recommandations (issue #116).
|
||||||
|
|
||||||
|
Ordonnance ce que `docs/architecture/20-backend.md` et l'ADR 0006 décrivent encore comme lancé à
|
||||||
|
la main. Toute la logique reste dans `apps/backend`, ce DAG ne fait que l'appeler, sur le patron
|
||||||
|
de `ml_score` (cf. `docs/architecture/10-infra.md`, section Airflow, et l'ADR 0008 pour
|
||||||
|
l'environnement `/opt/backend` que l'image embarque désormais).
|
||||||
|
|
||||||
|
Planifié à la quinzième minute plutôt qu'à l'heure pile : la règle `anomaly` compare une lecture
|
||||||
|
à la `prediction` du même instant, que `ml_score` (`@hourly`) vient d'écrire. Aucune dépendance
|
||||||
|
déclarée entre les deux DAGs pour autant, quatre règles sur cinq ne touchent pas au modèle et un
|
||||||
|
modèle jamais entraîné ne doit pas priver le parc de ses alertes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from airflow.models.dag import DAG
|
||||||
|
from airflow.operators.bash import BashOperator
|
||||||
|
|
||||||
|
# Le backend a son propre environnement uv dans l'image (ADR 0008). `--no-sync` et
|
||||||
|
# `env -u VIRTUAL_ENV` : cf. `ml_train.py`, même raisonnement.
|
||||||
|
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
||||||
|
|
||||||
|
# Les deux tâches sont idempotentes en base (`ON CONFLICT DO NOTHING` sur
|
||||||
|
# `uq_alert_source_reference` et `uq_recommendation_alert_rule`) : reprendre ne duplique rien.
|
||||||
|
TENTATIVES = 2
|
||||||
|
DELAI_ENTRE_TENTATIVES = timedelta(minutes=2)
|
||||||
|
# `execution_timeout` vaut par tentative : c'est le pire cas des deux tâches enchaînées, reprises
|
||||||
|
# et délais compris, qui doit tenir sous le pas horaire. Les tests d'intégrité en font le calcul.
|
||||||
|
PLAFOND_PAR_TACHE = timedelta(minutes=5)
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
dag_id="alertes",
|
||||||
|
description=(
|
||||||
|
"Détecte les alertes internes puis génère les recommandations "
|
||||||
|
"(app.detection.internal_alerts, app.cli)."
|
||||||
|
),
|
||||||
|
schedule="15 * * * *",
|
||||||
|
start_date=datetime(2026, 1, 1),
|
||||||
|
catchup=False,
|
||||||
|
# Deux exécutions simultanées analyseraient la même fenêtre de 48h, et la génération relit
|
||||||
|
# l'intégralité de la table `alert` à chaque passage.
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=["alertes"],
|
||||||
|
) as dag:
|
||||||
|
detection = BashOperator(
|
||||||
|
task_id="detection",
|
||||||
|
bash_command=f"{COMMANDE_BACKEND} app.detection.internal_alerts",
|
||||||
|
retries=TENTATIVES,
|
||||||
|
retry_delay=DELAI_ENTRE_TENTATIVES,
|
||||||
|
execution_timeout=PLAFOND_PAR_TACHE,
|
||||||
|
)
|
||||||
|
|
||||||
|
recommandations = BashOperator(
|
||||||
|
task_id="recommandations",
|
||||||
|
bash_command=f"{COMMANDE_BACKEND} app.cli generate-recommendations",
|
||||||
|
retries=TENTATIVES,
|
||||||
|
retry_delay=DELAI_ENTRE_TENTATIVES,
|
||||||
|
execution_timeout=PLAFOND_PAR_TACHE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# `recommendation.alert_id` est une clé étrangère `NOT NULL` : la génération n'a rien à lire
|
||||||
|
# tant que la détection n'a pas écrit.
|
||||||
|
detection >> recommandations
|
||||||
@@ -8,7 +8,7 @@ ce DAG ne reentraine jamais rien. Si aucun modele n'a encore ete entraine, la ta
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from airflow.models.dag import DAG
|
from airflow.models.dag import DAG
|
||||||
from airflow.operators.bash import BashOperator
|
from airflow.operators.bash import BashOperator
|
||||||
@@ -21,13 +21,21 @@ with DAG(
|
|||||||
schedule="@hourly",
|
schedule="@hourly",
|
||||||
start_date=datetime(2026, 1, 1),
|
start_date=datetime(2026, 1, 1),
|
||||||
catchup=False,
|
catchup=False,
|
||||||
|
# Deux scorings qui se chevauchent inseraient en meme temps dans `prediction` (pas de contrainte
|
||||||
|
# d'unicite sur `(site_id, target_at)`, chaque run garde sa ligne).
|
||||||
|
max_active_runs=1,
|
||||||
tags=["ml"],
|
tags=["ml"],
|
||||||
) as dag:
|
) as dag:
|
||||||
# `--frozen --no-dev` : cf. `ml_train.py`, meme raisonnement.
|
# `--no-sync`, `env -u VIRTUAL_ENV` : cf. `ml_train.py`, meme raisonnement.
|
||||||
BashOperator(
|
BashOperator(
|
||||||
task_id="score",
|
task_id="score",
|
||||||
bash_command=(
|
bash_command=(
|
||||||
"cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.score "
|
"cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.score "
|
||||||
f"--model {MODEL_PATH}"
|
f"--model {MODEL_PATH}"
|
||||||
),
|
),
|
||||||
|
# Un incident transitoire sur Postgres ne doit pas faire perdre le creneau horaire.
|
||||||
|
retries=2,
|
||||||
|
retry_delay=timedelta(minutes=2),
|
||||||
|
# Bien en dessous du pas horaire : un scoring pendu ne doit pas empieter sur le suivant.
|
||||||
|
execution_timeout=timedelta(minutes=30),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"""DAG d'entrainement du modele LightGBM (issue #115).
|
"""DAG d'entrainement du modele LightGBM (issue #115).
|
||||||
|
|
||||||
Pas de planification : reentrainer est couteux et sa cadence n'est pas une decision prise
|
Pas de planification : reentrainer est couteux et sa cadence n'est pas une decision prise, en
|
||||||
(cf. `docs/architecture/20-backend.md`). Declenchement manuel depuis l'UI ou la CLI Airflow en
|
particulier tant que `train.py` ecrase le modele sans comparer ses metriques a l'ancien (cf.
|
||||||
attendant. `ml_score` (DAG separe, planifie toutes les heures) reutilise le modele que ce DAG
|
`docs/architecture/10-infra.md`, section Airflow). Declenchement manuel depuis l'UI ou la CLI
|
||||||
ecrit, il ne reentraine jamais rien lui-meme.
|
Airflow en attendant. `ml_score` (DAG separe, planifie toutes les heures) reutilise le modele que
|
||||||
|
ce DAG ecrit, il ne reentraine jamais rien lui-meme.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from airflow.models.dag import DAG
|
from airflow.models.dag import DAG
|
||||||
from airflow.operators.bash import BashOperator
|
from airflow.operators.bash import BashOperator
|
||||||
@@ -22,15 +23,21 @@ with DAG(
|
|||||||
schedule=None,
|
schedule=None,
|
||||||
start_date=datetime(2026, 1, 1),
|
start_date=datetime(2026, 1, 1),
|
||||||
catchup=False,
|
catchup=False,
|
||||||
|
# Deux entrainements simultanes ecriraient le meme fichier modele.
|
||||||
|
max_active_runs=1,
|
||||||
tags=["ml"],
|
tags=["ml"],
|
||||||
) as dag:
|
) as dag:
|
||||||
# `--frozen --no-dev` : l'environnement `/opt/ml/.venv` est fige a la construction de l'image
|
# `--no-sync` : l'environnement `/opt/ml/.venv` est fige a la construction de l'image, `uv run`
|
||||||
# (groupe `dev` exclu). Sans `--no-dev` ici, `uv run` resynchronise ruff/mypy a chaque
|
# ne le resynchronise pas (sinon `enervision-ml` est reconstruit a chaque tache).
|
||||||
# execution : un acces reseau evitable, sur le chemin d'execution d'une tache planifiee.
|
# `env -u VIRTUAL_ENV` : l'image de base positionne celui d'Airflow, que `uv` signale a chaque
|
||||||
|
# execution sans qu'il change quoi que ce soit.
|
||||||
BashOperator(
|
BashOperator(
|
||||||
task_id="train",
|
task_id="train",
|
||||||
bash_command=(
|
bash_command=(
|
||||||
"cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.train "
|
"cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train "
|
||||||
f"--model-output {MODEL_PATH} --mlflow-tracking-uri {MLFLOW_TRACKING_URI}"
|
f"--model-output {MODEL_PATH} --mlflow-tracking-uri {MLFLOW_TRACKING_URI}"
|
||||||
),
|
),
|
||||||
|
# Un entrainement complet dure quelques minutes ; une connexion pendue ne doit pas
|
||||||
|
# immobiliser un slot du scheduler indefiniment.
|
||||||
|
execution_timeout=timedelta(hours=1),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
"""Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution
|
"""Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution
|
||||||
reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition."""
|
reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from airflow.models.baseoperator import BaseOperator
|
||||||
from airflow.models.dagbag import DagBag
|
from airflow.models.dagbag import DagBag
|
||||||
|
|
||||||
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
|
DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags"
|
||||||
|
|
||||||
|
DAG_IDS = ["ml_train", "ml_score", "alertes"]
|
||||||
|
TACHES = [
|
||||||
|
("ml_train", "train"),
|
||||||
|
("ml_score", "score"),
|
||||||
|
("alertes", "detection"),
|
||||||
|
("alertes", "recommandations"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def dagbag() -> DagBag:
|
def dagbag() -> DagBag:
|
||||||
@@ -19,20 +29,24 @@ def test_dags_folder_has_no_import_error(dagbag: DagBag) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None:
|
def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None:
|
||||||
assert set(dagbag.dag_ids) == {"ml_train", "ml_score"}
|
assert set(dagbag.dag_ids) == set(DAG_IDS)
|
||||||
|
|
||||||
|
|
||||||
def test_ml_train_has_no_schedule() -> None:
|
def test_ml_train_has_no_schedule(dagbag: DagBag) -> None:
|
||||||
dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
|
||||||
assert dagbag.dags["ml_train"].timetable.summary == "None"
|
assert dagbag.dags["ml_train"].timetable.summary == "None"
|
||||||
|
|
||||||
|
|
||||||
def test_ml_score_runs_every_hour() -> 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 `.summary` le rend.
|
||||||
dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
|
||||||
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *"
|
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *"
|
||||||
|
|
||||||
|
|
||||||
|
def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None:
|
||||||
|
# Le decalage n'est pas cosmetique : la regle `anomaly` compare une lecture a la `prediction`
|
||||||
|
# du meme instant, que `ml_score` ecrit a l'heure pile.
|
||||||
|
assert dagbag.dags["alertes"].timetable.summary == "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:
|
||||||
tache = dagbag.dags["ml_train"].get_task("train")
|
tache = dagbag.dags["ml_train"].get_task("train")
|
||||||
assert "enervision_ml.train" in tache.bash_command
|
assert "enervision_ml.train" in tache.bash_command
|
||||||
@@ -43,6 +57,28 @@ def test_ml_score_task_calls_the_scoring_module(dagbag: DagBag) -> None:
|
|||||||
assert "enervision_ml.score" in tache.bash_command
|
assert "enervision_ml.score" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_alertes_detection_task_calls_the_backend_detection(dagbag: DagBag) -> None:
|
||||||
|
tache = dagbag.dags["alertes"].get_task("detection")
|
||||||
|
assert "app.detection.internal_alerts" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_alertes_recommendation_task_calls_the_backend_cli(dagbag: DagBag) -> None:
|
||||||
|
tache = dagbag.dags["alertes"].get_task("recommandations")
|
||||||
|
assert "app.cli generate-recommendations" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
||||||
|
def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None:
|
||||||
|
# Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008).
|
||||||
|
assert "/opt/backend" in dagbag.dags["alertes"].get_task(task_id).bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None:
|
||||||
|
# `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire
|
||||||
|
# tant que la detection n'a pas ecrit.
|
||||||
|
assert dagbag.dags["alertes"].get_task("detection").downstream_task_ids == {"recommandations"}
|
||||||
|
|
||||||
|
|
||||||
def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> None:
|
def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> None:
|
||||||
entrainement = dagbag.dags["ml_train"].get_task("train").bash_command
|
entrainement = dagbag.dags["ml_train"].get_task("train").bash_command
|
||||||
scoring = dagbag.dags["ml_score"].get_task("score").bash_command
|
scoring = dagbag.dags["ml_score"].get_task("score").bash_command
|
||||||
@@ -50,3 +86,56 @@ def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> N
|
|||||||
|
|
||||||
assert chemin_modele in entrainement
|
assert chemin_modele in entrainement
|
||||||
assert chemin_modele in scoring
|
assert chemin_modele in scoring
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("dag_id", DAG_IDS)
|
||||||
|
def test_no_two_runs_of_a_dag_overlap(dagbag: DagBag, dag_id: str) -> None:
|
||||||
|
# Deux entrainements ecriraient le meme fichier modele, deux scorings inseriraient en meme
|
||||||
|
# temps dans `prediction`, deux detections analyseraient la meme fenetre.
|
||||||
|
assert dagbag.dags[dag_id].max_active_runs == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
|
||||||
|
def test_every_task_has_an_execution_timeout(dagbag: DagBag, dag_id: str, task_id: str) -> None:
|
||||||
|
# Sans plafond, une connexion pendue immobilise un slot du scheduler indefiniment.
|
||||||
|
assert dagbag.dags[dag_id].get_task(task_id).execution_timeout is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_score_execution_timeout_stays_below_its_hourly_step(dagbag: DagBag) -> None:
|
||||||
|
timeout = dagbag.dags["ml_score"].get_task("score").execution_timeout
|
||||||
|
assert timeout is not None
|
||||||
|
assert timeout < timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
def duree_au_pire(tache: BaseOperator) -> timedelta:
|
||||||
|
# `execution_timeout` plafonne une tentative, pas la tache : deux reprises occupent trois
|
||||||
|
# plafonds et deux delais d'attente.
|
||||||
|
assert tache.execution_timeout is not None
|
||||||
|
return (tache.retries + 1) * tache.execution_timeout + tache.retries * tache.retry_delay
|
||||||
|
|
||||||
|
|
||||||
|
def test_alertes_worst_case_stays_below_its_hourly_step(dagbag: DagBag) -> None:
|
||||||
|
# Les deux taches s'enchainent : c'est leur somme, reprises comprises, qui doit tenir dans le
|
||||||
|
# pas horaire, sinon `max_active_runs=1` fait attendre l'execution suivante.
|
||||||
|
taches = [
|
||||||
|
dagbag.dags["alertes"].get_task(task_id) for task_id in ("detection", "recommandations")
|
||||||
|
]
|
||||||
|
assert sum((duree_au_pire(tache) for tache in taches), timedelta()) < timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ml_score_retries_after_a_transient_failure(dagbag: DagBag) -> None:
|
||||||
|
assert dagbag.dags["ml_score"].get_task("score").retries >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
||||||
|
def test_alertes_retries_after_a_transient_failure(dagbag: DagBag, task_id: str) -> None:
|
||||||
|
# Les deux commandes sont idempotentes en base, une reprise ne duplique rien.
|
||||||
|
assert dagbag.dags["alertes"].get_task(task_id).retries >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
|
||||||
|
def test_tasks_never_resync_the_baked_environment(
|
||||||
|
dagbag: DagBag, dag_id: str, task_id: str
|
||||||
|
) -> None:
|
||||||
|
# Sans `--no-sync`, `uv run` reconstruit le projet a chaque execution.
|
||||||
|
assert "--no-sync" in dagbag.dags[dag_id].get_task(task_id).bash_command
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Reverse proxy
|
||||||
|
|
||||||
|
Terminaison TLS et routage de la stack déployée. Seul composant publié sur le réseau : il
|
||||||
|
écoute en 80 et 443, et rien d'autre ne sort du réseau Compose.
|
||||||
|
|
||||||
|
- `nginx.conf` : bloc `http`, journalisation, compression, zones de limitation de débit.
|
||||||
|
- `conf.d/enervision.conf` : redirection 80 vers 443, terminaison TLS, en-têtes de sécurité,
|
||||||
|
routage.
|
||||||
|
- `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git.
|
||||||
|
- `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`.
|
||||||
|
|
||||||
|
Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la
|
||||||
|
configuration est montée en volume par `docker-compose.prod.yml`.
|
||||||
|
|
||||||
|
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
|
||||||
|
ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
|
||||||
|
|
||||||
|
## Routage
|
||||||
|
|
||||||
|
| Chemin | Destination | Remarque |
|
||||||
|
|---|---|---|
|
||||||
|
| `/.well-known/acme-challenge/` | `/var/www/certbot` sur le port 80 | Seul chemin non redirigé vers HTTPS |
|
||||||
|
| `/api/v1/auth/` + `login`, `password`, `forgot-password`, `reset-password` | `backend:8000` | Zone resserrée, 30 requêtes par minute |
|
||||||
|
| `/api/` | `backend:8000` | Préfixe `/api/v1` préservé tel quel, 20 requêtes par seconde |
|
||||||
|
| `/` | `frontend:3000` | Le SPA, qui renvoie `index.html` sur les routes inconnues |
|
||||||
|
|
||||||
|
La zone resserrée ne couvre que les routes qui vérifient un secret. `/auth/me` et `/auth/refresh`
|
||||||
|
partent à chaque chargement de page et restent dans la zone générale : derrière un NAT, où une
|
||||||
|
seule adresse porte tous les postes, les y soumettre aurait produit des 429 en usage normal.
|
||||||
|
|
||||||
|
L'interface Airflow, celle de Mailpit et la base ne passent pas par le proxy : l'overlay les
|
||||||
|
ramène sur `127.0.0.1`, donc joignables par tunnel SSH et pas autrement. Les publier derrière le
|
||||||
|
proxy demanderait une authentification propre, qui n'est pas la leur.
|
||||||
|
|
||||||
|
`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**,
|
||||||
|
pas sous `/api`. Ils tombent donc dans `location /`, donc sur le SPA : ils ne sont pas joignables
|
||||||
|
depuis l'extérieur, sans qu'aucune règle de blocage ait à être écrite. Y toucher, c'est les
|
||||||
|
exposer.
|
||||||
|
|
||||||
|
## Certificat : deux modes, un seul emplacement
|
||||||
|
|
||||||
|
nginx lit toujours `tls/fullchain.pem` et `tls/privkey.pem`. Seule leur fabrication change, la
|
||||||
|
configuration n'a jamais à bouger.
|
||||||
|
|
||||||
|
### Démonstration, certificat auto-signé
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make tls-selfsigned PUBLIC_HOST=enervision.local
|
||||||
|
make stack-up
|
||||||
|
```
|
||||||
|
|
||||||
|
Le navigateur avertira d'un émetteur inconnu : c'est attendu, et c'est le seul mode exploitable
|
||||||
|
tant que la machine cible n'a pas de nom de domaine public.
|
||||||
|
|
||||||
|
### Let's Encrypt
|
||||||
|
|
||||||
|
Le défi HTTP-01 exige un nom de domaine **résolvable publiquement** et le port 80 joignable
|
||||||
|
depuis Internet. La cible documentée aujourd'hui (`ssh_host = "10.0.0.10"`, serveur de l'école)
|
||||||
|
ne remplit ni l'une ni l'autre condition : le chemin ci-dessous est livré et documenté, il n'a
|
||||||
|
pas été exercé.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make stack-up # nginx doit tourner pour servir le défi
|
||||||
|
make tls-acme PUBLIC_HOST=enervision.fr ACME_EMAIL=ops@enervision.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
Renouvellement, à passer en tâche planifiée sur la machine :
|
||||||
|
|
||||||
|
```cron
|
||||||
|
17 3 * * * cd /srv/enervision && make tls-renew >> /var/log/enervision-tls.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour un domaine sans port 80 entrant, le défi DNS-01 est l'alternative : elle demande un
|
||||||
|
greffon certbot propre au fournisseur DNS et un jeton d'API, hors périmètre à ce jour.
|
||||||
|
|
||||||
|
## Vérifier la configuration sans démarrer la stack
|
||||||
|
|
||||||
|
`nginx -t` charge les certificats : `tls/` doit être rempli, par `make tls-selfsigned` au besoin.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \
|
||||||
|
-v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \
|
||||||
|
-v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \
|
||||||
|
nginx:1.28-alpine nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image.
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Contrainte : certbot écrit dans /etc/letsencrypt/live/<domaine>/, nginx lit /etc/nginx/tls/.
|
||||||
|
# Ce hook recopie le résultat à l'emplacement unique que la configuration nginx connaît, ce
|
||||||
|
# qui rend le mode auto-signé et le mode ACME interchangeables sans toucher à un vhost.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cp -L "$RENEWED_LINEAGE/fullchain.pem" /tls/fullchain.pem
|
||||||
|
cp -L "$RENEWED_LINEAGE/privkey.pem" /tls/privkey.pem
|
||||||
|
chmod 644 /tls/fullchain.pem
|
||||||
|
chmod 600 /tls/privkey.pem
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Piège : `X-Forwarded-For` se construit avec `$proxy_add_x_forwarded_for`, qui ajoute l'IP
|
||||||
|
# réelle en fin de chaîne. `get_client_ip()` (apps/backend/app/api/deps.py) ne lit que le
|
||||||
|
# dernier élément : toute autre forme rend la limitation de débit par IP globale, donc le
|
||||||
|
# déni de service auto-infligé que ce code cherche précisément à éviter.
|
||||||
|
# Piège : un nom d'hôte littéral dans `proxy_pass` fige l'IP du conteneur au démarrage de
|
||||||
|
# nginx, et recréer `backend` seul donnerait des 502 jusqu'au rechargement du proxy. D'où la
|
||||||
|
# variable et le résolveur interne de Docker : la résolution redevient dynamique.
|
||||||
|
# Pourquoi : la redirection 80 vers 443 conserve `$host` plutôt qu'un nom canonique, faute de
|
||||||
|
# quoi l'accès par IP cesserait de fonctionner sur la cible. Risque acté dans l'ADR 0007.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/tls/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/tls/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
# L'application refuse délibérément de poser ces deux en-têtes, verrouillé par
|
||||||
|
# tests/api/test_hardening.py. Ils appartiennent au terminateur TLS, c'est-à-dire ici.
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
|
||||||
|
# Piège : la zone `auth` ne couvre que les routes qui vérifient un secret. Derrière le NAT de
|
||||||
|
# l'école, `/auth/me` et `/auth/refresh` y produiraient des 429 à chaque chargement de page.
|
||||||
|
location ~ ^/api/v1/auth/(login|password|forgot-password|reset-password)$ {
|
||||||
|
limit_req zone=auth burst=20 nodelay;
|
||||||
|
set $cible_api http://backend:8000;
|
||||||
|
proxy_pass $cible_api$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=40 nodelay;
|
||||||
|
set $cible_api http://backend:8000;
|
||||||
|
proxy_pass $cible_api$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
set $cible_web http://frontend:3000;
|
||||||
|
proxy_pass $cible_web$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Contrainte : les directives `limit_req_zone` ne sont valides que dans le bloc `http`.
|
||||||
|
# Les `location` de conf.d/enervision.conf s'y réfèrent par nom, `api` et `auth`.
|
||||||
|
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
log_format enervision '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent $request_time '
|
||||||
|
'"$http_referer" "$http_user_agent"';
|
||||||
|
access_log /var/log/nginx/access.log enervision;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
client_max_body_size 2m;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types application/javascript application/json application/xml
|
||||||
|
image/svg+xml text/css text/plain;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=auth:10m rate=30r/m;
|
||||||
|
limit_req_status 429;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Pipeline d'entrainement du modele de prevision de consommation energetique. Contexte complet :
|
Pipeline d'entrainement du modele de prevision de consommation energetique. Contexte complet :
|
||||||
[ADR 0005](../docs/adr/0005-modele-prediction-lightgbm.md) (choix du modele) et
|
[ADR 0005](../docs/adr/0005-modele-prediction-lightgbm.md) (choix du modele) et
|
||||||
[ML-START.md](../ML-START.md) (mecanisme d'acces aux donnees).
|
[ML-START.md](../docs/ML-START.md) (mecanisme d'acces aux donnees).
|
||||||
|
|
||||||
| Element | Choix |
|
| Element | Choix |
|
||||||
|--------------|-----------------------------------------------|
|
|--------------|-----------------------------------------------|
|
||||||
@@ -103,7 +103,7 @@ prevision (utile plus tard pour comparer prevision et realise, surveillance de d
|
|||||||
uv run ruff check . # lint
|
uv run ruff check . # lint
|
||||||
uv run ruff format . # format
|
uv run ruff format . # format
|
||||||
uv run mypy enervision_ml tests # typage strict
|
uv run mypy enervision_ml tests # typage strict
|
||||||
uv run pytest # tests
|
uv run pytest # tests + couverture (ml/coverage.xml avec --cov-report=xml, lu par Sonar)
|
||||||
```
|
```
|
||||||
|
|
||||||
Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`,
|
Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`,
|
||||||
|
|||||||
+11
-1
@@ -17,6 +17,7 @@ dev = [
|
|||||||
"ruff>=0.16.7",
|
"ruff>=0.16.7",
|
||||||
"mypy>=2.3.1",
|
"mypy>=2.3.1",
|
||||||
"pytest>=9.1.1",
|
"pytest>=9.1.1",
|
||||||
|
"pytest-cov>=7.1.0",
|
||||||
"pandas-stubs>=3.0.5.260914",
|
"pandas-stubs>=3.0.5.260914",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -75,5 +76,14 @@ ignore_missing_imports = true
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "-q --strict-markers -m 'not integration'"
|
addopts = "-q --strict-markers -m 'not integration' --cov=enervision_ml --cov-report=term-missing"
|
||||||
markers = ["integration: requiert une base PostgreSQL joignable"]
|
markers = ["integration: requiert une base PostgreSQL joignable"]
|
||||||
|
|
||||||
|
# Rapport lu par SonarCloud (`ml/coverage.xml`, cf. sonar-project.properties), meme mecanisme que
|
||||||
|
# apps/backend. Pas de seuil ici : celui de la quality gate porte sur le code nouveau.
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["enervision_ml"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
show_missing = true
|
||||||
|
|||||||
Generated
+55
@@ -388,6 +388,45 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.16.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "50.0.1"
|
version = "50.0.1"
|
||||||
@@ -494,6 +533,7 @@ dev = [
|
|||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "pandas-stubs" },
|
{ name = "pandas-stubs" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -512,6 +552,7 @@ dev = [
|
|||||||
{ name = "mypy", specifier = ">=2.3.1" },
|
{ name = "mypy", specifier = ">=2.3.1" },
|
||||||
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
|
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
|
||||||
{ name = "pytest", specifier = ">=9.1.1" },
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
{ name = "ruff", specifier = ">=0.16.7" },
|
{ name = "ruff", specifier = ">=0.16.7" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1591,6 +1632,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
version = "2.9.0.post0"
|
||||||
|
|||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Contrainte : nginx lit toujours infra/proxy/tls/{fullchain,privkey}.pem, quel que soit le
|
||||||
|
# mode d'obtention. Ce script remplit ces deux fichiers pour la démonstration, certbot les
|
||||||
|
# remplit par acme-deploy-hook.sh. La configuration nginx ne connaît pas la différence.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RACINE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
DESTINATION="$RACINE/infra/proxy/tls"
|
||||||
|
HOTE="${PUBLIC_HOST:-enervision.local}"
|
||||||
|
ADRESSE="${PUBLIC_IP:-}"
|
||||||
|
JOURS="${TLS_DAYS:-365}"
|
||||||
|
ECRASER=0
|
||||||
|
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$argument" in
|
||||||
|
--force) ECRASER=1 ;;
|
||||||
|
*)
|
||||||
|
echo "Usage : PUBLIC_HOST=exemple.local [PUBLIC_IP=10.0.0.10] $0 [--force]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -f "$DESTINATION/fullchain.pem" && $ECRASER -eq 0 ]]; then
|
||||||
|
echo "Un certificat existe déjà dans $DESTINATION." >&2
|
||||||
|
echo "Relancer avec --force pour l'écraser." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DESTINATION"
|
||||||
|
|
||||||
|
NOMS="DNS:$HOTE,DNS:localhost"
|
||||||
|
if [[ -n "$ADRESSE" ]]; then
|
||||||
|
NOMS="$NOMS,IP:$ADRESSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
openssl req -x509 -nodes -newkey rsa:2048 -sha256 -days "$JOURS" \
|
||||||
|
-subj "/CN=$HOTE" \
|
||||||
|
-addext "subjectAltName=$NOMS" \
|
||||||
|
-keyout "$DESTINATION/privkey.pem" \
|
||||||
|
-out "$DESTINATION/fullchain.pem" 2>/dev/null
|
||||||
|
|
||||||
|
chmod 600 "$DESTINATION/privkey.pem"
|
||||||
|
chmod 644 "$DESTINATION/fullchain.pem"
|
||||||
|
|
||||||
|
echo "Certificat auto-signé écrit dans $DESTINATION."
|
||||||
|
echo " Noms couverts : $NOMS"
|
||||||
|
echo " Validité : $JOURS jours"
|
||||||
|
echo "Le navigateur avertira d'un émetteur inconnu, c'est attendu hors Let's Encrypt."
|
||||||
@@ -3,15 +3,17 @@ sonar.organization=groupe3-ener-vision
|
|||||||
sonar.sourceEncoding=UTF-8
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
# Dossier contenant le code source
|
# Dossier contenant le code source
|
||||||
sonar.sources=apps/frontend/src,apps/backend
|
sonar.sources=apps/frontend/src,apps/backend,ml,etl/airflow
|
||||||
# Dossier contenant les tests
|
# Dossier contenant les tests
|
||||||
sonar.tests=apps/frontend/src,apps/backend/tests
|
sonar.tests=apps/frontend/src,apps/backend/tests,ml/tests,etl/airflow/tests
|
||||||
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
||||||
|
|
||||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
# Liste des fichiers et dossiers à exclure de l'analyse
|
||||||
sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
|
sonar.exclusions=.pytest_cache,.venv,.airflow_home,alembic,tests,ml/data/**,ml/models/**,ml/mlruns/**,ml/mlartifacts/**,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
|
||||||
|
|
||||||
# Chemin vers le rapport de couverture de code
|
# Chemin vers le rapport de couverture de code
|
||||||
# Fichier généré par Pytest
|
# Fichier généré par Pytest
|
||||||
sonar.python.coverage.reportPaths=apps/backend/coverage.xml
|
sonar.python.coverage.reportPaths=apps/backend/coverage.xml,ml/coverage.xml
|
||||||
|
# Les DAGs n'ont pas de couverture mesurable : leurs tests ne font que les charger (DagBag)
|
||||||
|
sonar.coverage.exclusions=etl/airflow/**
|
||||||
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
||||||
|
|||||||
Reference in New Issue
Block a user