Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf6476fb6 | ||
|
|
d2ca2644d2 | ||
|
|
91a1bfda33 | ||
|
|
fe100653d0 | ||
|
|
bccf7ed774 | ||
|
|
bf2b66e4ad | ||
|
|
8ea388ce9a | ||
|
|
b4da0bbefc | ||
|
|
25e0bb4ce1 | ||
|
|
ce3d1d1992 | ||
|
|
cb23895026 | ||
|
|
2c86e00629 | ||
|
|
7e504bee72 | ||
|
|
32155cbfc5 | ||
|
|
cb4df846cb | ||
|
|
d16de84559 | ||
|
|
ae4082c584 | ||
|
|
f18d4f9ef9 | ||
|
|
308769b325 | ||
|
|
90639b5618 | ||
|
|
e147ea69bc | ||
|
|
f0ff953a5e | ||
|
|
7fd8d1ce30 | ||
|
|
6785d06a9a | ||
|
|
c2bd1317ed | ||
|
|
d276070ee2 | ||
|
|
e10ab44dc7 | ||
|
|
32dd0587e9 | ||
|
|
83caa9006e | ||
|
|
91752a2b0b | ||
|
|
3c378c177f | ||
|
|
2adfdf0eb0 | ||
|
|
44f3416ffe | ||
|
|
342128ccff | ||
|
|
62d81e901d | ||
|
|
31886944ab | ||
|
|
63cbeafe3b | ||
|
|
be44b97d7a | ||
|
|
d7457e9fd9 | ||
|
|
5545c166fd | ||
|
|
f0ad8e9990 | ||
|
|
3c01ab3ecc | ||
|
|
c3fd9327ea |
@@ -53,3 +53,21 @@ AIRFLOW_APP_SECRET_KEY=change_me
|
|||||||
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
|
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
|
||||||
PUBLIC_HOST=enervision.local
|
PUBLIC_HOST=enervision.local
|
||||||
ACME_EMAIL=
|
ACME_EMAIL=
|
||||||
|
|
||||||
|
# Deux environnements sur la même machine (ADR 0009) : un dossier, un `.env` et un projet Compose
|
||||||
|
# chacun. Le nom de projet préfixe volumes, réseau et conteneurs et l'emporte sur `name:`.
|
||||||
|
# Vide sur un poste de développement : le projet reste `enervision`.
|
||||||
|
COMPOSE_PROJECT_NAME=
|
||||||
|
# Origine publique, avec le port si le proxy HTTPS n'écoute pas 443. Vide : https://PUBLIC_HOST.
|
||||||
|
# Recette : PUBLIC_HOST=rec.enervision.local et PUBLIC_ORIGIN=https://rec.enervision.local:8443.
|
||||||
|
PUBLIC_ORIGIN=
|
||||||
|
# Ports publiés par le proxy. Vides : 80 et 443. Recette : PROXY_HTTPS_PORT=8443 et
|
||||||
|
# PROXY_HTTP_PORT=127.0.0.1:8081, la redirection vers 443 n'ayant pas à être joignable de
|
||||||
|
# l'extérieur. Décaler aussi POSTGRES_PORT, MAILPIT_UI_PORT et AIRFLOW_PORT (5434, 8026, 8082).
|
||||||
|
PROXY_HTTP_PORT=
|
||||||
|
PROXY_HTTPS_PORT=
|
||||||
|
# Réglages mémoire de la stack déployée. Sans eux, timescaledb-tune réserve 25 % de la RAM de la
|
||||||
|
# machine à chaque base au premier démarrage, et le webserver Airflow lance 4 workers gunicorn.
|
||||||
|
TS_TUNE_MEMORY=2GB
|
||||||
|
TS_TUNE_NUM_CPUS=2
|
||||||
|
AIRFLOW_WEBSERVER_WORKERS=2
|
||||||
|
|||||||
@@ -91,11 +91,12 @@ jobs:
|
|||||||
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
|
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
|
||||||
|
|
||||||
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
|
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
|
||||||
# l'import du module prouve que l'environnement /opt/backend est complet. Les deux
|
# l'import des modules prouve que l'environnement /opt/backend est complet.
|
||||||
# commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui.
|
# Les deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes.
|
||||||
- name: Vérifie que les deux commandes du DAG alertes s'importent sans réseau
|
- name: Vérifie que les trois commandes backend s'importent sans réseau
|
||||||
run: >
|
run: >
|
||||||
docker run --rm --network none enervision-airflow:ci
|
docker run --rm --network none enervision-airflow:ci
|
||||||
bash -c "cd /opt/backend
|
bash -c "cd /opt/backend
|
||||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
|
||||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help"
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
|
||||||
|
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Pourquoi : le runner tourne sur la VM ENI, adresse privée que les runners hébergés par GitHub
|
||||||
|
# ne joignent pas, et travaille dans un dossier stable par environnement plutôt que dans son
|
||||||
|
# espace de travail : `.env`, certificats et volumes y survivent d'un déploiement à l'autre.
|
||||||
|
# Piège : jamais de déclencheur `pull_request` ici. Sur un dépôt public, une PR de fork
|
||||||
|
# exécuterait son code sur la machine de production (ADR 0009) - job deploy.
|
||||||
|
|
||||||
|
name: Déploiement
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: [self-hosted, linux, eni-g3]
|
||||||
|
timeout-minutes: 30
|
||||||
|
environment:
|
||||||
|
name: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
|
||||||
|
url: ${{ github.ref_name == 'main' && 'https://enervision.local' || 'https://rec.enervision.local:8443' }}
|
||||||
|
env:
|
||||||
|
ENVIRONNEMENT: ${{ github.ref_name == 'main' && 'prod' || 'rec' }}
|
||||||
|
PORT_HTTPS: ${{ github.ref_name == 'main' && '443' || '8443' }}
|
||||||
|
steps:
|
||||||
|
- name: Aligner le dossier de l'environnement sur la branche poussée
|
||||||
|
run: |
|
||||||
|
cd "/srv/enervision/${ENVIRONNEMENT}"
|
||||||
|
git fetch --quiet origin "${GITHUB_REF_NAME}"
|
||||||
|
git checkout --quiet "${GITHUB_REF_NAME}"
|
||||||
|
git reset --quiet --hard "origin/${GITHUB_REF_NAME}"
|
||||||
|
git log -1 --format='%h %s'
|
||||||
|
|
||||||
|
- name: Reconstruire et redémarrer la stack
|
||||||
|
run: |
|
||||||
|
cd "/srv/enervision/${ENVIRONNEMENT}"
|
||||||
|
make stack-up
|
||||||
|
|
||||||
|
- name: Attendre que l'API réponde derrière le proxy
|
||||||
|
run: |
|
||||||
|
for tentative in $(seq 1 36); do
|
||||||
|
if curl --fail --silent --insecure "https://localhost:${PORT_HTTPS}/api/v1/health/ready"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "L'API ne répond pas après 3 minutes" >&2
|
||||||
|
cd "/srv/enervision/${ENVIRONNEMENT}"
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=50 backend proxy
|
||||||
|
exit 1
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -13,11 +13,28 @@ ifdef ACME_EMAIL
|
|||||||
export ACME_EMAIL
|
export ACME_EMAIL
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
# Piege : make ne lit pas `.env`, que seul docker compose interpole. Les cibles hors conteneur
|
||||||
|
# (ml-*, demo-data, db-wait) joignent la base par le port publie et ont besoin de ces valeurs.
|
||||||
|
env-val = $(shell sed -n 's/^$(1)=//p' .env 2>/dev/null | tail -1)
|
||||||
|
PG_USER := $(or $(strip $(call env-val,POSTGRES_USER)),enervision)
|
||||||
|
PG_PASSWORD := $(or $(strip $(call env-val,POSTGRES_PASSWORD)),change_me)
|
||||||
|
PG_DB := $(or $(strip $(call env-val,POSTGRES_DB)),enervision)
|
||||||
|
PG_PORT := $(or $(strip $(call env-val,POSTGRES_PORT)),5433)
|
||||||
|
AIRFLOW_PORT := $(or $(strip $(call env-val,AIRFLOW_PORT)),8080)
|
||||||
|
MAILPIT_UI_PORT := $(or $(strip $(call env-val,MAILPIT_UI_PORT)),8025)
|
||||||
|
ML_DATABASE_URL ?= postgresql+psycopg://$(PG_USER):$(PG_PASSWORD)@localhost:$(PG_PORT)/$(PG_DB)
|
||||||
|
export ML_DATABASE_URL
|
||||||
|
|
||||||
|
# Le jeu historique s'arrete au 31/12/2024 : score et detection ancres a l'horloge reelle ne
|
||||||
|
# verraient qu'un parc muet depuis des mois. Cf. `--now` de enervision_ml.score.
|
||||||
|
DEMO_NOW ?= 2024-12-31T00:00:00Z
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
||||||
dev dev-backend dev-frontend \
|
dev dev-backend dev-frontend \
|
||||||
lint format typecheck test test-cov test-integration check \
|
lint format typecheck test test-cov test-integration 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 db-wait db-ensure-airflow \
|
||||||
|
migrate bootstrap-admin services-up demo-data demo-data-force \
|
||||||
ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \
|
ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \
|
||||||
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
|
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
|
||||||
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
|
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
|
||||||
@@ -39,12 +56,19 @@ install-ml: ## Installe les dépendances du pipeline ML
|
|||||||
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
|
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
|
||||||
cd $(AIRFLOW) && uv sync --all-groups
|
cd $(AIRFLOW) && uv sync --all-groups
|
||||||
|
|
||||||
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
|
dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airflow, puis backend et frontend
|
||||||
|
@echo "airflow -> http://localhost:$(AIRFLOW_PORT) mailpit -> http://localhost:$(MAILPIT_UI_PORT)"
|
||||||
@trap 'kill 0' EXIT INT TERM; \
|
@trap 'kill 0' EXIT INT TERM; \
|
||||||
$(MAKE) --no-print-directory dev-backend & \
|
$(MAKE) --no-print-directory dev-backend & \
|
||||||
$(MAKE) --no-print-directory dev-frontend & \
|
$(MAKE) --no-print-directory dev-frontend & \
|
||||||
wait
|
wait
|
||||||
|
|
||||||
|
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow)
|
||||||
|
docker compose up -d db mailpit
|
||||||
|
@$(MAKE) --no-print-directory db-wait
|
||||||
|
@$(MAKE) --no-print-directory db-ensure-airflow
|
||||||
|
docker compose up -d airflow-init airflow-webserver airflow-scheduler
|
||||||
|
|
||||||
dev-backend: ## Lance l'API seule en rechargement à chaud
|
dev-backend: ## Lance l'API seule en rechargement à chaud
|
||||||
@echo "backend -> http://localhost:8000 (docs sur /docs)"
|
@echo "backend -> http://localhost:8000 (docs sur /docs)"
|
||||||
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
|
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
|
||||||
@@ -91,8 +115,8 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du
|
|||||||
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
|
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
|
||||||
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
|
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
|
||||||
|
|
||||||
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
|
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV= et NOW= optionnels
|
||||||
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
|
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) $(if $(NOW),--now $(NOW),)
|
||||||
|
|
||||||
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
|
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
|
||||||
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
|
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
|
||||||
@@ -108,7 +132,7 @@ airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structur
|
|||||||
|
|
||||||
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
|
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
|
||||||
|
|
||||||
airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
|
airflow-up: db-ensure-airflow ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant.
|
||||||
docker compose up -d airflow-init airflow-webserver airflow-scheduler
|
docker compose up -d airflow-init airflow-webserver airflow-scheduler
|
||||||
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
|
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
|
||||||
|
|
||||||
@@ -124,12 +148,15 @@ docker-build: ## Construit l'image du backend
|
|||||||
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
|
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
|
||||||
./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
|
./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
|
||||||
|
|
||||||
stack-up: ## Démarre la stack complète derrière le reverse proxy (80/443). PUBLIC_HOST=... au besoin
|
# Piège : l'image backend ne migre pas au démarrage, et `/health/ready` ne teste que la connexion
|
||||||
|
# et l'extension. Sans `alembic upgrade head`, la stack démarre verte sur une base sans schéma.
|
||||||
|
stack-up: ## Démarre la stack derrière le reverse proxy, puis migre la base. PUBLIC_HOST=... au besoin
|
||||||
@test -f infra/proxy/tls/fullchain.pem \
|
@test -f infra/proxy/tls/fullchain.pem \
|
||||||
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
|
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
|
||||||
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|
||||||
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
|
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
|
||||||
$(COMPOSE_PROD) up -d --build
|
$(COMPOSE_PROD) up -d --build
|
||||||
|
$(COMPOSE_PROD) exec -T backend alembic upgrade head
|
||||||
|
|
||||||
stack-down: ## Arrête la stack complète en conservant les données
|
stack-down: ## Arrête la stack complète en conservant les données
|
||||||
$(COMPOSE_PROD) stop
|
$(COMPOSE_PROD) stop
|
||||||
@@ -165,8 +192,36 @@ db-logs: ## Suit les journaux de la base
|
|||||||
db-psql: ## Ouvre une session psql sur la base applicative
|
db-psql: ## Ouvre une session psql sur la base applicative
|
||||||
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision}
|
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision}
|
||||||
|
|
||||||
|
db-wait: ## Attend que la base accepte les connexions
|
||||||
|
@for _ in $$(seq 1 60); do \
|
||||||
|
docker compose exec -T db pg_isready -U $(PG_USER) -d $(PG_DB) >/dev/null 2>&1 && exit 0; \
|
||||||
|
sleep 1; \
|
||||||
|
done; \
|
||||||
|
echo "La base n'accepte toujours pas de connexion apres 60s"; exit 1
|
||||||
|
|
||||||
|
# Piege : db/init ne rejoue qu'a la premiere initialisation du volume. Un `pgdata` cree avant
|
||||||
|
# db/init/120-airflow-database.sql n'a pas de base `airflow`, et airflow-init boucle dessus.
|
||||||
|
db-ensure-airflow: ## Crée la base de métadonnées Airflow si le volume pgdata est antérieur à db/init/120
|
||||||
|
@docker compose exec -T db psql -U $(PG_USER) -d postgres -tAc \
|
||||||
|
"SELECT 1 FROM pg_database WHERE datname = 'airflow'" | grep -q 1 \
|
||||||
|
|| docker compose exec -T db psql -U $(PG_USER) -d postgres -c "CREATE DATABASE airflow"
|
||||||
|
|
||||||
migrate: ## Applique les migrations Alembic
|
migrate: ## Applique les migrations Alembic
|
||||||
cd $(BACKEND) && uv run alembic upgrade head
|
cd $(BACKEND) && uv run alembic upgrade head
|
||||||
|
|
||||||
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier
|
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier
|
||||||
cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis}
|
cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis}
|
||||||
|
|
||||||
|
demo-data: ## Renseigne prédictions, alertes et recommandations si elles manquent. NOW= optionnel
|
||||||
|
@nombre=$$(docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -tAc 'SELECT count(*) FROM alert') \
|
||||||
|
|| { echo "demo-data : base injoignable ou migrations non appliquees"; exit 1; }; \
|
||||||
|
if [ "$$nombre" = 0 ]; then \
|
||||||
|
$(MAKE) --no-print-directory demo-data-force; \
|
||||||
|
else \
|
||||||
|
echo "demo-data : $$nombre alerte(s) deja en base (make demo-data-force pour rejouer)"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
demo-data-force: ## Rejoue le peuplement sans regarder l'existant. Les trois etapes sont idempotentes
|
||||||
|
$(MAKE) --no-print-directory ml-score NOW=$(DEMO_NOW)
|
||||||
|
$(MAKE) --no-print-directory detect-alerts NOW=$(DEMO_NOW)
|
||||||
|
$(MAKE) --no-print-directory recommendations
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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` | Trois DAGs |
|
| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs |
|
||||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||||
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
||||||
@@ -48,7 +48,7 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
|
|||||||
│ ├── migrations/ Migrations SQL versionnees
|
│ ├── migrations/ Migrations SQL versionnees
|
||||||
│ └── seeds/ Jeux de donnees de reference
|
│ └── seeds/ Jeux de donnees de reference
|
||||||
├── etl/airflow/
|
├── etl/airflow/
|
||||||
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes)
|
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique)
|
||||||
│ ├── plugins/ Operateurs et hooks maison
|
│ ├── plugins/ Operateurs et hooks maison
|
||||||
│ ├── include/ Requetes SQL et ressources des DAGs
|
│ ├── include/ Requetes SQL et ressources des DAGs
|
||||||
│ └── tests/ Tests d'integrite des DAGs
|
│ └── tests/ Tests d'integrite des DAGs
|
||||||
@@ -75,25 +75,52 @@ Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Pyth
|
|||||||
cp .env.example .env # variables de docker-compose
|
cp .env.example .env # variables de docker-compose
|
||||||
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
|
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
|
||||||
|
|
||||||
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
|
make install # dependances du backend, du frontend, du ML et des DAGs
|
||||||
make install # dependances du backend et du frontend
|
make dev # toute la stack, voir ci-dessous
|
||||||
make migrate # applique les migrations Alembic
|
|
||||||
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
|
|
||||||
make check # lint + typage + tests
|
make check # lint + typage + tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make dev` enchaine tout : demarrage des services conteneurises (base sur le port 5433, Mailpit,
|
||||||
|
Airflow), migrations Alembic, peuplement de demonstration si les alertes manquent, puis backend
|
||||||
|
et frontend en rechargement a chaud sur le poste.
|
||||||
|
|
||||||
|
| Service | Adresse |
|
||||||
|
|---|---|
|
||||||
|
| Backend | <http://localhost:8000> (documentation sur `/docs`) |
|
||||||
|
| Frontend | <http://localhost:4200> |
|
||||||
|
| Airflow | <http://localhost:8080> (`AIRFLOW_ADMIN_USERNAME` / `AIRFLOW_ADMIN_PASSWORD` du `.env`) |
|
||||||
|
| Mailpit | <http://localhost:8025> |
|
||||||
|
|
||||||
|
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
|
||||||
|
`AIRFLOW_WEBSERVER_SECRET_KEY`, `AIRFLOW_APP_SECRET_KEY` et `AIRFLOW_ADMIN_PASSWORD`. Sans elles
|
||||||
|
`airflow-init` refuse de demarrer, et `airflow-webserver` comme `airflow-scheduler` avec lui.
|
||||||
|
|
||||||
|
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
|
||||||
|
`make airflow-up`, `make dev-backend`, `make dev-frontend`.
|
||||||
|
|
||||||
`make help` liste les cibles disponibles.
|
`make help` liste les cibles disponibles.
|
||||||
|
|
||||||
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`,
|
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`,
|
||||||
`apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que
|
`apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que
|
||||||
5432, souvent deja pris par une autre base.
|
5432, souvent deja pris par une autre base.
|
||||||
|
|
||||||
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
|
Le backend et le frontend tournent sur le poste, lances ensemble par `make dev` (logs
|
||||||
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par
|
entrelaces dans le meme terminal, Ctrl+C arrete les deux) ; la base, Mailpit et Airflow tournent
|
||||||
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend`
|
en conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
|
||||||
et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend`
|
et n'embarque pas le source, donc toute modification y demande un
|
||||||
du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc
|
`docker compose up -d --build backend`.
|
||||||
toute modification y demande un `docker compose up -d --build backend`.
|
|
||||||
|
### Donnees de demonstration
|
||||||
|
|
||||||
|
Le jeu historique s'arrete au 31/12/2024. `make demo-data` renseigne les tables que les vues
|
||||||
|
alertes, recommandations et previsions lisent, en ancrant le scoring et la detection a cette
|
||||||
|
date (`DEMO_NOW`) plutot qu'a l'horloge reelle, qui ne verrait qu'un parc muet depuis des mois.
|
||||||
|
La cible ne fait rien si des alertes existent deja ; `make demo-data-force` rejoue les trois
|
||||||
|
etapes, toutes idempotentes en base.
|
||||||
|
|
||||||
|
Un volume `pgdata` cree avant `db/init/120-airflow-database.sql` n'a pas de base `airflow` :
|
||||||
|
`db/init` ne rejoue qu'a la premiere initialisation. `make db-ensure-airflow`, appelee par
|
||||||
|
`make dev` et `make airflow-up`, la cree au besoin, sans detruire les donnees applicatives.
|
||||||
|
|
||||||
Verifier que la base repond et que l'extension est chargee :
|
Verifier que la base repond et que l'extension est chargee :
|
||||||
|
|
||||||
@@ -116,6 +143,12 @@ nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvel
|
|||||||
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
|
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
|
||||||
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||||
|
|
||||||
|
Sur la VM ENI, deux environnements cohabitent, recette sur `dev` et production sur `main`,
|
||||||
|
chacun dans son dossier et son projet Compose : `scripts/provision-host.sh` les prépare, le
|
||||||
|
workflow `deploy.yml` les redéploie à chaque push par un runner auto-hébergé. Ports, noms
|
||||||
|
d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et
|
||||||
|
[l'ADR 0009](docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.models.energy import Alert
|
from app.models.energy import Alert
|
||||||
|
|
||||||
|
# Douze colonnes par alerte, contre quatre pour une recommandation : le plafond asyncpg de
|
||||||
|
# 32 767 parametres tombe a 2 730 lignes, d'ou un lot plus petit que `recommendation.py`.
|
||||||
|
TAILLE_DE_LOT = 1000
|
||||||
|
|
||||||
|
|
||||||
class AlertRepository:
|
class AlertRepository:
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
@@ -44,12 +48,17 @@ class AlertRepository:
|
|||||||
}
|
}
|
||||||
for alerte in alerts
|
for alerte in alerts
|
||||||
]
|
]
|
||||||
requete = (
|
creees: list[Alert] = []
|
||||||
insert(Alert)
|
# Piège : asyncpg plafonne une requête à 32 767 paramètres. Une détection sur une fenêtre
|
||||||
.values(valeurs)
|
# chargée dépasse ce seuil, et l'`INSERT` d'un seul tenant échouerait.
|
||||||
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
|
for debut in range(0, len(valeurs), TAILLE_DE_LOT):
|
||||||
.returning(Alert)
|
requete = (
|
||||||
)
|
insert(Alert)
|
||||||
resultat = await self._session.execute(requete)
|
.values(valeurs[debut : debut + TAILLE_DE_LOT])
|
||||||
|
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
|
||||||
|
.returning(Alert)
|
||||||
|
)
|
||||||
|
resultat = await self._session.execute(requete)
|
||||||
|
creees.extend(resultat.scalars().all())
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
return resultat.scalars().all()
|
return creees
|
||||||
|
|||||||
@@ -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,21 +219,22 @@ async def test_alert_rejects_prediction_when_site_differs(
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
statement = insert(Alert).values(
|
||||||
|
source_alert_id=str(uuid4()),
|
||||||
|
site_id=other_site,
|
||||||
|
source="enervision",
|
||||||
|
timestamp=MOMENT,
|
||||||
|
type="spike",
|
||||||
|
severity="high",
|
||||||
|
message="Test",
|
||||||
|
prediction_id=prediction_id,
|
||||||
|
raw_data={},
|
||||||
|
)
|
||||||
|
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(
|
await data_connection.execute(statement)
|
||||||
insert(Alert).values(
|
|
||||||
source_alert_id=str(uuid4()),
|
|
||||||
site_id=other_site,
|
|
||||||
source="enervision",
|
|
||||||
timestamp=MOMENT,
|
|
||||||
type="spike",
|
|
||||||
severity="high",
|
|
||||||
message="Test",
|
|
||||||
prediction_id=prediction_id,
|
|
||||||
raw_data={},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import pytest
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.energy import Alert
|
from app.models.energy import Alert
|
||||||
|
from app.repositories import alert as module_alert
|
||||||
from app.repositories.alert import AlertRepository
|
from app.repositories.alert import AlertRepository
|
||||||
from app.schemas.alert import AlertSeverity
|
from app.schemas.alert import AlertSeverity
|
||||||
from tests.repositories.test_site import creer as creer_site
|
from tests.repositories.test_site import creer as creer_site
|
||||||
@@ -146,3 +147,20 @@ async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession)
|
|||||||
creees = await depot.create_many([])
|
creees = await depot.create_many([])
|
||||||
|
|
||||||
assert creees == []
|
assert creees == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_many_inserts_every_alert_across_several_batches(
|
||||||
|
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(module_alert, "TAILLE_DE_LOT", 2)
|
||||||
|
site = await creer_site(session)
|
||||||
|
depot = AlertRepository(session)
|
||||||
|
a_inserer = [
|
||||||
|
_alerte_a_inserer(site_id=site.site_id, source_alert_id=f"threshold:lot-{index}")
|
||||||
|
for index in range(5)
|
||||||
|
]
|
||||||
|
|
||||||
|
creees = await depot.create_many(a_inserer)
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
assert len(creees) == 5
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
requete = text(
|
||||||
|
"insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
await session.execute(
|
await session.execute(requete, {"e": saisie})
|
||||||
text(
|
|
||||||
"insert into app_user (email, password_hash, role) "
|
|
||||||
"values (:e, '$argon2id$x', 'lecteur')"
|
|
||||||
),
|
|
||||||
{"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(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ RUN npm run build
|
|||||||
# ==================
|
# ==================
|
||||||
|
|
||||||
|
|
||||||
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner
|
FROM nginx:1.28-alpine AS runner
|
||||||
|
|
||||||
# Copie de la configuration de nginx
|
# Copie de la configuration de nginx
|
||||||
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
|
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,6 +38,12 @@ 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',
|
path: 'monitoring/sensors',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
|
|||||||
describe('mockApiInterceptor', () => {
|
describe('mockApiInterceptor', () => {
|
||||||
let http: HttpClient;
|
let http: HttpClient;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
let useMockFixturesInitial: boolean;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
useMockFixturesInitial = environment.useMockFixtures;
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
||||||
@@ -21,7 +23,7 @@ describe('mockApiInterceptor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
environment.useMockFixtures = true;
|
environment.useMockFixtures = useMockFixturesInitial;
|
||||||
httpMock.verify();
|
httpMock.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
|
<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"
|
||||||
@@ -27,87 +28,120 @@
|
|||||||
@if (statsError(); as message) {
|
@if (statsError(); as message) {
|
||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
}
|
}
|
||||||
@if (alertsError(); as message) {
|
|
||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
|
||||||
}
|
|
||||||
@if (predictionsError(); as message) {
|
@if (predictionsError(); as message) {
|
||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (stats(); as s) {
|
@if (stats(); as s) {
|
||||||
<section class="overview">
|
<p class="dashboard__status">
|
||||||
<ev-card class="card card--gauge">
|
<span class="dashboard__pulse" aria-hidden="true"></span>
|
||||||
<span class="card__label">Consommation vs capacité</span>
|
Actualisé à {{ s.timestamp | date: 'HH:mm:ss' }} · {{ s.total_sites }} sites suivis
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="overview" aria-label="Indicateurs du parc">
|
||||||
|
<ev-card class="kpi kpi--gauge">
|
||||||
|
<span class="kpi__label">Consommation vs capacité</span>
|
||||||
<app-consumption-gauge
|
<app-consumption-gauge
|
||||||
[consumption]="s.total_consumption_kw"
|
[consumption]="s.total_consumption_kw"
|
||||||
[capacity]="s.total_capacity_kw"
|
[capacity]="s.total_capacity_kw"
|
||||||
/>
|
/>
|
||||||
<span class="card__value"
|
<span class="kpi__value">
|
||||||
>{{ s.total_consumption_kw | number: '1.0-1' }} /
|
{{ s.total_consumption_kw | number: '1.0-1' }}
|
||||||
{{ s.total_capacity_kw | number }} kW</span
|
<small>/ {{ s.total_capacity_kw | number }} kW</small>
|
||||||
|
</span>
|
||||||
|
</ev-card>
|
||||||
|
|
||||||
|
<ev-card class="kpi">
|
||||||
|
<span class="kpi__label">Charge moyenne du parc</span>
|
||||||
|
<span class="kpi__value"
|
||||||
|
>{{ s.average_load_percent | number: '1.0-0' }} <small>%</small></span
|
||||||
>
|
>
|
||||||
</ev-card>
|
<div
|
||||||
|
class="progress-bar"
|
||||||
<ev-card class="card">
|
role="progressbar"
|
||||||
<span class="card__label">Charge moyenne du parc</span>
|
aria-valuemin="0"
|
||||||
<span class="card__value">{{ s.average_load_percent }} %</span>
|
aria-valuemax="100"
|
||||||
<div class="progress-bar">
|
[attr.aria-valuenow]="s.average_load_percent"
|
||||||
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
|
>
|
||||||
|
<div
|
||||||
|
class="progress-bar__fill"
|
||||||
|
[class]="'progress-bar__fill--' + loadTone(s.average_load_percent)"
|
||||||
|
[style.width.%]="s.average_load_percent"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="kpi__hint">{{ loadHint(s.average_load_percent) }}</span>
|
||||||
</ev-card>
|
</ev-card>
|
||||||
|
|
||||||
<ev-card class="card">
|
<ev-card class="kpi">
|
||||||
<span class="card__label">Sites suivis</span>
|
<span class="kpi__label">Sites suivis</span>
|
||||||
<span class="card__value">{{ s.total_sites }}</span>
|
<span class="kpi__value">{{ s.total_sites }}</span>
|
||||||
|
<a routerLink="/sites" class="ev-link kpi__link">Voir la liste des sites</a>
|
||||||
</ev-card>
|
</ev-card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="chart-section">
|
|
||||||
<h2>Charge et alerte visuelle par site</h2>
|
|
||||||
<app-site-load-chart [sites]="s.sites" />
|
|
||||||
</section>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (alerts().length > 0) {
|
<div class="dashboard__grid">
|
||||||
<section class="alerts-section">
|
<div class="dashboard__main">
|
||||||
<h2>Alertes actives</h2>
|
@if (stats(); as s) {
|
||||||
<ul class="alerts-list">
|
<section class="chart-section">
|
||||||
@for (alert of alerts(); track alert.alert_id) {
|
<h2>Charge par site</h2>
|
||||||
<li class="alert-item">
|
<ev-card class="chart-card">
|
||||||
<ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
|
<app-site-load-chart [sites]="s.sites" />
|
||||||
<span class="alert-item__message">{{ alert.message }}</span>
|
</ev-card>
|
||||||
</li>
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="predictions-section">
|
||||||
|
<h2>Prévisions de consommation</h2>
|
||||||
|
@if (predictions().length > 0) {
|
||||||
|
<ev-card class="ev-table-card">
|
||||||
|
<table class="ev-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Prévision</th>
|
||||||
|
<th>Échéance</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (site of predictions(); track site.site_id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ site.site_name }}</td>
|
||||||
|
@if (site.prediction; as prediction) {
|
||||||
|
@if (prediction.status === 'available') {
|
||||||
|
<td class="ev-table__number">
|
||||||
|
{{ prediction.predicted_value | number: '1.0-1' }} kWh
|
||||||
|
</td>
|
||||||
|
<td>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</td>
|
||||||
|
} @else {
|
||||||
|
<td>
|
||||||
|
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
|
||||||
|
prediction.status === 'insufficient_data'
|
||||||
|
? 'Historique insuffisant'
|
||||||
|
: 'Erreur'
|
||||||
|
}}</ev-badge>
|
||||||
|
</td>
|
||||||
|
<td class="ev-table__muted">-</td>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<td><ev-badge tone="neutral">Pas encore de prévision</ev-badge></td>
|
||||||
|
<td class="ev-table__muted">-</td>
|
||||||
|
}
|
||||||
|
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</ev-card>
|
||||||
|
} @else if (!predictionsError()) {
|
||||||
|
<p class="dashboard__empty">Aucune prévision disponible pour le moment.</p>
|
||||||
}
|
}
|
||||||
</ul>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
@if (predictions().length > 0) {
|
<aside class="dashboard__side" aria-label="Alertes actives">
|
||||||
<section class="predictions-section">
|
<app-alert-feed />
|
||||||
<h2>Prévisions de consommation</h2>
|
</aside>
|
||||||
<ul class="predictions-list">
|
</div>
|
||||||
@for (site of predictions(); track site.site_id) {
|
|
||||||
<li class="prediction-item">
|
|
||||||
<span class="prediction-item__site">{{ site.site_name }}</span>
|
|
||||||
@if (site.prediction; as prediction) {
|
|
||||||
@if (prediction.status === 'available') {
|
|
||||||
<span class="prediction-item__value">
|
|
||||||
{{ prediction.predicted_value | number: '1.0-1' }} kWh
|
|
||||||
<span class="prediction-item__target"
|
|
||||||
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
|
|
||||||
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur'
|
|
||||||
}}</ev-badge>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<ev-badge tone="neutral">Pas encore de prévision</ev-badge>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
|
|
||||||
.dashboard__header {
|
.dashboard__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 2rem;
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__brand {
|
.dashboard__brand {
|
||||||
@@ -20,8 +22,9 @@
|
|||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.75rem;
|
font-size: var(--font-size-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,19 +34,69 @@
|
|||||||
|
|
||||||
.dashboard__subtitle {
|
.dashboard__subtitle {
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard__actions {
|
.dashboard__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
.ev-link {
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__pulse {
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-success);
|
||||||
|
animation: pulse 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(22, 163, 74, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dashboard__pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.1rem;
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-error {
|
.banner-error {
|
||||||
@@ -54,116 +107,124 @@ h2 {
|
|||||||
.overview {
|
.overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 1rem;
|
gap: var(--space-3);
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.kpi {
|
||||||
padding: 1.25rem;
|
position: relative;
|
||||||
gap: 0.35rem;
|
overflow: hidden;
|
||||||
|
padding: var(--space-4);
|
||||||
|
gap: var(--space-1);
|
||||||
|
transition:
|
||||||
|
box-shadow 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card--gauge {
|
.kpi--gauge {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card--link {
|
.kpi__label {
|
||||||
cursor: pointer;
|
font-size: var(--font-size-xs);
|
||||||
transition: border-color 0.15s ease;
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
.kpi__value {
|
||||||
border-color: var(--color-primary);
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__label {
|
.kpi__hint {
|
||||||
font-size: 0.8rem;
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__value {
|
.kpi__link {
|
||||||
font-size: 1.6rem;
|
margin-top: auto;
|
||||||
font-weight: 700;
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 6px;
|
height: 8px;
|
||||||
|
margin: var(--space-1) 0;
|
||||||
background: var(--color-border-light);
|
background: var(--color-border-light);
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar__fill {
|
.progress-bar__fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-primary);
|
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-success);
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-bar__fill--warning {
|
||||||
|
background: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar__fill--danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__side {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.chart-section {
|
.chart-section {
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alerts-list {
|
.chart-card {
|
||||||
list-style: none;
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
font-size: var(--font-size-sm);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-danger-bg);
|
|
||||||
border: 1px solid var(--color-danger-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-item__message {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.predictions-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item__site {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item__value {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prediction-item__target {
|
|
||||||
margin-left: 0.35rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.dashboard__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__side {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { of, throwError } from 'rxjs';
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
|
import { Router, provideRouter } from '@angular/router';
|
||||||
import { Dashboard } from './dashboard';
|
import { Dashboard } from './dashboard';
|
||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
import { AlertsService } from '../../core/services/alerts.service';
|
import { AlertsService } from '../../core/services/alerts.service';
|
||||||
|
import { SitesService } from '../../core/services/sites.service';
|
||||||
import { PredictionsService } from '../../core/services/predictions.service';
|
import { PredictionsService } from '../../core/services/predictions.service';
|
||||||
import {AuthService} from '../../core/services/auth.service';
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
import {Router, provideRouter} from '@angular/router';
|
|
||||||
|
|
||||||
vi.mock('chart.js', () => {
|
vi.mock('chart.js', () => {
|
||||||
class ChartMock {
|
class ChartMock {
|
||||||
@@ -18,66 +19,73 @@ vi.mock('chart.js', () => {
|
|||||||
return { Chart: ChartMock, registerables: [] };
|
return { Chart: ChartMock, registerables: [] };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const STATS = { total_sites: 7, sites: [] };
|
||||||
|
|
||||||
function predictionsMock(sites: unknown[] = []) {
|
function predictionsMock(sites: unknown[] = []) {
|
||||||
return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
|
return {
|
||||||
|
getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
options: {
|
||||||
|
stats?: Observable<unknown>;
|
||||||
|
predictions?: { getPredictions: ReturnType<typeof vi.fn> };
|
||||||
|
auth?: Record<string, unknown>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const statsMock = { getSummary: vi.fn().mockReturnValue(options.stats ?? of(STATS)) };
|
||||||
|
const predictions = options.predictions ?? predictionsMock();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [Dashboard],
|
||||||
|
providers: [
|
||||||
|
{ provide: StatsService, useValue: statsMock },
|
||||||
|
{ provide: AlertsService, useValue: { getAlerts: vi.fn().mockReturnValue(of([])) } },
|
||||||
|
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of([])) } },
|
||||||
|
{ provide: PredictionsService, useValue: predictions },
|
||||||
|
...(options.auth ? [{ provide: AuthService, useValue: options.auth }] : []),
|
||||||
|
provideRouter([]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return { fixture: TestBed.createComponent(Dashboard), statsMock, predictions };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Dashboard', () => {
|
describe('Dashboard', () => {
|
||||||
afterEach(() => vi.useRealTimers());
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
it('charge les stats, les alertes et les prévisions au démarrage', async () => {
|
it('charge les stats et les prévisions au démarrage', async () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture, statsMock, predictions } = setup({
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
|
predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]),
|
||||||
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictions },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
// laisse le timer(0, ...) se déclencher avant de vérifier
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(statsMock.getSummary).toHaveBeenCalled();
|
expect(statsMock.getSummary).toHaveBeenCalled();
|
||||||
expect(alertsMock.getAlerts).toHaveBeenCalled();
|
|
||||||
expect(predictions.getPredictions).toHaveBeenCalled();
|
expect(predictions.getPredictions).toHaveBeenCalled();
|
||||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
|
||||||
expect(fixture.componentInstance.predictions().length).toBe(1);
|
expect(fixture.componentInstance.predictions().length).toBe(1);
|
||||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||||
expect(fixture.componentInstance.alertsError()).toBeNull();
|
|
||||||
expect(fixture.componentInstance.predictionsError()).toBeNull();
|
expect(fixture.componentInstance.predictionsError()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('délègue les alertes au widget app-alert-feed', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('app-alert-feed')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const statsMock = {
|
const { fixture, statsMock } = setup({
|
||||||
getSummary: vi
|
stats: throwError(() => new Error('API injoignable')),
|
||||||
.fn()
|
|
||||||
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
|
||||||
.mockReturnValue(of({ total_sites: 7, sites: [] })),
|
|
||||||
};
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
statsMock.getSummary
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||||
|
.mockReturnValue(of(STATS));
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
vi.advanceTimersByTime(1);
|
vi.advanceTimersByTime(1);
|
||||||
@@ -91,45 +99,11 @@ describe('Dashboard', () => {
|
|||||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
|
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.componentInstance.alerts().length).toBe(0);
|
|
||||||
expect(fixture.componentInstance.alertsError()).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
|
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture } = setup({
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
const predictions = {
|
|
||||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
|
||||||
};
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictions },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.componentInstance.predictions().length).toBe(0);
|
expect(fixture.componentInstance.predictions().length).toBe(0);
|
||||||
@@ -138,25 +112,11 @@ describe('Dashboard', () => {
|
|||||||
|
|
||||||
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
|
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture } = setup({
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
const predictions = {
|
|
||||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
|
||||||
};
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictions },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
||||||
|
|
||||||
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
|
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
|
||||||
@@ -168,109 +128,106 @@ describe('Dashboard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
const authMock = {
|
|
||||||
logout: vi.fn().mockReturnValue(of(undefined)),
|
|
||||||
clearSession: vi.fn(),
|
|
||||||
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
|
||||||
};
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
{ provide: AuthService, useValue: authMock },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const router = TestBed.inject(Router);
|
|
||||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
|
||||||
button.click();
|
|
||||||
|
|
||||||
expect(authMock.logout).toHaveBeenCalled();
|
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
|
||||||
});
|
|
||||||
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
const authMock = {
|
const authMock = {
|
||||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
logout: vi.fn().mockReturnValue(of(undefined)),
|
||||||
clearSession: vi.fn(),
|
clearSession: vi.fn(),
|
||||||
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
};
|
};
|
||||||
TestBed.configureTestingModule({
|
const { fixture } = setup({ auth: authMock });
|
||||||
imports: [Dashboard],
|
fixture.detectChanges();
|
||||||
providers: [
|
const router = TestBed.inject(Router);
|
||||||
{ provide: StatsService, useValue: statsMock },
|
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
fixture.nativeElement.querySelector('.logout-button').click();
|
||||||
{ provide: AuthService, useValue: authMock },
|
|
||||||
provideRouter([]),
|
expect(authMock.logout).toHaveBeenCalled();
|
||||||
],
|
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
||||||
fixture.detectChanges();
|
const authMock = {
|
||||||
|
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
|
};
|
||||||
|
const { fixture } = setup({ auth: authMock });
|
||||||
|
fixture.detectChanges();
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
|
|
||||||
const router = TestBed.inject(Router);
|
fixture.nativeElement.querySelector('.logout-button').click();
|
||||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
||||||
|
|
||||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
expect(authMock.clearSession).toHaveBeenCalled();
|
||||||
button.click();
|
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||||
|
});
|
||||||
|
|
||||||
expect(authMock.clearSession).toHaveBeenCalled();
|
it('affiche l’heure du dernier relevé et les indicateurs du parc', () => {
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
vi.useFakeTimers();
|
||||||
});
|
const { fixture } = setup({
|
||||||
|
stats: of({
|
||||||
it('distingue le ton des sévérités high et critical', () => {
|
timestamp: '2026-09-18T09:00:00Z',
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
total_sites: 7,
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
total_consumption_kw: 1234.5,
|
||||||
|
total_capacity_kw: 5000,
|
||||||
TestBed.configureTestingModule({
|
average_load_percent: 24.7,
|
||||||
imports: [Dashboard],
|
sites: [],
|
||||||
providers: [
|
}),
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
fixture.detectChanges();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const texte = fixture.nativeElement.textContent as string;
|
||||||
|
expect(texte).toContain('Actualisé à');
|
||||||
|
expect(texte).toContain('7 sites suivis');
|
||||||
|
expect(texte).toContain('Marge confortable');
|
||||||
|
expect(fixture.nativeElement.querySelector('.progress-bar__fill--success')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('présente les prévisions en tableau avec un lien vers chaque site', () => {
|
||||||
|
const { fixture } = setup({
|
||||||
|
predictions: predictionsMock([
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
prediction: {
|
||||||
|
target_at: '2026-09-18T10:00:00Z',
|
||||||
|
target_metric: 'consumption_kwh',
|
||||||
|
period_minutes: 60,
|
||||||
|
predicted_value: 118.4,
|
||||||
|
status: 'available',
|
||||||
|
failure_reason: null,
|
||||||
|
model_reference: 'lightgbm-v1',
|
||||||
|
created_at: '2026-09-18T09:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ site_id: 'SITE002', site_name: 'Bureau Lille', prediction: null },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const table = fixture.nativeElement.querySelector('table.ev-table');
|
||||||
|
expect(table).not.toBeNull();
|
||||||
|
expect(table.textContent).toContain('118.4 kWh');
|
||||||
|
expect(table.textContent).toContain('Pas encore de prévision');
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE001"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colore la charge moyenne selon les seuils 70 % et 90 %', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
const dashboard = fixture.componentInstance;
|
const dashboard = fixture.componentInstance;
|
||||||
|
|
||||||
expect(dashboard.badgeToneForSeverity('low')).toBe('success');
|
expect(dashboard.loadTone(69.9)).toBe('success');
|
||||||
expect(dashboard.badgeToneForSeverity('medium')).toBe('warning');
|
expect(dashboard.loadTone(70)).toBe('warning');
|
||||||
expect(dashboard.badgeToneForSeverity('high')).toBe('danger');
|
expect(dashboard.loadTone(89.9)).toBe('warning');
|
||||||
expect(dashboard.badgeToneForSeverity('critical')).toBe('critical');
|
expect(dashboard.loadTone(90)).toBe('danger');
|
||||||
expect(dashboard.badgeToneForSeverity('high')).not.toBe(
|
expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc');
|
||||||
dashboard.badgeToneForSeverity('critical'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('distingue le ton des statuts de prévision', () => {
|
it('distingue le ton des statuts de prévision', () => {
|
||||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
const { fixture } = setup();
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [Dashboard],
|
|
||||||
providers: [
|
|
||||||
{ provide: StatsService, useValue: statsMock },
|
|
||||||
{ provide: AlertsService, useValue: alertsMock },
|
|
||||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
|
||||||
provideRouter([]),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(Dashboard);
|
|
||||||
const dashboard = fixture.componentInstance;
|
const dashboard = fixture.componentInstance;
|
||||||
|
|
||||||
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
|
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import { Router, RouterLink } from '@angular/router';
|
|||||||
import { StatsService } from '../../core/services/stats.service';
|
import { StatsService } from '../../core/services/stats.service';
|
||||||
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
||||||
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
||||||
import { AlertsService } from '../../core/services/alerts.service';
|
import { AlertFeed } from '../../shared/components/alert-feed/alert-feed';
|
||||||
import { PredictionsService } from '../../core/services/predictions.service';
|
import { PredictionsService } from '../../core/services/predictions.service';
|
||||||
import { AuthService } from '../../core/services/auth.service';
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
import { StatsSummary } from '../../shared/models/stats.model';
|
import { StatsSummary } from '../../shared/models/stats.model';
|
||||||
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
|
||||||
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
|
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
|
||||||
import { Card } from '../../shared/components/ui/card/card';
|
import { Card } from '../../shared/components/ui/card/card';
|
||||||
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||||
@@ -22,22 +21,19 @@ const REFRESH_INTERVAL_MS = 10000;
|
|||||||
const UNAVAILABLE_MESSAGE =
|
const UNAVAILABLE_MESSAGE =
|
||||||
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
||||||
|
|
||||||
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
// `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` :
|
||||||
low: 'success',
|
// mieux vaut un ton défini que `undefined` le jour où ce statut apparaît.
|
||||||
medium: 'warning',
|
|
||||||
high: 'danger',
|
|
||||||
critical: 'critical',
|
|
||||||
};
|
|
||||||
|
|
||||||
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
|
|
||||||
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
|
|
||||||
// tomber sur `undefined` si ce statut apparaît un jour.
|
|
||||||
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
||||||
available: 'success',
|
available: 'success',
|
||||||
insufficient_data: 'warning',
|
insufficient_data: 'warning',
|
||||||
error: 'danger',
|
error: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SEUIL_CHARGE_SOUTENUE = 70;
|
||||||
|
const SEUIL_CHARGE_CRITIQUE = 90;
|
||||||
|
|
||||||
|
export type LoadTone = 'success' | 'warning' | 'danger';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dashboard',
|
selector: 'app-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -47,6 +43,7 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
|||||||
RouterLink,
|
RouterLink,
|
||||||
ConsumptionGauge,
|
ConsumptionGauge,
|
||||||
SiteLoadChart,
|
SiteLoadChart,
|
||||||
|
AlertFeed,
|
||||||
Card,
|
Card,
|
||||||
EvAlert,
|
EvAlert,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -58,32 +55,20 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
|||||||
})
|
})
|
||||||
export class Dashboard implements OnInit {
|
export class Dashboard implements OnInit {
|
||||||
private statsService = inject(StatsService);
|
private statsService = inject(StatsService);
|
||||||
private alertsService = inject(AlertsService);
|
|
||||||
public auth = inject(AuthService);
|
public auth = inject(AuthService);
|
||||||
private predictionsService = inject(PredictionsService);
|
private predictionsService = inject(PredictionsService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
stats = signal<StatsSummary | null>(null);
|
stats = signal<StatsSummary | null>(null);
|
||||||
alerts = signal<Alert[]>([]);
|
|
||||||
predictions = signal<SitePredictionSummary[]>([]);
|
predictions = signal<SitePredictionSummary[]>([]);
|
||||||
|
|
||||||
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
|
// Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en
|
||||||
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
|
// silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur.
|
||||||
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
|
|
||||||
statsError = signal<string | null>(null);
|
statsError = signal<string | null>(null);
|
||||||
alertsError = signal<string | null>(null);
|
|
||||||
predictionsError = signal<string | null>(null);
|
predictionsError = signal<string | null>(null);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.alertsService
|
|
||||||
.getAlerts()
|
|
||||||
.pipe(catchError(() => this.reportUnavailable(this.alertsError)))
|
|
||||||
.subscribe((alerts) => {
|
|
||||||
this.alertsError.set(null);
|
|
||||||
this.alerts.set(alerts);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
|
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
|
||||||
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
|
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
|
||||||
this.predictionsService
|
this.predictionsService
|
||||||
@@ -99,7 +84,9 @@ export class Dashboard implements OnInit {
|
|||||||
timer(0, REFRESH_INTERVAL_MS)
|
timer(0, REFRESH_INTERVAL_MS)
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(() =>
|
switchMap(() =>
|
||||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
this.statsService
|
||||||
|
.getSummary()
|
||||||
|
.pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
||||||
),
|
),
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
)
|
)
|
||||||
@@ -109,14 +96,28 @@ export class Dashboard implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
|
|
||||||
return TON_PAR_SEVERITE[severity];
|
|
||||||
}
|
|
||||||
|
|
||||||
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
|
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
|
||||||
return TON_PAR_STATUT_PREDICTION[status];
|
return TON_PAR_STATUT_PREDICTION[status];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadTone(percent: number): LoadTone {
|
||||||
|
if (percent >= SEUIL_CHARGE_CRITIQUE) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
return percent >= SEUIL_CHARGE_SOUTENUE ? 'warning' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHint(percent: number): string {
|
||||||
|
switch (this.loadTone(percent)) {
|
||||||
|
case 'danger':
|
||||||
|
return 'Proche de la capacité du parc';
|
||||||
|
case 'warning':
|
||||||
|
return 'Charge soutenue';
|
||||||
|
default:
|
||||||
|
return 'Marge confortable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onLogout(): void {
|
onLogout(): void {
|
||||||
this.auth.logout().subscribe({
|
this.auth.logout().subscribe({
|
||||||
next: () => this.router.navigate(['/login']),
|
next: () => this.router.navigate(['/login']),
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
}
|
}
|
||||||
|
|
||||||
<ev-card class="table-card">
|
<ev-card class="ev-table-card">
|
||||||
<table class="sites-table">
|
<table class="ev-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
@@ -36,7 +36,9 @@
|
|||||||
<td>{{ site.site_type }}</td>
|
<td>{{ site.site_type }}</td>
|
||||||
<td>{{ site.location || '-' }}</td>
|
<td>{{ site.location || '-' }}</td>
|
||||||
<td>{{ site.capacity_kw ?? '-' }}</td>
|
<td>{{ site.capacity_kw ?? '-' }}</td>
|
||||||
<td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td>
|
<td>
|
||||||
|
<ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge>
|
||||||
|
</td>
|
||||||
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,32 +32,3 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-card {
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sites-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 0.85rem 1.25rem;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<section class="alert-feed">
|
||||||
|
<header class="alert-feed__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="alert-feed__title">Alertes actives</h2>
|
||||||
|
@if (!loading() || alerts().length > 0) {
|
||||||
|
<p class="alert-feed__count">
|
||||||
|
{{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="alert-feed__filters">
|
||||||
|
@if (!siteId()) {
|
||||||
|
<label class="alert-feed__filter">
|
||||||
|
<span class="form-label">Site</span>
|
||||||
|
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
|
||||||
|
<option value="">Tous les sites</option>
|
||||||
|
@for (site of sites(); track site.site_id) {
|
||||||
|
<option [value]="site.site_id">{{ site.site_name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="alert-feed__filter">
|
||||||
|
<span class="form-label">Sévérité</span>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
data-testid="severity-filter"
|
||||||
|
(change)="onSeverityChange($event)"
|
||||||
|
>
|
||||||
|
<option value="">Toutes</option>
|
||||||
|
@for (severite of severites; track severite) {
|
||||||
|
<option [value]="severite">{{ severityLabel(severite) }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="alert-feed__banner">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading() && alerts().length === 0 && !error()) {
|
||||||
|
<p class="alert-feed__state" aria-live="polite">Chargement des alertes…</p>
|
||||||
|
} @else if (alerts().length === 0 && !error()) {
|
||||||
|
<ev-alert severity="success" class="alert-feed__banner"
|
||||||
|
>Aucune alerte pour ces critères.</ev-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ul class="alert-feed__list" [attr.aria-busy]="loading()">
|
||||||
|
@for (alert of visibleAlerts(); track alert.alert_id) {
|
||||||
|
<li class="alert-feed__item" [class]="'alert-feed__item--' + alert.severity">
|
||||||
|
<span class="alert-feed__icon">
|
||||||
|
<ev-icon [name]="alert.type" [label]="typeLabel(alert.type)" />
|
||||||
|
</span>
|
||||||
|
<div class="alert-feed__body">
|
||||||
|
<div class="alert-feed__meta">
|
||||||
|
<ev-badge [tone]="toneFor(alert.severity)">{{
|
||||||
|
severityLabel(alert.severity)
|
||||||
|
}}</ev-badge>
|
||||||
|
<span class="alert-feed__type">{{ typeLabel(alert.type) }}</span>
|
||||||
|
<span class="alert-feed__site">{{ siteName(alert.site_id) }}</span>
|
||||||
|
<time class="alert-feed__time" [attr.datetime]="alert.timestamp">
|
||||||
|
{{ alert.timestamp | date: 'dd/MM/yyyy HH:mm' }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p class="alert-feed__message">{{ alert.message }}</p>
|
||||||
|
@if (alert.value !== null) {
|
||||||
|
<p class="alert-feed__values">
|
||||||
|
<strong>{{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }}</strong>
|
||||||
|
@if (alert.threshold !== null) {
|
||||||
|
<span
|
||||||
|
>seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (hiddenCount() > 0) {
|
||||||
|
<ev-button
|
||||||
|
variant="secondary"
|
||||||
|
[fullWidth]="false"
|
||||||
|
class="alert-feed__more"
|
||||||
|
data-testid="show-more"
|
||||||
|
(click)="showMore()"
|
||||||
|
>
|
||||||
|
Afficher plus ({{ hiddenCount() }} restantes)
|
||||||
|
</ev-button>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__count {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 10rem;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__state {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
border-left: 4px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item--medium {
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
color: var(--color-warning-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item--high {
|
||||||
|
border-left-color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__item--critical {
|
||||||
|
border-left-color: var(--color-critical);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
color: var(--color-critical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__values {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-feed__more {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { AlertFeed } from './alert-feed';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
|
import { Alert } from '../../models/alert.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
|
||||||
|
const SITES: Site[] = [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function alerte(surcharges: Partial<Alert> = {}): Alert {
|
||||||
|
return {
|
||||||
|
alert_id: 1,
|
||||||
|
site_id: 'SITE001',
|
||||||
|
timestamp: '2026-09-15T11:12:00Z',
|
||||||
|
type: 'spike',
|
||||||
|
severity: 'critical',
|
||||||
|
message: 'Variation brutale entre deux lectures consécutives',
|
||||||
|
value: 812.5,
|
||||||
|
threshold: 400,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
|
||||||
|
sitesMock: { getSites: ReturnType<typeof vi.fn> } = {
|
||||||
|
getSites: vi.fn().mockReturnValue(of(SITES)),
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [AlertFeed],
|
||||||
|
providers: [
|
||||||
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
|
{ provide: SitesService, useValue: sitesMock },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return TestBed.createComponent(AlertFeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function premierChargement(fixture: ComponentFixture<AlertFeed>) {
|
||||||
|
fixture.detectChanges();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function texte(fixture: ComponentFixture<AlertFeed>): string {
|
||||||
|
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function choisir(fixture: ComponentFixture<AlertFeed>, testId: string, value: string) {
|
||||||
|
const select = fixture.nativeElement.querySelector(
|
||||||
|
`[data-testid="${testId}"]`,
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
select.value = value;
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AlertFeed', () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('charge les alertes au démarrage sans filtre et les affiche avec leur contexte', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getAlerts.mock.calls[0][0]).toEqual({});
|
||||||
|
const contenu = texte(fixture);
|
||||||
|
expect(contenu).toContain('Usine Nantes');
|
||||||
|
expect(contenu).toContain('Critique');
|
||||||
|
expect(contenu).toContain('Pic de consommation');
|
||||||
|
expect(contenu).toContain('15/09/2026');
|
||||||
|
expect(contenu).toContain('812.5 kW');
|
||||||
|
expect(contenu).toContain('seuil 400 kW');
|
||||||
|
expect(fixture.nativeElement.querySelector('ev-icon svg')).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('.alert-feed__item--critical')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annonce le chargement avant la première réponse', () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.loading()).toBe(true);
|
||||||
|
expect(texte(fixture)).toContain('Chargement des alertes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence d'alerte pour les critères choisis", () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('Aucune alerte pour ces critères.');
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relance la requête avec la sévérité choisie et revient à la première page', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
premierChargement(fixture);
|
||||||
|
fixture.componentInstance.showMore();
|
||||||
|
|
||||||
|
choisir(fixture, 'severity-filter', 'high');
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getAlerts.mock.calls[1][0]).toEqual({ severity: 'high' });
|
||||||
|
expect(fixture.componentInstance.visibleCount()).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relance la requête avec le site choisi dans le filtre', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
choisir(fixture, 'site-filter', 'SITE001');
|
||||||
|
|
||||||
|
expect(getAlerts.mock.calls[1][0]).toEqual({ site_id: 'SITE001' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('masque le filtre site et force site_id quand le parent fixe le site', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
fixture.componentRef.setInput('siteId', 'SITE001');
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts.mock.calls[0][0]).toEqual({ site_id: 'SITE001' });
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="site-filter"]')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="severity-filter"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'indisponibilité en gardant la liste, puis repart au rafraîchissement suivant", () => {
|
||||||
|
const getAlerts = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(of([alerte()]))
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||||
|
.mockReturnValue(of([alerte(), alerte({ alert_id: 2 })]));
|
||||||
|
const fixture = setup({ getAlerts });
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||||
|
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||||
|
expect(texte(fixture)).toContain('Alertes indisponibles');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(3);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
expect(fixture.componentInstance.alerts().length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pagine côté client par dix et dévoile le reste à la demande', () => {
|
||||||
|
const alertes = Array.from({ length: 25 }, (_, i) => alerte({ alert_id: i + 1 }));
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(alertes)) });
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(10);
|
||||||
|
expect(texte(fixture)).toContain('Afficher plus (15 restantes)');
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(20);
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(25);
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="show-more"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replie sur l'identifiant quand le site est inconnu ou que la liste des sites échoue", () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(of([alerte({ site_id: 'SITE999' })])) },
|
||||||
|
{ getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
|
);
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('SITE999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'affiche pas de mesure pour une alerte sans valeur", () => {
|
||||||
|
const fixture = setup({
|
||||||
|
getAlerts: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(
|
||||||
|
of([alerte({ type: 'outage', value: null, threshold: null, metric: null })]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
premierChargement(fixture);
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('.alert-feed__values')).toBeNull();
|
||||||
|
expect(texte(fixture)).toContain('Coupure');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { catchError, EMPTY, Observable, of, switchMap, tap, timer } from 'rxjs';
|
||||||
|
import { AlertFilters, AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
|
import { Alert, AlertMetric, AlertSeverity, AlertType } from '../../models/alert.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
import {
|
||||||
|
LIBELLE_PAR_SEVERITE,
|
||||||
|
LIBELLE_PAR_TYPE,
|
||||||
|
SEVERITES,
|
||||||
|
TON_PAR_SEVERITE,
|
||||||
|
UNITE_PAR_METRIQUE,
|
||||||
|
} from '../../models/alert-presentation';
|
||||||
|
import { Badge, BadgeTone } from '../ui/badge/badge';
|
||||||
|
import { Button } from '../ui/button/button';
|
||||||
|
import { Alert as EvAlert } from '../ui/alert/alert';
|
||||||
|
import { Icon } from '../ui/icon/icon';
|
||||||
|
|
||||||
|
// Le DAG de détection tourne toutes les heures : une minute suffit largement pour suivre le flux.
|
||||||
|
const REFRESH_INTERVAL_MS = 60_000;
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
const UNAVAILABLE_MESSAGE = 'Alertes indisponibles, la liste affichée date du dernier chargement.';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-alert-feed',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DatePipe, DecimalPipe, Badge, Button, EvAlert, Icon],
|
||||||
|
templateUrl: './alert-feed.html',
|
||||||
|
styleUrl: './alert-feed.scss',
|
||||||
|
})
|
||||||
|
export class AlertFeed {
|
||||||
|
private alertsService = inject(AlertsService);
|
||||||
|
private sitesService = inject(SitesService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
siteId = input<string | null>(null);
|
||||||
|
|
||||||
|
readonly severites = SEVERITES;
|
||||||
|
severity = signal<AlertSeverity | null>(null);
|
||||||
|
siteFilter = signal<string | null>(null);
|
||||||
|
|
||||||
|
alerts = signal<Alert[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
visibleCount = signal(PAGE_SIZE);
|
||||||
|
|
||||||
|
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
|
||||||
|
initialValue: [] as Site[],
|
||||||
|
});
|
||||||
|
|
||||||
|
private filters = computed<AlertFilters>(() => ({
|
||||||
|
site_id: this.siteId() ?? this.siteFilter() ?? undefined,
|
||||||
|
severity: this.severity() ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
private siteNameById = computed(
|
||||||
|
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
|
||||||
|
);
|
||||||
|
|
||||||
|
visibleAlerts = computed(() => this.alerts().slice(0, this.visibleCount()));
|
||||||
|
hiddenCount = computed(() => Math.max(this.alerts().length - this.visibleCount(), 0));
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
toObservable(this.filters)
|
||||||
|
.pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.visibleCount.set(PAGE_SIZE);
|
||||||
|
}),
|
||||||
|
// Piège : catchError sur l'observable interne ; sur le flux externe il terminerait le
|
||||||
|
// timer et le rafraîchissement ne repartirait jamais.
|
||||||
|
switchMap((filters) =>
|
||||||
|
timer(0, REFRESH_INTERVAL_MS).pipe(
|
||||||
|
switchMap(() =>
|
||||||
|
this.alertsService
|
||||||
|
.getAlerts(filters)
|
||||||
|
.pipe(catchError(() => this.reportUnavailable())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((alerts) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(null);
|
||||||
|
this.alerts.set(alerts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSiteChange(event: Event): void {
|
||||||
|
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeverityChange(event: Event): void {
|
||||||
|
const value = (event.target as HTMLSelectElement).value;
|
||||||
|
this.severity.set(value ? (value as AlertSeverity) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
showMore(): void {
|
||||||
|
this.visibleCount.update((count) => count + PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
toneFor(severity: AlertSeverity): BadgeTone {
|
||||||
|
return TON_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
severityLabel(severity: AlertSeverity): string {
|
||||||
|
return LIBELLE_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
typeLabel(type: AlertType): string {
|
||||||
|
return LIBELLE_PAR_TYPE[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
siteName(siteId: string): string {
|
||||||
|
return this.siteNameById().get(siteId) ?? siteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
unitFor(metric: AlertMetric | null): string {
|
||||||
|
return metric ? UNITE_PAR_METRIQUE[metric] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportUnavailable(): Observable<never> {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 @@
|
|||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
focusable="false"
|
||||||
|
[attr.role]="label() ? 'img' : null"
|
||||||
|
[attr.aria-label]="label()"
|
||||||
|
[attr.aria-hidden]="label() ? null : 'true'"
|
||||||
|
>
|
||||||
|
@switch (name()) {
|
||||||
|
@case ('spike') {
|
||||||
|
<polyline points="3 17 8 12 11 15 21 5" />
|
||||||
|
<polyline points="15 5 21 5 21 11" />
|
||||||
|
}
|
||||||
|
@case ('threshold') {
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" stroke-dasharray="3 3" />
|
||||||
|
<polyline points="4 19 9 13 13 15 20 6" />
|
||||||
|
}
|
||||||
|
@case ('anomaly') {
|
||||||
|
<polyline points="3 12 7 12 9.5 6 12.5 18 15 12 21 12" />
|
||||||
|
}
|
||||||
|
@case ('outage') {
|
||||||
|
<path d="M7.5 6.5a7 7 0 1 0 9 0" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="11" />
|
||||||
|
}
|
||||||
|
@case ('sensor') {
|
||||||
|
<circle cx="12" cy="13" r="2" />
|
||||||
|
<path d="M8.5 9.5a5 5 0 0 0 0 7" />
|
||||||
|
<path d="M15.5 9.5a5 5 0 0 1 0 7" />
|
||||||
|
<path d="M5.5 6.5a9 9 0 0 0 0 13" />
|
||||||
|
<path d="M18.5 6.5a9 9 0 0 1 0 13" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { Icon, IconName } from './icon';
|
||||||
|
|
||||||
|
const NOMS: IconName[] = ['spike', 'threshold', 'anomaly', 'outage', 'sensor'];
|
||||||
|
|
||||||
|
function rendre(name: IconName, label: string | null = null) {
|
||||||
|
const fixture = TestBed.createComponent(Icon);
|
||||||
|
fixture.componentRef.setInput('name', name);
|
||||||
|
fixture.componentRef.setInput('label', label);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture.nativeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Icon', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({ imports: [Icon] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dessine un tracé distinct pour chacun des cinq types', () => {
|
||||||
|
const traces = NOMS.map((name) => rendre(name).querySelector('svg')?.innerHTML.trim());
|
||||||
|
|
||||||
|
for (const trace of traces) {
|
||||||
|
expect(trace).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(new Set(traces).size).toBe(NOMS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reste décoratif sans libellé', () => {
|
||||||
|
const svg = rendre('spike').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(svg?.hasAttribute('role')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expose un rôle image et un libellé accessible quand on lui en donne un', () => {
|
||||||
|
const svg = rendre('outage', 'Coupure').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('role')).toBe('img');
|
||||||
|
expect(svg?.getAttribute('aria-label')).toBe('Coupure');
|
||||||
|
expect(svg?.hasAttribute('aria-hidden')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hérite de la couleur du parent via currentColor', () => {
|
||||||
|
const svg = rendre('sensor').querySelector('svg');
|
||||||
|
|
||||||
|
expect(svg?.getAttribute('stroke')).toBe('currentColor');
|
||||||
|
expect(svg?.getAttribute('fill')).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
export type IconName = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ev-icon',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './icon.html',
|
||||||
|
styleUrl: './icon.scss',
|
||||||
|
})
|
||||||
|
export class Icon {
|
||||||
|
name = input.required<IconName>();
|
||||||
|
label = input<string | null>(null);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
@use 'styles/forms';
|
@use 'styles/forms';
|
||||||
@use 'styles/auth-page';
|
@use 'styles/auth-page';
|
||||||
@use 'styles/links';
|
@use 'styles/links';
|
||||||
|
@use 'styles/tables';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Piège : `ev-card` pose son padding sur `:host`, compilé en `[_nghost-…]` (même spécificité
|
||||||
|
// qu'une classe) et injecté après la feuille globale ; il faut le sélecteur d'élément pour gagner.
|
||||||
|
ev-card.ev-table-card {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ev-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover td {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ev-table__number {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ev-table__muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
@@ -29,10 +29,17 @@
|
|||||||
|
|
||||||
// Typo, rayons, ombre
|
// Typo, rayons, ombre
|
||||||
--font-family: 'Segoe UI', system-ui, sans-serif;
|
--font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--font-size-xs: 0.75rem;
|
||||||
|
--font-size-sm: 0.85rem;
|
||||||
|
--font-size-md: 1rem;
|
||||||
|
--font-size-lg: 1.25rem;
|
||||||
|
--font-size-xl: 1.75rem;
|
||||||
|
--font-size-2xl: 2.25rem;
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
--radius-md: 12px;
|
--radius-md: 12px;
|
||||||
--radius-pill: 999px;
|
--radius-pill: 999px;
|
||||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
|
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-card-hover: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
// Espacements
|
// Espacements
|
||||||
--space-1: 0.35rem;
|
--space-1: 0.35rem;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
|
|||||||
+11
-4
@@ -5,6 +5,8 @@
|
|||||||
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
|
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
|
||||||
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
|
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
|
||||||
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
|
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
|
||||||
|
# Pourquoi : ports du proxy et origine publique en variables, pour que deux environnements
|
||||||
|
# cohabitent sur la même machine, chacun dans son projet Compose (ADR 0009).
|
||||||
|
|
||||||
name: enervision
|
name: enervision
|
||||||
|
|
||||||
@@ -12,6 +14,9 @@ services:
|
|||||||
db:
|
db:
|
||||||
ports: !override
|
ports: !override
|
||||||
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
||||||
|
environment:
|
||||||
|
TS_TUNE_MEMORY: ${TS_TUNE_MEMORY:-2GB}
|
||||||
|
TS_TUNE_NUM_CPUS: ${TS_TUNE_NUM_CPUS:-2}
|
||||||
|
|
||||||
mailpit:
|
mailpit:
|
||||||
ports: !override
|
ports: !override
|
||||||
@@ -20,6 +25,8 @@ services:
|
|||||||
airflow-webserver:
|
airflow-webserver:
|
||||||
ports: !override
|
ports: !override
|
||||||
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
|
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
AIRFLOW__WEBSERVER__WORKERS: ${AIRFLOW_WEBSERVER_WORKERS:-2}
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
ports: !reset null
|
ports: !reset null
|
||||||
@@ -37,8 +44,8 @@ services:
|
|||||||
APP_ENV: prod
|
APP_ENV: prod
|
||||||
APP_DEBUG: "false"
|
APP_DEBUG: "false"
|
||||||
APP_TRUST_PROXY_HEADERS: "true"
|
APP_TRUST_PROXY_HEADERS: "true"
|
||||||
APP_CORS_ORIGINS: https://${PUBLIC_HOST:-enervision.local}
|
APP_CORS_ORIGINS: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}
|
||||||
APP_FRONTEND_RESET_PASSWORD_URL: https://${PUBLIC_HOST:-enervision.local}/reset-password
|
APP_FRONTEND_RESET_PASSWORD_URL: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}/reset-password
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
ports: !reset null
|
ports: !reset null
|
||||||
@@ -51,8 +58,8 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "${PROXY_HTTP_PORT:-80}:80"
|
||||||
- "443:443"
|
- "${PROXY_HTTPS_PORT:-443}:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
|
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ x-airflow-common: &airflow-common
|
|||||||
volumes:
|
volumes:
|
||||||
- ./etl/airflow/dags:/opt/airflow/dags
|
- ./etl/airflow/dags:/opt/airflow/dags
|
||||||
- ./etl/airflow/plugins:/opt/airflow/plugins
|
- ./etl/airflow/plugins:/opt/airflow/plugins
|
||||||
|
- ./data/raw:/opt/data/raw:ro
|
||||||
- airflow_logs:/opt/airflow/logs
|
- airflow_logs:/opt/airflow/logs
|
||||||
- airflow_ml_state:/opt/ml/state
|
- airflow_ml_state:/opt/ml/state
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -15,3 +15,4 @@
|
|||||||
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||||
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||||
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
||||||
|
| [0009](adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 0009 - Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé
|
||||||
|
|
||||||
|
- Statut : accepté
|
||||||
|
- Date : 2026-09-21
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
La grille note EC03 à EC06 sur ce qui est déployé et fonctionnel au J10. Au 21/09, rien ne
|
||||||
|
l'est : la CI s'arrête au merge (issue #21), la topologie Compose avec reverse proxy
|
||||||
|
([ADR 0007](0007-terminaison-tls-et-reverse-proxy-nginx.md)) n'a jamais quitté le poste, et le
|
||||||
|
module Terraform k3s n'a jamais été appliqué. L'école met à disposition une seule VM,
|
||||||
|
`eadl-2025-nantes-g3`, sur une adresse privée que les runners hébergés par GitHub ne joignent
|
||||||
|
pas, sans DNS public.
|
||||||
|
|
||||||
|
Il faut deux environnements, recette et production, parce que la stratégie de branches en a
|
||||||
|
déjà deux, `dev` et `main`, et qu'un déploiement direct en production à chaque merge sur `dev`
|
||||||
|
n'est pas défendable.
|
||||||
|
|
||||||
|
La branche `feat/deploy` tentait de déployer par provisioners Terraform : nginx système et copie
|
||||||
|
du build Angular. La revue postée sur #21 relève huit points bloquants, dont des racines `rec`
|
||||||
|
et `prod` qui ne passent pas `terraform validate`.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Un projet Docker Compose par environnement, sur la même machine.** Deux clones du dépôt,
|
||||||
|
`/srv/enervision/rec` sur `dev` et `/srv/enervision/prod` sur `main`, chacun avec son `.env` et
|
||||||
|
son `COMPOSE_PROJECT_NAME`. Le nom de projet préfixe volumes, réseau et conteneurs : les deux
|
||||||
|
stacks ne partagent rien.
|
||||||
|
|
||||||
|
**Les ports du proxy et l'origine publique deviennent des variables** de
|
||||||
|
`docker-compose.prod.yml`. La production garde 80 et 443. La recette publie 8443 et ramène sa
|
||||||
|
redirection HTTP sur la boucle locale, faute de quoi elle renverrait vers la production. Base,
|
||||||
|
Mailpit et Airflow restent sur `127.0.0.1`, décalés d'un port.
|
||||||
|
|
||||||
|
**Deux noms d'hôte**, `enervision.local` et `rec.enervision.local`, sur la même IP. Le cookie de
|
||||||
|
rafraîchissement est posé par hôte, pas par port : un seul nom ferait se déconnecter la
|
||||||
|
production à chaque connexion en recette.
|
||||||
|
|
||||||
|
**Un runner GitHub Actions auto-hébergé sur la VM** exécute `deploy.yml` : un `push` sur `dev`
|
||||||
|
déploie la recette, un `push` sur `main` déploie la production après approbation dans
|
||||||
|
l'environnement GitHub `prod`. Le job aligne le clone sur la branche puis lance `make stack-up`.
|
||||||
|
Les images sont construites sur la machine.
|
||||||
|
|
||||||
|
**Les secrets vivent dans le `.env` de chaque dossier**, générés sur la machine par
|
||||||
|
`scripts/provision-host.sh`, jamais dans git ni dans GitHub. Le runner n'a besoin d'aucun
|
||||||
|
secret.
|
||||||
|
|
||||||
|
## Alternatives écartées
|
||||||
|
|
||||||
|
- **k3s avec un namespace par environnement** : le cluster serait vide, sans manifeste, sans
|
||||||
|
registre, sans stockage persistant. C'est la cible de `10-infra.md`, pas celle de la semaine.
|
||||||
|
- **Provisioners Terraform de `feat/deploy`** : voir la revue sur #21. Terraform reste l'outil
|
||||||
|
de provisionnement de la machine, pas de livraison applicative.
|
||||||
|
- **Deux machines**, VM Proxmox et VM Azure ENI : une deuxième infrastructure à justifier devant
|
||||||
|
le jury et à provisionner, pour un bénéfice nul sur la grille.
|
||||||
|
- **Un seul proxy frontal routant par nom d'hôte vers les deux stacks** : des URL sans port,
|
||||||
|
mais le proxy devrait joindre deux réseaux Compose où les services portent les mêmes noms.
|
||||||
|
La complexité dépasse le gain.
|
||||||
|
- **Images publiées sur GHCR et déployées par digest** : la bonne pratique, remise à plus tard.
|
||||||
|
Un registre à authentifier sur la machine, alors que le runner y construit déjà.
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
- Deux TimescaleDB sur une machine de 8 Go : sans réglage, chacune se réserverait 25 % de la
|
||||||
|
RAM au premier démarrage. L'overlay fixe `TS_TUNE_MEMORY` à 2 Go et `TS_TUNE_NUM_CPUS` à 2 par
|
||||||
|
base, et 2 workers gunicorn par webserver Airflow. La montée à 32 Go prévue par les
|
||||||
|
consignes est à demander.
|
||||||
|
- Un runner auto-hébergé sur un dépôt public exécute le code qu'on lui envoie. `deploy.yml` ne
|
||||||
|
se déclenche jamais sur `pull_request`, le runner tourne sous un utilisateur dédié, et le
|
||||||
|
dépôt doit exiger une approbation pour les workflows des PR externes.
|
||||||
|
- Les deux environnements construisent leurs images séparément à partir du même commit : ce qui
|
||||||
|
tourne en production a été construit deux fois, pas promu. Le passage à GHCR lèvera cette
|
||||||
|
limite.
|
||||||
|
- Le `make stack-up` du runner reconstruit l'image Airflow, qui copie `ml/` et `apps/backend/`,
|
||||||
|
à chaque push : plusieurs minutes par déploiement, acceptable pour la cadence du projet.
|
||||||
|
- `environments/prod` de Terraform reste vide. Le provisionnement de la machine est porté par
|
||||||
|
`scripts/provision-host.sh`, que Terraform pourra appeler par `remote-exec` le jour où une
|
||||||
|
racine visant la VM existera.
|
||||||
|
- L'image frontend quitte `dhi.io/nginx`, registre authentifié dont personne n'a l'accès, pour
|
||||||
|
`nginx:1.28-alpine`, la même image que le proxy. Elle n'avait jamais été construite.
|
||||||
@@ -70,10 +70,11 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
|
|||||||
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||||
[30-frontend.md](30-frontend.md).
|
[30-frontend.md](30-frontend.md).
|
||||||
|
|
||||||
Le lien `airflow --> db` est maintenant en trait plein : trois DAGs tournent, deux pour
|
Le lien `airflow --> db` est maintenant en trait plein : quatre DAGs tournent, deux pour
|
||||||
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
|
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
|
||||||
génération des recommandations (issue #116), cf. plus bas et [20-backend.md](20-backend.md). Le
|
génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset
|
||||||
reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit.
|
historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des
|
||||||
|
deux sources restent à compléter dans l'issue #15.
|
||||||
|
|
||||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
||||||
collecteur ne vient le lire.
|
collecteur ne vient le lire.
|
||||||
@@ -85,17 +86,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 | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG |
|
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
|
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 6 workflows, 18 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) |
|
||||||
|
|
||||||
## Flux bout en bout
|
## 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
|
||||||
@@ -168,7 +170,9 @@ consolidée.
|
|||||||
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi
|
- **Certificat reconnu** : aucun nom de domaine public ne résout vers la machine, donc le défi
|
||||||
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME
|
HTTP-01 de Let's Encrypt ne peut pas aboutir. Le certificat servi est auto-signé, le chemin ACME
|
||||||
est livré et documenté mais pas exercé.
|
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
|
||||||
|
(`pip-audit`, `npm audit`, Dependabot sur 5 écosystèmes), de même que le SAST Bandit. Voir
|
||||||
|
[50-cicd.md](50-cicd.md).
|
||||||
|
|
||||||
## Décisions structurantes
|
## Décisions structurantes
|
||||||
|
|
||||||
@@ -183,3 +187,5 @@ Elles vivent dans `../adr/`, pas ici.
|
|||||||
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
|
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
|
||||||
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||||
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||||
|
| [0008](../adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
||||||
|
| [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
||||||
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
|
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
|
||||||
|
| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` |
|
||||||
| k3s single-node | Cible à terme | `En cours` |
|
| k3s single-node | Cible à terme | `En cours` |
|
||||||
|
|
||||||
## Poste de développement
|
## Poste de développement
|
||||||
@@ -51,7 +52,7 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin
|
|||||||
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver :
|
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver :
|
||||||
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
|
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
|
||||||
|
|
||||||
### Airflow (issues #115 et #116)
|
### Airflow (issues #115, #116 et #119)
|
||||||
|
|
||||||
Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make
|
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`) :
|
||||||
@@ -75,6 +76,12 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
|
|||||||
| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` |
|
| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` |
|
||||||
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
||||||
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
||||||
|
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
||||||
|
|
||||||
|
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
|
||||||
|
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
|
||||||
|
`./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les
|
||||||
|
modifier.
|
||||||
|
|
||||||
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
|
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
|
||||||
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
|
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
|
||||||
@@ -169,6 +176,31 @@ Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent
|
|||||||
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
|
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
|
||||||
compte sur l'IP du proxy et devient globale.
|
compte sur l'IP du proxy et devient globale.
|
||||||
|
|
||||||
|
### Deux environnements sur la même machine
|
||||||
|
|
||||||
|
Statut : `En cours`, la machine n'étant pas encore provisionnée. Décision et motifs dans
|
||||||
|
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
|
||||||
|
La VM `eadl-2025-nantes-g3` portera la recette et la production, chacune dans son clone du dépôt,
|
||||||
|
son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et conteneurs : rien
|
||||||
|
n'est partagé. `scripts/provision-host.sh` prépare les deux dossiers, génère les secrets et les
|
||||||
|
certificats, et ne démarre rien.
|
||||||
|
|
||||||
|
| | Recette | Production |
|
||||||
|
|---|---|---|
|
||||||
|
| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` |
|
||||||
|
| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
|
||||||
|
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` |
|
||||||
|
| Proxy HTTP, HTTPS | `127.0.0.1:8081`, `8443` | `80`, `443` |
|
||||||
|
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
|
||||||
|
|
||||||
|
Les deux noms d'hôte visent la même IP, à déclarer dans le `/etc/hosts` des postes. Deux noms
|
||||||
|
distincts sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
|
||||||
|
La redirection HTTP de la recette est ramenée sur la boucle locale parce que la configuration
|
||||||
|
Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
|
||||||
|
|
||||||
|
Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur
|
||||||
|
la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
|
||||||
|
|
||||||
## Cible à terme, k3s
|
## Cible à terme, k3s
|
||||||
|
|
||||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
||||||
@@ -227,6 +259,9 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
|||||||
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
|
| 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) |
|
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
|
||||||
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
|
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
|
||||||
|
| Un projet Compose par environnement, sur la même machine | Une seule VM, et l'isolation par nom de projet ne demande ni cluster ni registre | `.env` de chaque dossier, [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) |
|
||||||
|
| Runner GitHub Actions auto-hébergé sur la VM | Les runners hébergés par GitHub ne joignent pas une adresse privée d'école | `.github/workflows/deploy.yml` |
|
||||||
|
| Secrets dans le `.env` de chaque environnement, sur la machine | Ni dans git, ni dans GitHub : le runner n'a rien à recevoir | `scripts/provision-host.sh` |
|
||||||
|
|
||||||
## Ports et noms
|
## Ports et noms
|
||||||
|
|
||||||
@@ -237,7 +272,7 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
|||||||
| API | `8000` | Identique en conteneur et hors conteneur |
|
| API | `8000` | Identique en conteneur et hors conteneur |
|
||||||
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
|
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
|
||||||
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
|
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
|
||||||
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`. 80 ne sert que la redirection et le défi ACME |
|
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. 80 ne sert que la redirection et le défi ACME. La recette publie `8443` et `127.0.0.1:8081` |
|
||||||
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
||||||
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
||||||
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
|
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,39 +4,47 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
|
|||||||
|
|
||||||
## État actuel
|
## État actuel
|
||||||
|
|
||||||
Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée
|
Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, la
|
||||||
par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API.
|
supervision des capteurs (admin) et le flux des alertes actives, tous branchés sur l'API réelle.
|
||||||
|
|
||||||
Ce qui est en place :
|
Ce qui est en place :
|
||||||
|
|
||||||
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
|
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
|
||||||
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
|
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
|
||||||
`provideHttpClient(withInterceptors([mockApiInterceptor]))`.
|
`provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`.
|
||||||
- Une route `/dashboard` en composant différé, et une redirection depuis la racine.
|
- Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`,
|
||||||
- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et
|
`/monitoring/sensors` réservée au rôle `admin`) et une redirection depuis la racine.
|
||||||
`AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification
|
- `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses
|
||||||
(jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`,
|
filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`,
|
||||||
`features/dashboard` la page principale, `shared/components` la jauge de consommation et le
|
`SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur
|
||||||
graphique de charge par site, tous deux construits sur Chart.js.
|
d'authentification (jeton porteur, rafraîchissement sur 401), `core/guards` la garde `authGuard`.
|
||||||
|
- `features/` porte une page par domaine. `shared/components` porte la jauge de consommation et
|
||||||
|
les graphiques Chart.js, le widget `app-alert-feed` (flux d'alertes filtrable par site et
|
||||||
|
sévérité, rafraîchi toutes les 60 s, première vue de l'application avec des états chargement /
|
||||||
|
vide / indisponible) et, dans `shared/models`, des types alignés sur les schémas Pydantic du
|
||||||
|
backend, plus les tables de présentation partagées (`alert-presentation.ts` : ton, libellé et
|
||||||
|
unité par sévérité, type et métrique).
|
||||||
- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation,
|
- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation,
|
||||||
changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail :
|
changement de mot de passe, garde de route sur toute la zone authentifiée. Détail :
|
||||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||||
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
|
- Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`,
|
||||||
`ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit
|
`ev-badge`, `ev-brand`, `ev-icon`, tokens CSS dans `styles/_tokens.scss`, classes globales de
|
||||||
réutiliser plutôt que redéfinir ses propres styles. Détail :
|
formulaire, de navigation et de tableau) que toute nouvelle page doit réutiliser plutôt que
|
||||||
|
redéfinir ses propres styles. Détail :
|
||||||
[32-design-systeme-frontend.md](32-design-systeme-frontend.md).
|
[32-design-systeme-frontend.md](32-design-systeme-frontend.md).
|
||||||
- L'état vit dans des signaux, sans bibliothèque dédiée.
|
- L'état vit dans des signaux, sans bibliothèque dédiée.
|
||||||
|
- TypeScript en `"strict": true` ; `strictTemplates` n'est pas encore activé.
|
||||||
- Vitest via le builder `@angular/build:unit-test`, couverture activée.
|
- Vitest via le builder `@angular/build:unit-test`, couverture activée.
|
||||||
- Prettier configuré, parser `angular` pour les gabarits HTML.
|
- Prettier configuré, parser `angular` pour les gabarits HTML.
|
||||||
|
|
||||||
Ce qui n'existe pas encore :
|
Ce qui n'existe pas encore :
|
||||||
|
|
||||||
- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
|
- **Le mode fixtures est inactif.** `useMockFixtures` vaut `false` dans `environment.ts` comme dans
|
||||||
sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien
|
`environment.development.ts` : `mockApiInterceptor` ne sert `/stats/summary` et `/alerts` que
|
||||||
ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement.
|
dans son propre spec. En développement, toutes les pages exigent un backend joignable et un jeton
|
||||||
`GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette
|
valide.
|
||||||
PR (voir plus bas).
|
- Un état de chargement généralisé : seul `app-alert-feed` en a un, les autres pages restent vides
|
||||||
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
|
tant que la première réponse n'est pas arrivée.
|
||||||
- Aucun lint : ESLint n'est pas installé.
|
- Aucun lint : ESLint n'est pas installé.
|
||||||
|
|
||||||
## Arborescence
|
## Arborescence
|
||||||
@@ -87,11 +95,11 @@ sequenceDiagram
|
|||||||
```
|
```
|
||||||
|
|
||||||
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
|
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
|
||||||
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
|
`environment.useMockFixtures` est vrai. Le drapeau vaut `false` dans les deux fichiers
|
||||||
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
|
d'environnement : en pratique toutes les requêtes suivent le chemin réel et l'intercepteur n'est
|
||||||
`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il
|
exercé que par son spec. `/predictions` et `/auth/*` ne sont de toute façon jamais mockés. En
|
||||||
suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide
|
développement, un jeton valide et un backend joignable sont donc nécessaires pour que le tableau de
|
||||||
et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche.
|
bord s'affiche.
|
||||||
|
|
||||||
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
|
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
|
||||||
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
|
||||||
@@ -146,6 +154,29 @@ Compose.
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -20,16 +20,18 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
|
|||||||
| `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) |
|
| `--color-success` / `-bg`, `--color-warning` / `-bg` / `-text`, `--color-danger` / `-hover` / `-bg` / `-border`, `--color-critical` | États sémantiques (alertes, badges) |
|
||||||
| `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) |
|
| `--color-text-inverse` | Texte sur fond coloré plein (boutons/badges) |
|
||||||
| `--font-family` | Police unique de l'application |
|
| `--font-family` | Police unique de l'application |
|
||||||
|
| `--font-size-xs` à `--font-size-2xl` | Échelle typographique (0.75rem à 2.25rem) : libellés, corps, titres, grands nombres |
|
||||||
| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) |
|
| `--radius-sm`, `--radius-md`, `--radius-pill` | Rayons de bordure (input/bouton, carte, pastille) |
|
||||||
| `--shadow-card` | Ombre portée des cartes |
|
| `--shadow-card`, `--shadow-card-hover` | Ombre portée des cartes, au repos et au survol |
|
||||||
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
|
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
|
||||||
|
|
||||||
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-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`,
|
||||||
@@ -83,6 +85,23 @@ dans le tableau `imports` du composant qui l'utilise.
|
|||||||
```html
|
```html
|
||||||
<ev-brand />
|
<ev-brand />
|
||||||
```
|
```
|
||||||
|
- **`<ev-icon>`** (`icon/`) : `name` (obligatoire : `spike` / `threshold` / `anomaly` / `outage` /
|
||||||
|
`sensor`, les types d'alerte du contrat) et `label` (facultatif). SVG inline en trait sur
|
||||||
|
`currentColor`, dimensionné par `font-size` comme `ev-brand`. Sans `label` l'icône est décorative
|
||||||
|
(`aria-hidden`) ; avec, elle porte `role="img"` et `aria-label`. Pas de bibliothèque d'icônes : la
|
||||||
|
CSP du reverse proxy (`script-src 'self'`) interdit les scripts tiers, pas le SVG inline.
|
||||||
|
```html
|
||||||
|
<ev-icon name="spike" label="Pic de consommation" />
|
||||||
|
```
|
||||||
|
- **`<app-alert-feed>`** (`shared/components/alert-feed/`) : widget métier plutôt qu'atome du kit,
|
||||||
|
mais réutilisable tel quel. Input `siteId` (facultatif : fige le site et masque son filtre). Il
|
||||||
|
porte ses filtres (`.form-select`), ses états et son rafraîchissement ; le parent ne fait que le
|
||||||
|
poser dans une section.
|
||||||
|
|
||||||
|
Les classes de tableau partagées sont dans `apps/frontend/src/styles/_tables.scss`, importées
|
||||||
|
globalement : `.ev-table-card` sur la `<ev-card>` qui enveloppe un tableau (padding nul),
|
||||||
|
`.ev-table` sur le `<table>`, `.ev-table__number` pour une cellule numérique en chiffres
|
||||||
|
tabulaires, `.ev-table__muted` pour une cellule sans valeur.
|
||||||
|
|
||||||
## Logo
|
## Logo
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# 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 | Déployer chaque branche d'intégration sur son environnement de la VM ENI | `En cours` |
|
||||||
|
|
||||||
|
Le **D** de CI/CD est écrit depuis le 21/09 : `deploy.yml` déploie `dev` en recette et `main` en
|
||||||
|
production sur la VM de l'école, par un runner auto-hébergé (issue #21,
|
||||||
|
[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)). Il n'a encore rien
|
||||||
|
déployé : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Statut à
|
||||||
|
basculer sur `Fait` au premier déploiement vert. Sa limite, nommée ici plutôt que découverte en
|
||||||
|
soutenance : les images sont construites sur la machine à chaque déploiement, aucun artefact
|
||||||
|
n'est publié puis promu d'un environnement à l'autre.
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
subgraph cd["Déploiement · deploy.yml"]
|
||||||
|
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
|
||||||
|
end
|
||||||
|
|
||||||
|
push -->|"push sur dev ou main"| dep
|
||||||
|
```
|
||||||
|
|
||||||
|
## Déclenchement
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
`deploy.yml` est le sixième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
|
||||||
|
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
|
||||||
|
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
|
||||||
|
entrant n'est ouvert.
|
||||||
|
|
||||||
|
| Événement | Environnement GitHub | Dossier sur la VM | Garde |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `push` sur `dev` | `rec` | `/srv/enervision/rec` | aucune : la recette suit `dev` |
|
||||||
|
| `push` sur `main` | `prod` | `/srv/enervision/prod` | approbation d'un relecteur dans l'environnement `prod`, branche `main` seule autorisée |
|
||||||
|
|
||||||
|
Le job aligne le clone sur la branche (`fetch`, `checkout`, `reset --hard`), lance
|
||||||
|
`make stack-up`, qui reconstruit les images, redémarre les conteneurs puis applique les
|
||||||
|
migrations Alembic dans le conteneur backend, et attend jusqu'à trois minutes que
|
||||||
|
`/api/v1/health/ready` réponde derrière le proxy. Cette sonde ne vérifie que la connexion à la
|
||||||
|
base et la présence de TimescaleDB : sans la migration, le déploiement serait vert sur une base
|
||||||
|
sans schéma, et c'est pourquoi `make stack-up` la porte. Un groupe de concurrence par branche,
|
||||||
|
sans annulation, empêche deux déploiements simultanés du même environnement.
|
||||||
|
|
||||||
|
Le job ne fait pas de `actions/checkout` dans son espace de travail, et c'est voulu : le dossier
|
||||||
|
de l'environnement est stable, hors du runner, parce que `.env`, certificats et volumes doivent
|
||||||
|
survivre d'un déploiement à l'autre.
|
||||||
|
|
||||||
|
**Piège à connaître.** Un runner auto-hébergé sur un dépôt public exécute ce qu'un workflow lui
|
||||||
|
envoie, et une PR de fork peut réécrire un workflow. Trois parades, et les trois sont
|
||||||
|
nécessaires : `deploy.yml` ne se déclenche jamais sur `pull_request` ; le runner tourne sous un
|
||||||
|
utilisateur dédié membre du groupe `docker`, jamais root ; le dépôt doit exiger une approbation
|
||||||
|
pour les workflows des PR externes (Settings, Actions, « Require approval for all outside
|
||||||
|
collaborators »), ce qui reste à activer. Les workflows de CI restent sur `ubuntu-latest`.
|
||||||
|
|
||||||
|
Cet utilisateur dédié doit posséder `/srv/enervision` : sinon git refuse les deux clones pour
|
||||||
|
propriété douteuse et le `.env` en `600` lui échappe. `PROPRIETAIRE=<utilisateur du runner>`
|
||||||
|
passé à `scripts/provision-host.sh` fixe ce propriétaire.
|
||||||
|
|
||||||
|
La machine se prépare avec `scripts/provision-host.sh`, qui vérifie Docker et Compose 2.24.4 ou
|
||||||
|
plus, clone les deux branches, génère les secrets de chaque `.env` et les certificats
|
||||||
|
auto-signés, et ne démarre rien. Le détail des deux environnements, ports et noms d'hôte, est
|
||||||
|
dans [10-infra.md](10-infra.md).
|
||||||
|
|
||||||
|
## Ce qui bloque un merge
|
||||||
|
|
||||||
|
| 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é côté GitHub : **`SONAR_TOKEN`**, porté par les secrets du dépôt.
|
||||||
|
Les identifiants de la base du job d'intégration sont des valeurs de test en clair dans le
|
||||||
|
workflow, ce qui est volontaire : elles ne protègent rien, la base est créée et détruite avec le
|
||||||
|
run.
|
||||||
|
|
||||||
|
Le déploiement ne consomme **aucun secret GitHub** (issue #22). Les secrets de chaque
|
||||||
|
environnement, mots de passe PostgreSQL et Airflow, clés de signature, clé Fernet, vivent dans le
|
||||||
|
`.env` de son dossier sur la VM, en `600`, générés sur la machine par `scripts/provision-host.sh`.
|
||||||
|
Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce dossier, n'a rien
|
||||||
|
à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se
|
||||||
|
régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow.
|
||||||
|
|
||||||
|
## Ce qui manque, et pourquoi
|
||||||
|
|
||||||
|
| Manque | Issue | Conséquence assumée |
|
||||||
|
|---|---|---|
|
||||||
|
| Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit |
|
||||||
|
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
|
||||||
|
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
|
||||||
|
| Tests de charge | #47 | Aucun garde-fou de performance |
|
||||||
|
| 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,12 @@ 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/` ne contient que des `.gitkeep`. Elles en sortiront le jour où
|
qu'une section de plus dans une autre vue devienne illisible. L'observabilité, elle, n'en a
|
||||||
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
|
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).
|
leurs contraintes sont décrits dans [10-infra.md](10-infra.md).
|
||||||
|
|||||||
+10
-2
@@ -663,8 +663,16 @@ mock_api_import.py
|
|||||||
|
|
||||||
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP.
|
||||||
|
|
||||||
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline ML (`ml_train`/`ml_score`, issue #115) ainsi que la détection d'alertes et la génération des recommandations (`alertes`, issue #116). Il n'orchestre pas encore ces deux imports : `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) restent à faire.
|
Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline
|
||||||
|
ML (`ml_train`/`ml_score`, issue #115), la détection d'alertes et la génération des
|
||||||
|
recommandations (`alertes`, issue #116), ainsi que l'import historique
|
||||||
|
(`historical_import`, issue #119).
|
||||||
|
|
||||||
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
|
Le DAG `historical_import` est déclenché manuellement. Il exécute
|
||||||
|
`app.etl.historical_import` avec les fichiers montés en lecture seule depuis `data/raw` vers
|
||||||
|
`/opt/data/raw`. L'orchestration de l'import API Mock et la réconciliation globale des deux
|
||||||
|
sources restent couvertes par l'issue #15.
|
||||||
|
|
||||||
|
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` et `historical_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui).
|
||||||
|
|
||||||
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""DAG d'import du dataset historique EnerVision (issue #119).
|
||||||
|
|
||||||
|
Orchestre le pipeline existant `app.etl.historical_import` sans dupliquer sa logique ETL.
|
||||||
|
Le dataset historique sert à initialiser l'environnement : le DAG reste donc manuel.
|
||||||
|
|
||||||
|
Le backend est exécuté dans l'environnement `/opt/backend` embarqué dans l'image Airflow,
|
||||||
|
sur le même patron que le DAG `alertes` (ADR 0008).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from airflow.models.dag import DAG
|
||||||
|
from airflow.operators.bash import BashOperator
|
||||||
|
|
||||||
|
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
||||||
|
|
||||||
|
CSV_PATH = "/opt/data/raw/all_sites_combined.csv"
|
||||||
|
METADATA_PATH = "/opt/data/raw/dataset_metadata.json"
|
||||||
|
SOURCE_TIMEZONE = "UTC"
|
||||||
|
BATCH_SIZE = 1000
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
dag_id="historical_import",
|
||||||
|
description="Importe le dataset historique CSV/JSON dans dataset, site et reading.",
|
||||||
|
schedule=None,
|
||||||
|
start_date=datetime(2026, 1, 1),
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=["etl", "historical"],
|
||||||
|
) as dag:
|
||||||
|
BashOperator(
|
||||||
|
task_id="import_historical",
|
||||||
|
bash_command=(
|
||||||
|
f"{COMMANDE_BACKEND} app.etl.historical_import "
|
||||||
|
f"--csv {CSV_PATH} "
|
||||||
|
f"--metadata {METADATA_PATH} "
|
||||||
|
"--source-timezone UTC "
|
||||||
|
"--batch-size 1000"
|
||||||
|
),
|
||||||
|
retries=1,
|
||||||
|
retry_delay=timedelta(minutes=2),
|
||||||
|
execution_timeout=timedelta(minutes=30),
|
||||||
|
)
|
||||||
@@ -10,12 +10,13 @@ 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"]
|
DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import"]
|
||||||
TACHES = [
|
TACHES = [
|
||||||
("ml_train", "train"),
|
("ml_train", "train"),
|
||||||
("ml_score", "score"),
|
("ml_score", "score"),
|
||||||
("alertes", "detection"),
|
("alertes", "detection"),
|
||||||
("alertes", "recommandations"),
|
("alertes", "recommandations"),
|
||||||
|
("historical_import", "import_historical"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +48,10 @@ def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None:
|
|||||||
assert dagbag.dags["alertes"].timetable.summary == "15 * * * *"
|
assert dagbag.dags["alertes"].timetable.summary == "15 * * * *"
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_import_has_no_schedule(dagbag: DagBag) -> None:
|
||||||
|
assert dagbag.dags["historical_import"].timetable.summary == "None"
|
||||||
|
|
||||||
|
|
||||||
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
|
def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None:
|
||||||
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
|
||||||
@@ -67,12 +72,29 @@ def test_alertes_recommendation_task_calls_the_backend_cli(dagbag: DagBag) -> No
|
|||||||
assert "app.cli generate-recommendations" in tache.bash_command
|
assert "app.cli generate-recommendations" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_import_calls_the_existing_backend_module(dagbag: DagBag) -> None:
|
||||||
|
tache = dagbag.dags["historical_import"].get_task("import_historical")
|
||||||
|
assert "app.etl.historical_import" in tache.bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> None:
|
||||||
|
commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command
|
||||||
|
|
||||||
|
assert "--csv /opt/data/raw/all_sites_combined.csv" in commande
|
||||||
|
assert "--metadata /opt/data/raw/dataset_metadata.json" in commande
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
||||||
def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None:
|
def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None:
|
||||||
# Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008).
|
# Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008).
|
||||||
assert "/opt/backend" in dagbag.dags["alertes"].get_task(task_id).bash_command
|
assert "/opt/backend" in dagbag.dags["alertes"].get_task(task_id).bash_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_import_runs_in_the_backend_environment(dagbag: DagBag) -> None:
|
||||||
|
commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command
|
||||||
|
assert "/opt/backend" in commande
|
||||||
|
|
||||||
|
|
||||||
def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None:
|
def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None:
|
||||||
# `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire
|
# `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire
|
||||||
# tant que la detection n'a pas ecrit.
|
# tant que la detection n'a pas ecrit.
|
||||||
@@ -133,6 +155,10 @@ def test_alertes_retries_after_a_transient_failure(dagbag: DagBag, task_id: str)
|
|||||||
assert dagbag.dags["alertes"].get_task(task_id).retries >= 1
|
assert dagbag.dags["alertes"].get_task(task_id).retries >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) -> None:
|
||||||
|
assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
|
@pytest.mark.parametrize(("dag_id", "task_id"), TACHES)
|
||||||
def test_tasks_never_resync_the_baked_environment(
|
def test_tasks_never_resync_the_baked_environment(
|
||||||
dagbag: DagBag, dag_id: str, task_id: str
|
dagbag: DagBag, dag_id: str, task_id: str
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ configuration est montée en volume par `docker-compose.prod.yml`.
|
|||||||
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
|
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
|
||||||
ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
|
ou plus récent**. Sur une version antérieure, la fusion échoue au lieu de dépublier les ports.
|
||||||
|
|
||||||
|
Les ports publiés sont `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`, 80 et 443 par défaut. Quand deux
|
||||||
|
environnements partagent la machine ([ADR 0009](../../docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md)),
|
||||||
|
la recette publie `8443` et ramène son port 80 sur `127.0.0.1:8081` : la redirection ci-dessous
|
||||||
|
renvoie vers `https://$host` sans port, donc vers la production. `PUBLIC_ORIGIN` porte alors
|
||||||
|
l'origine avec son port pour le CORS et le lien de réinitialisation.
|
||||||
|
|
||||||
## Routage
|
## Routage
|
||||||
|
|
||||||
| Chemin | Destination | Remarque |
|
| Chemin | Destination | Remarque |
|
||||||
|
|||||||
+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
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pourquoi : la machine porte deux environnements, chacun un clone du dépôt, un `.env` et un
|
||||||
|
# projet Compose (ADR 0009). Ce script prépare la machine et les deux dossiers sans rien
|
||||||
|
# démarrer : construction des images et démarrage restent à l'opérateur, puis au runner GitHub.
|
||||||
|
# Rejouable : un dossier déjà cloné est réaligné sur sa branche, un `.env` existant n'est jamais
|
||||||
|
# réécrit, un certificat présent n'est jamais régénéré.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}"
|
||||||
|
RACINE="${RACINE:-/srv/enervision}"
|
||||||
|
ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}"
|
||||||
|
PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}"
|
||||||
|
COMPOSE_MINIMALE="2.24.4"
|
||||||
|
|
||||||
|
erreur() { echo "erreur : $*" >&2; exit 1; }
|
||||||
|
secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; }
|
||||||
|
# Clé Fernet : 32 octets en base64 urlsafe, padding compris.
|
||||||
|
fernet() { openssl rand -base64 32 | tr '+/' '-_'; }
|
||||||
|
|
||||||
|
verifier_outils() {
|
||||||
|
for outil in git make openssl curl; do
|
||||||
|
command -v "$outil" >/dev/null || erreur "$outil absent (apt-get install $outil)"
|
||||||
|
done
|
||||||
|
command -v docker >/dev/null || erreur "Docker absent : https://docs.docker.com/engine/install/debian/"
|
||||||
|
docker info >/dev/null 2>&1 || erreur "le démon Docker ne répond pas, ou l'utilisateur n'est pas dans le groupe docker"
|
||||||
|
local version
|
||||||
|
version="$(docker compose version --short 2>/dev/null || true)"
|
||||||
|
[[ -n "$version" ]] || erreur "plugin docker compose absent (paquet docker-compose-plugin)"
|
||||||
|
[[ "$(printf '%s\n%s\n' "$COMPOSE_MINIMALE" "${version#v}" | sort -V | head -1)" == "$COMPOSE_MINIMALE" ]] \
|
||||||
|
|| erreur "docker compose $version trop ancien : $COMPOSE_MINIMALE requis pour !override et !reset"
|
||||||
|
curl -fsSI --max-time 10 https://github.com >/dev/null || erreur "pas de sortie HTTPS vers github.com"
|
||||||
|
echo "docker compose $version, sortie Internet : ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
preparer() {
|
||||||
|
local env="$1" branche="$2" hote="$3" origine="$4"
|
||||||
|
local port_https="$5" port_http="$6" port_pg="$7" port_mailpit="$8" port_airflow="$9"
|
||||||
|
local dossier="$RACINE/$env"
|
||||||
|
|
||||||
|
if [[ -d "$dossier/.git" ]]; then
|
||||||
|
git -C "$dossier" fetch --quiet origin "$branche"
|
||||||
|
git -C "$dossier" checkout --quiet "$branche"
|
||||||
|
git -C "$dossier" reset --quiet --hard "origin/$branche"
|
||||||
|
else
|
||||||
|
git clone --quiet --branch "$branche" "$DEPOT" "$dossier"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$dossier/.env" ]]; then
|
||||||
|
sed -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|" \
|
||||||
|
-e "s|^POSTGRES_PORT=.*|POSTGRES_PORT=$port_pg|" \
|
||||||
|
-e "s|^APP_SECRET_KEY=.*|APP_SECRET_KEY=$(secret)|" \
|
||||||
|
-e "s|^MAILPIT_UI_PORT=.*|MAILPIT_UI_PORT=$port_mailpit|" \
|
||||||
|
-e "s|^AIRFLOW_PORT=.*|AIRFLOW_PORT=$port_airflow|" \
|
||||||
|
-e "s|^AIRFLOW_FERNET_KEY=.*|AIRFLOW_FERNET_KEY=$(fernet)|" \
|
||||||
|
-e "s|^AIRFLOW_WEBSERVER_SECRET_KEY=.*|AIRFLOW_WEBSERVER_SECRET_KEY=$(secret)|" \
|
||||||
|
-e "s|^AIRFLOW_ADMIN_PASSWORD=.*|AIRFLOW_ADMIN_PASSWORD=$(secret | cut -c1-20)|" \
|
||||||
|
-e "s|^AIRFLOW_APP_SECRET_KEY=.*|AIRFLOW_APP_SECRET_KEY=$(secret)|" \
|
||||||
|
-e "s|^PUBLIC_HOST=.*|PUBLIC_HOST=$hote|" \
|
||||||
|
-e "s|^PUBLIC_ORIGIN=.*|PUBLIC_ORIGIN=$origine|" \
|
||||||
|
-e "s|^COMPOSE_PROJECT_NAME=.*|COMPOSE_PROJECT_NAME=enervision-$env|" \
|
||||||
|
-e "s|^PROXY_HTTP_PORT=.*|PROXY_HTTP_PORT=$port_http|" \
|
||||||
|
-e "s|^PROXY_HTTPS_PORT=.*|PROXY_HTTPS_PORT=$port_https|" \
|
||||||
|
"$dossier/.env.example" > "$dossier/.env"
|
||||||
|
# Branche antérieure à l'ADR 0009 : ces clés manquent alors dans .env.example.
|
||||||
|
for cle in "COMPOSE_PROJECT_NAME=enervision-$env" "PUBLIC_ORIGIN=$origine" \
|
||||||
|
"PROXY_HTTP_PORT=$port_http" "PROXY_HTTPS_PORT=$port_https"; do
|
||||||
|
grep -q "^${cle%%=*}=" "$dossier/.env" || echo "$cle" >> "$dossier/.env"
|
||||||
|
done
|
||||||
|
chmod 600 "$dossier/.env"
|
||||||
|
echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$dossier/infra/proxy/tls/fullchain.pem" ]]; then
|
||||||
|
(cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh)
|
||||||
|
fi
|
||||||
|
echo "$env : $dossier sur $branche, $origine"
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier_outils
|
||||||
|
mkdir -p "$RACINE"
|
||||||
|
|
||||||
|
# env branche hôte origine https http pg mailpit airflow
|
||||||
|
preparer prod main enervision.local https://enervision.local 443 80 5433 8025 8080
|
||||||
|
preparer rec dev rec.enervision.local https://rec.enervision.local:8443 8443 127.0.0.1:8081 5434 8026 8082
|
||||||
|
|
||||||
|
if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then
|
||||||
|
chown -R "$PROPRIETAIRE" "$RACINE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<FIN
|
||||||
|
|
||||||
|
Démarrage, dans chaque dossier : make stack-up, qui applique aussi les migrations.
|
||||||
|
Premier administrateur, stack démarrée, dans chaque dossier :
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \\
|
||||||
|
python -m app.cli create-admin --email <adresse>
|
||||||
|
Le runner GitHub Actions (label eni-g3) rejouera le déploiement à chaque push sur dev et main.
|
||||||
|
L'installer sous le propriétaire de $RACINE, sinon git refuse ces dépôts et le .env en 600 lui
|
||||||
|
échappe : relancer au besoin ce script avec PROPRIETAIRE=<utilisateur du runner>.
|
||||||
|
Données historiques : git ne porte pas data/raw, déposer les fichiers dans chaque dossier avant
|
||||||
|
de déclencher le DAG historical_import.
|
||||||
|
Depuis un poste : ajouter « $ADRESSE enervision.local rec.enervision.local » à /etc/hosts.
|
||||||
|
FIN
|
||||||
@@ -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