Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb8b5a784 | ||
|
|
c1f9dec5fe | ||
|
|
be6be42947 | ||
|
|
b5993f994e | ||
|
|
091ee344d1 | ||
|
|
f81959c1a9 | ||
|
|
5c361c6952 | ||
|
|
a27e0c6535 | ||
|
|
7a97a4d1ac | ||
|
|
20abcfee33 | ||
|
|
3d6d2eb760 | ||
|
|
154deca511 | ||
|
|
a577356198 | ||
|
|
19ff152445 | ||
|
|
bf3584182f | ||
|
|
28e8260216 | ||
|
|
e90e563a1c | ||
|
|
d7ca9fd8b0 | ||
|
|
67204b5eb6 | ||
|
|
985466e9cc | ||
|
|
c347212c93 | ||
|
|
c67c7802c1 | ||
|
|
e824423889 | ||
|
|
436147c8cb | ||
|
|
8fb7ab8680 | ||
|
|
65baf3fddb | ||
|
|
19f4ddc62f | ||
|
|
282472bae9 | ||
|
|
8d80cdc79d | ||
|
|
b61287c8d2 | ||
|
|
a4be46c6e6 | ||
|
|
66a65991b5 | ||
|
|
095cb4559b | ||
|
|
d48b0eef4d | ||
|
|
c8e8a44390 | ||
|
|
401d5a3174 | ||
|
|
6a70443d52 | ||
|
|
ebf6476fb6 | ||
|
|
d2ca2644d2 | ||
|
|
91a1bfda33 | ||
|
|
fe100653d0 | ||
|
|
bccf7ed774 | ||
|
|
1502eea036 | ||
|
|
bf2b66e4ad | ||
|
|
8ea388ce9a | ||
|
|
b4da0bbefc | ||
|
|
25e0bb4ce1 | ||
|
|
ce3d1d1992 | ||
|
|
cb23895026 | ||
|
|
2c86e00629 | ||
|
|
cf50d3d36c | ||
|
|
7e504bee72 | ||
|
|
32155cbfc5 | ||
|
|
cb4df846cb | ||
|
|
d16de84559 | ||
|
|
ae4082c584 | ||
|
|
f18d4f9ef9 | ||
|
|
308769b325 | ||
|
|
90639b5618 | ||
|
|
e147ea69bc | ||
|
|
f0ff953a5e | ||
|
|
7fd8d1ce30 | ||
|
|
6785d06a9a | ||
|
|
c2bd1317ed | ||
|
|
d276070ee2 | ||
|
|
e10ab44dc7 | ||
|
|
32dd0587e9 | ||
|
|
4afcfb3ced | ||
|
|
83caa9006e | ||
|
|
91752a2b0b | ||
|
|
79b157d37e | ||
|
|
851e0279f6 | ||
|
|
7f025f72ac | ||
|
|
1cd206adc8 | ||
|
|
3c378c177f | ||
|
|
2adfdf0eb0 | ||
|
|
44f3416ffe | ||
|
|
342128ccff | ||
|
|
62d81e901d | ||
|
|
31886944ab | ||
|
|
63cbeafe3b | ||
|
|
f8d08c8686 | ||
|
|
be44b97d7a | ||
|
|
306c5a52e5 | ||
|
|
801379f956 | ||
|
|
2686880185 | ||
|
|
ae58a896d9 | ||
|
|
5a29faaa16 | ||
|
|
bc75528616 | ||
|
|
d7457e9fd9 | ||
|
|
3cd9a6b272 | ||
|
|
26f834485c | ||
|
|
777cd0ac64 | ||
|
|
c528ed239b | ||
|
|
a88e51c92a | ||
|
|
f3ea2785b3 | ||
|
|
901ceffd72 | ||
|
|
5545c166fd | ||
|
|
f0ad8e9990 | ||
|
|
3c01ab3ecc | ||
|
|
6f6f451eb4 | ||
|
|
cc3e38efa3 | ||
|
|
0a2ed5ad8f | ||
|
|
459ddf1792 | ||
|
|
de697b080d | ||
|
|
f238940867 | ||
|
|
b941880c22 | ||
|
|
9d2384a639 | ||
|
|
0c487fa7be | ||
|
|
b3efb98208 | ||
|
|
2d7b4bd74d | ||
|
|
56c6b79a5a | ||
|
|
452cfdef85 | ||
|
|
96dd1f834c | ||
|
|
e66ef86729 | ||
|
|
0318ee6cc5 | ||
|
|
0ddfb1997d | ||
|
|
f9c2a4610c | ||
|
|
b5fa7b0010 | ||
|
|
7f710c9084 | ||
|
|
c3fd9327ea |
@@ -17,3 +17,60 @@ APP_LOG_LEVEL=INFO
|
||||
APP_SECRET_KEY=change_me
|
||||
APP_CORS_ORIGINS=http://localhost:4200
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# Mailpit capture les courriels du backend, rien ne sort vers l'extérieur.
|
||||
MAILPIT_SMTP_PORT=1025
|
||||
MAILPIT_UI_PORT=8025
|
||||
|
||||
# API Mock EnerVision
|
||||
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
|
||||
APP_MOCK_API_USERNAME=change_me
|
||||
APP_MOCK_API_PASSWORD=change_me
|
||||
APP_MOCK_API_TIMEOUT_SECONDS=10
|
||||
|
||||
# Airflow (api-server + scheduler + dag-processor, LocalExecutor). Base de métadonnées dédiée `airflow` dans le
|
||||
# même conteneur `db` (cf. db/init/120-airflow-database.sql), pas un conteneur de plus.
|
||||
AIRFLOW_PORT=8080
|
||||
# Chiffre les connexions/variables stockées par Airflow. Générer la vôtre :
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
AIRFLOW_FERNET_KEY=change_me
|
||||
# Clé de session de l'api-server Airflow, distincte de la précédente. Générer la
|
||||
# vôtre : python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
AIRFLOW_API_SECRET_KEY=change_me
|
||||
# Secret des jetons JWT entre scheduler, tâches et api-server (conteneurs distincts, le secret
|
||||
# doit être partagé). Même générateur que ci-dessus.
|
||||
AIRFLOW_JWT_SECRET=change_me
|
||||
AIRFLOW_ADMIN_USERNAME=admin
|
||||
# Compte Airflow créé au premier démarrage (service `airflow-init`), sans rapport avec les
|
||||
# comptes `app_user` d'EnerVision.
|
||||
AIRFLOW_ADMIN_PASSWORD=change_me
|
||||
AIRFLOW_ADMIN_EMAIL=admin@enervision.fr
|
||||
# `APP_SECRET_KEY` du backend, que le DAG `alertes` lance en sous-processus. Distincte de
|
||||
# celle de l'API : la détection ne signe aucun jeton, et Airflow exécute du code depuis son
|
||||
# interface (cf. ADR 0008). Générer la vôtre :
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
AIRFLOW_APP_SECRET_KEY=change_me
|
||||
|
||||
# Stack complète derrière le reverse proxy (docker-compose.prod.yml).
|
||||
# PUBLIC_HOST alimente l'origine CORS, le lien de réinitialisation et le certificat.
|
||||
PUBLIC_HOST=enervision.local
|
||||
ACME_EMAIL=
|
||||
|
||||
# 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. L'api-server Airflow 3 n'a rien à régler ici : son
|
||||
# nombre de workers vaut 1 par défaut, contre 4 pour le webserver d'Airflow 2.
|
||||
TS_TUNE_MEMORY=2GB
|
||||
TS_TUNE_NUM_CPUS=2
|
||||
|
||||
@@ -10,6 +10,15 @@ updates:
|
||||
frontend-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
# Pourquoi : @angular/build épingle typescript (>=6.0 <6.1) et vitest (^4). Une majeure
|
||||
# de l'un ou l'autre casse `npm ci` tant qu'Angular ne suit pas.
|
||||
- dependency-name: "typescript"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "vitest"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "@vitest/coverage-v8"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Backend — uv (lit pyproject.toml / uv.lock)
|
||||
- package-ecosystem: "uv"
|
||||
@@ -38,3 +47,9 @@ updates:
|
||||
directory: "/apps/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Images du reverse proxy et du compagnon ACME, épinglées dans les fichiers Compose
|
||||
- package-ecosystem: "docker-compose"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
name: Airflow
|
||||
|
||||
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
|
||||
# (contrairement à backend.yml et ml.yml) : celui de l'image apache/airflow retenue, et les tests
|
||||
# doivent tourner sur le même interpréteur qu'elle. Le 3.14 de ml/ ne vit que dans l'image
|
||||
# Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
|
||||
#
|
||||
# Piège : l'image COPY les fichiers de dépendances et le code de ml/ et de apps/backend/. Une
|
||||
# modification de l'un ou de l'autre peut donc casser sa construction, d'où ces chemins dans
|
||||
# les déclencheurs, alors même que ce workflow ne teste ni le modèle ni l'API.
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "etl/airflow/**"
|
||||
- "ml/pyproject.toml"
|
||||
- "ml/uv.lock"
|
||||
- "ml/enervision_ml/**"
|
||||
- "apps/backend/pyproject.toml"
|
||||
- "apps/backend/uv.lock"
|
||||
- "apps/backend/app/**"
|
||||
- ".github/workflows/airflow.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "etl/airflow/**"
|
||||
- "ml/pyproject.toml"
|
||||
- "ml/uv.lock"
|
||||
- "ml/enervision_ml/**"
|
||||
- "apps/backend/pyproject.toml"
|
||||
- "apps/backend/uv.lock"
|
||||
- "apps/backend/app/**"
|
||||
- ".github/workflows/airflow.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: airflow-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verification:
|
||||
name: Lint et intégrité des DAGs
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: etl/airflow
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: etl/airflow/uv.lock
|
||||
|
||||
- name: Installe l'interpréteur déclaré par .python-version
|
||||
run: uv python install
|
||||
|
||||
- name: Synchronise les dépendances sans dévier du verrou
|
||||
run: uv sync --all-groups --frozen
|
||||
|
||||
- name: Vérifie le formatage
|
||||
run: uv run ruff format --check .
|
||||
|
||||
- name: Analyse statique
|
||||
run: uv run ruff check --output-format=github .
|
||||
|
||||
# Aucun test ne lance de tâche ni de scheduler : DagBag charge les fichiers de dags/ et
|
||||
# vérifie import, planification, plafonds d'exécution et commande de chaque tâche.
|
||||
- name: Tests d'intégrité des DAGs
|
||||
run: uv run pytest
|
||||
|
||||
image:
|
||||
name: Construction de l'image
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
|
||||
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
|
||||
|
||||
# Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent
|
||||
# (`OSError: libgomp.so.1` au premier import) ou environnement ml/ non figé.
|
||||
- name: Vérifie que le pipeline ML s'importe sans réseau
|
||||
run: >
|
||||
docker run --rm --network none enervision-airflow:ci
|
||||
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
|
||||
|
||||
# `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et
|
||||
# l'import des modules prouve que l'environnement /opt/backend est complet.
|
||||
# Les deux commandes du DAG `alertes` et la commande du DAG historique sont couvertes.
|
||||
- name: Vérifie que les trois commandes backend s'importent sans réseau
|
||||
run: >
|
||||
docker run --rm --network none enervision-airflow:ci
|
||||
bash -c "cd /opt/backend
|
||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.detection.internal_alerts --help
|
||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.cli generate-recommendations --help
|
||||
&& env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help"
|
||||
@@ -30,10 +30,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
@@ -90,10 +90,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
@@ -126,10 +126,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
@@ -140,3 +140,29 @@ jobs:
|
||||
# Piège : sans `shell: bash`, un échec de `uv export` serait masqué par le pipe.
|
||||
shell: bash
|
||||
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@v7
|
||||
|
||||
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
|
||||
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
|
||||
- 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,58 @@
|
||||
# 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}"
|
||||
compose="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
|
||||
$compose ps
|
||||
$compose logs --tail=50 backend proxy
|
||||
exit 1
|
||||
@@ -17,10 +17,10 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
cache: npm
|
||||
cache-dependency-path: apps/frontend/package-lock.json
|
||||
|
||||
@@ -33,10 +33,10 @@ jobs:
|
||||
name: Audit des dépendances
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
|
||||
- run: npm audit --audit-level=high --package-lock-only
|
||||
working-directory: apps/frontend
|
||||
@@ -45,10 +45,10 @@ jobs:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
cache: npm
|
||||
cache-dependency-path: apps/frontend/package-lock.json
|
||||
- name : Installation des dépendances (Front)
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
||||
working-directory: apps/frontend
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: apps/frontend/coverage/frontend/lcov.info
|
||||
|
||||
@@ -30,10 +30,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: ml/uv.lock
|
||||
@@ -57,3 +57,26 @@ jobs:
|
||||
# synthetiques ou un magasin SQLite local jetable (cf. ml/tests/test_train.py).
|
||||
- name: Tests
|
||||
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@v7
|
||||
|
||||
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
|
||||
|
||||
- name: Rapport complet, tous niveaux
|
||||
continue-on-error: true
|
||||
run: uvx bandit==1.9.4 --recursive enervision_ml
|
||||
|
||||
@@ -5,11 +5,15 @@ on:
|
||||
paths:
|
||||
- "apps/frontend/**"
|
||||
- "apps/backend/**"
|
||||
- "ml/**"
|
||||
- "etl/airflow/**"
|
||||
- ".github/workflows/sonarqube.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/frontend/**"
|
||||
- "apps/backend/**"
|
||||
- "ml/**"
|
||||
- "etl/airflow/**"
|
||||
- ".github/workflows/sonarqube.yml"
|
||||
|
||||
|
||||
@@ -20,10 +24,10 @@ jobs:
|
||||
build-front:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
cache: npm
|
||||
cache-dependency-path: apps/frontend/package-lock.json
|
||||
|
||||
@@ -36,10 +40,10 @@ jobs:
|
||||
needs: build-front
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
cache: npm
|
||||
cache-dependency-path: apps/frontend/package-lock.json
|
||||
|
||||
@@ -52,7 +56,7 @@ jobs:
|
||||
working-directory: apps/frontend
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: apps/frontend/coverage/frontend/lcov.info
|
||||
@@ -60,9 +64,9 @@ jobs:
|
||||
build-back:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
@@ -91,9 +95,9 @@ jobs:
|
||||
needs: build-back
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: apps/backend/uv.lock
|
||||
@@ -103,29 +107,65 @@ jobs:
|
||||
working-directory: apps/backend
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: apps/backend/coverage.xml
|
||||
|
||||
sonarqube:
|
||||
needs: [build-front, build-back, test-front, test-back]
|
||||
name: SonarQube
|
||||
test-ml:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
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@v7
|
||||
with:
|
||||
name: ml-coverage
|
||||
path: ml/coverage.xml
|
||||
|
||||
sonarqube:
|
||||
needs: [build-front, build-back, test-front, test-back, test-ml]
|
||||
name: SonarQube
|
||||
# Pourquoi : GitHub ne fournit pas les secrets aux workflows lancés par dependabot[bot].
|
||||
# Sans SONAR_TOKEN le scan échoue sans rien analyser ; build et tests restent joués.
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Téléchargement du rapport de couverture (Front)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: apps/frontend/coverage/frontend
|
||||
- name: Téléchargement du rapport de couverture (Back)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: apps/backend
|
||||
- name: Téléchargement du rapport de couverture (ML)
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ml-coverage
|
||||
path: ml
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@v8
|
||||
env:
|
||||
|
||||
+6
-1
@@ -40,7 +40,6 @@ kubeconfig
|
||||
# Airflow
|
||||
etl/airflow/logs/
|
||||
airflow.db
|
||||
airflow-webserver.pid
|
||||
standalone_admin_password.txt
|
||||
|
||||
# Environnement et secrets
|
||||
@@ -66,6 +65,12 @@ ml/mlruns/
|
||||
ml/mlartifacts/
|
||||
ml/mlflow.db
|
||||
|
||||
# Airflow : base sqlite locale generee par les tests d'integrite des DAGs (etl/airflow/tests)
|
||||
etl/airflow/tests/.airflow_home/
|
||||
|
||||
# TLS : certificats du reverse proxy, générés par script ou par certbot
|
||||
infra/proxy/tls/*.pem
|
||||
|
||||
# IDE et OS
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
BACKEND := apps/backend
|
||||
FRONTEND := apps/frontend
|
||||
ML := ml
|
||||
AIRFLOW := etl/airflow
|
||||
COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml
|
||||
|
||||
# Piège : sans `export`, une valeur passée en ligne de commande n'atteindrait pas docker compose.
|
||||
# PUBLIC_HOST retombe sur le `.env`, que make ne lit pas, puis sur la valeur de `.env.example`.
|
||||
PUBLIC_HOST ?= $(shell sed -n 's/^PUBLIC_HOST=//p' .env 2>/dev/null | tail -1)
|
||||
PUBLIC_HOST := $(or $(strip $(PUBLIC_HOST)),enervision.local)
|
||||
export PUBLIC_HOST
|
||||
ifdef ACME_EMAIL
|
||||
export ACME_EMAIL
|
||||
endif
|
||||
|
||||
# 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
|
||||
.PHONY: help install install-backend install-frontend install-ml dev dev-backend dev-frontend \
|
||||
.PHONY: help install install-backend install-frontend install-ml install-airflow \
|
||||
dev dev-backend dev-frontend \
|
||||
lint format typecheck test test-cov test-integration check \
|
||||
openapi docker-build db-up db-down db-reset db-logs db-psql migrate bootstrap-admin \
|
||||
ml-lint ml-typecheck ml-test ml-check ml-train ml-score recommendations
|
||||
openapi docker-build db-up db-down db-reset db-logs db-psql db-wait db-ensure-airflow \
|
||||
migrate bootstrap-admin services-up demo-data demo-data-force \
|
||||
ml-lint ml-typecheck ml-test ml-check ml-train ml-score detect-alerts recommendations \
|
||||
airflow-lint airflow-test airflow-check airflow-up airflow-down airflow-logs \
|
||||
tls-selfsigned tls-acme tls-renew stack-up stack-down stack-logs
|
||||
|
||||
help: ## Liste les cibles disponibles
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
install: install-backend install-frontend install-ml ## Installe les dépendances backend, frontend et ML
|
||||
install: install-backend install-frontend install-ml install-airflow ## Installe les dépendances backend, frontend, ML et Airflow
|
||||
|
||||
install-backend: ## Installe les dépendances du backend
|
||||
cd $(BACKEND) && uv sync --all-groups
|
||||
@@ -22,12 +53,22 @@ install-frontend: ## Installe les dépendances du frontend
|
||||
install-ml: ## Installe les dépendances du pipeline ML
|
||||
cd $(ML) && uv sync --all-groups
|
||||
|
||||
dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud
|
||||
install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow
|
||||
cd $(AIRFLOW) && uv sync --all-groups
|
||||
|
||||
dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airflow, puis backend et frontend
|
||||
@echo "airflow -> http://localhost:$(AIRFLOW_PORT) mailpit -> http://localhost:$(MAILPIT_UI_PORT)"
|
||||
@trap 'kill 0' EXIT INT TERM; \
|
||||
$(MAKE) --no-print-directory dev-backend & \
|
||||
$(MAKE) --no-print-directory dev-frontend & \
|
||||
wait
|
||||
|
||||
services-up: ## Démarre les services conteneurisés dont `make dev` dépend (base, Mailpit, Airflow)
|
||||
docker compose up -d db mailpit
|
||||
@$(MAKE) --no-print-directory db-wait
|
||||
@$(MAKE) --no-print-directory db-ensure-airflow
|
||||
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
|
||||
|
||||
dev-backend: ## Lance l'API seule en rechargement à chaud
|
||||
@echo "backend -> http://localhost:8000 (docs sur /docs)"
|
||||
cd $(BACKEND) && uv run uvicorn app.main:create_app --factory --reload --host 0.0.0.0 --port 8000
|
||||
@@ -74,15 +115,68 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du
|
||||
ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL
|
||||
cd $(ML) && uv run python -m enervision_ml.train $(if $(CSV),--csv $(CSV),)
|
||||
|
||||
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV=chemin optionnel
|
||||
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),)
|
||||
ml-score: ## Score le prochain pas horaire et l'ecrit dans `prediction`. CSV= et NOW= optionnels
|
||||
cd $(ML) && uv run python -m enervision_ml.score $(if $(CSV),--csv $(CSV),) $(if $(NOW),--now $(NOW),)
|
||||
|
||||
detect-alerts: ## Détecte les alertes internes depuis les lectures en base. SITE= et NOW= optionnels
|
||||
cd $(BACKEND) && uv run python -m app.detection.internal_alerts $(if $(SITE),--site-id $(SITE),) $(if $(NOW),--now $(NOW),)
|
||||
|
||||
recommendations: ## Genere les recommandations depuis les alertes en base. SITE=identifiant optionnel
|
||||
cd $(BACKEND) && uv run python -m app.cli generate-recommendations $(if $(SITE),--site-id $(SITE),)
|
||||
|
||||
airflow-lint: ## Analyse statique des DAGs Airflow
|
||||
cd $(AIRFLOW) && uv run ruff check .
|
||||
|
||||
airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structure attendue
|
||||
cd $(AIRFLOW) && uv run pytest
|
||||
|
||||
airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow
|
||||
|
||||
airflow-up: db-ensure-airflow ## Démarre Airflow (api-server + scheduler + dag-processor, LocalExecutor). db-up requis avant.
|
||||
docker compose up -d airflow-init airflow-apiserver airflow-scheduler airflow-dag-processor
|
||||
@echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}"
|
||||
|
||||
airflow-down: ## Arrête l'api-server, le scheduler et le dag-processor Airflow
|
||||
docker compose stop airflow-apiserver airflow-scheduler airflow-dag-processor
|
||||
|
||||
airflow-logs: ## Suit les journaux du scheduler Airflow (où tournent les tâches, LocalExecutor)
|
||||
docker compose logs -f airflow-scheduler
|
||||
|
||||
docker-build: ## Construit l'image du backend
|
||||
docker build -t enervision-backend:local $(BACKEND)
|
||||
|
||||
tls-selfsigned: ## Génère le certificat de démonstration. PUBLIC_HOST=..., FORCE=1 pour écraser
|
||||
./scripts/tls-selfsigned.sh $(if $(FORCE),--force,)
|
||||
|
||||
# Piège : l'image backend ne migre pas au démarrage, et `/health/ready` ne teste que la connexion
|
||||
# et l'extension. Sans `alembic upgrade head`, la stack démarre verte sur une base sans schéma.
|
||||
stack-up: ## Démarre la stack derrière le reverse proxy, puis migre la base. PUBLIC_HOST=... au besoin
|
||||
@test -f infra/proxy/tls/fullchain.pem \
|
||||
|| { echo "Aucun certificat dans infra/proxy/tls. Lancer d'abord make tls-selfsigned"; exit 1; }
|
||||
@openssl x509 -in infra/proxy/tls/fullchain.pem -noout -checkhost "$(PUBLIC_HOST)" >/dev/null \
|
||||
|| { echo "Le certificat ne couvre pas $(PUBLIC_HOST). Relancer make tls-selfsigned PUBLIC_HOST=$(PUBLIC_HOST) FORCE=1"; exit 1; }
|
||||
$(COMPOSE_PROD) up -d --build
|
||||
$(COMPOSE_PROD) exec -T backend alembic upgrade head
|
||||
|
||||
stack-down: ## Arrête la stack complète en conservant les données
|
||||
$(COMPOSE_PROD) stop
|
||||
|
||||
stack-logs: ## Suit les journaux du reverse proxy
|
||||
$(COMPOSE_PROD) logs -f proxy
|
||||
|
||||
tls-acme: ## Demande un certificat Let's Encrypt. PUBLIC_HOST public et ACME_EMAIL requis
|
||||
@test "$(PUBLIC_HOST)" != enervision.local \
|
||||
|| { echo "PUBLIC_HOST doit être un domaine public résolvable, pas le nom de démonstration"; exit 1; }
|
||||
$(COMPOSE_PROD) --profile acme run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||
-d $(PUBLIC_HOST) \
|
||||
--email $${ACME_EMAIL:?ACME_EMAIL=... requis} \
|
||||
--agree-tos --no-eff-email --deploy-hook /deploy-hook.sh
|
||||
$(COMPOSE_PROD) exec proxy nginx -s reload
|
||||
|
||||
tls-renew: ## Renouvelle les certificats Let's Encrypt et recharge le proxy
|
||||
$(COMPOSE_PROD) --profile acme run --rm certbot renew --deploy-hook /deploy-hook.sh
|
||||
$(COMPOSE_PROD) exec proxy nginx -s reload
|
||||
|
||||
db-up: ## Démarre la base PostgreSQL TimescaleDB
|
||||
docker compose up -d db
|
||||
|
||||
@@ -98,8 +192,36 @@ db-logs: ## Suit les journaux de la base
|
||||
db-psql: ## Ouvre une session psql sur la base applicative
|
||||
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision}
|
||||
|
||||
db-wait: ## Attend que la base accepte les connexions
|
||||
@for _ in $$(seq 1 60); do \
|
||||
docker compose exec -T db pg_isready -U $(PG_USER) -d $(PG_DB) >/dev/null 2>&1 && exit 0; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "La base n'accepte toujours pas de connexion apres 60s"; exit 1
|
||||
|
||||
# Piege : db/init ne rejoue qu'a la premiere initialisation du volume. Un `pgdata` cree avant
|
||||
# db/init/120-airflow-database.sql n'a pas de base `airflow`, et airflow-init boucle dessus.
|
||||
db-ensure-airflow: ## Crée la base de métadonnées Airflow si le volume pgdata est antérieur à db/init/120
|
||||
@docker compose exec -T db psql -U $(PG_USER) -d postgres -tAc \
|
||||
"SELECT 1 FROM pg_database WHERE datname = 'airflow'" | grep -q 1 \
|
||||
|| docker compose exec -T db psql -U $(PG_USER) -d postgres -c "CREATE DATABASE airflow"
|
||||
|
||||
migrate: ## Applique les migrations Alembic
|
||||
cd $(BACKEND) && uv run alembic upgrade head
|
||||
|
||||
bootstrap-admin: ## Crée le premier administrateur, mot de passe saisi au clavier
|
||||
cd $(BACKEND) && uv run python -m app.cli create-admin --email $${EMAIL:?EMAIL=... requis}
|
||||
|
||||
demo-data: ## Renseigne prédictions, alertes et recommandations si elles manquent. NOW= optionnel
|
||||
@nombre=$$(docker compose exec -T db psql -U $(PG_USER) -d $(PG_DB) -tAc 'SELECT count(*) FROM alert') \
|
||||
|| { echo "demo-data : base injoignable ou migrations non appliquees"; exit 1; }; \
|
||||
if [ "$$nombre" = 0 ]; then \
|
||||
$(MAKE) --no-print-directory demo-data-force; \
|
||||
else \
|
||||
echo "demo-data : $$nombre alerte(s) deja en base (make demo-data-force pour rejouer)"; \
|
||||
fi
|
||||
|
||||
demo-data-force: ## Rejoue le peuplement sans regarder l'existant. Les trois etapes sont idempotentes
|
||||
$(MAKE) --no-print-directory ml-score NOW=$(DEMO_NOW)
|
||||
$(MAKE) --no-print-directory detect-alerts NOW=$(DEMO_NOW)
|
||||
$(MAKE) --no-print-directory recommendations
|
||||
|
||||
@@ -9,8 +9,10 @@ series temporelles energetiques, deployee sur une machine on-premise.
|
||||
|-------|----------------------------------------------------------|
|
||||
| J1 | Valider la préparation de l'environnement et du repo |
|
||||
| J2 | Valider le périmètre retenu et les choix technologiques |
|
||||
| J3 | Valider l'architecture et la gestion de la sécurité |
|
||||
| J4 | Valider la robustesse et assurer les livrables |
|
||||
| J3 | Ingestion & backend |
|
||||
| J4 | Architecture, sécurité & frontend |
|
||||
| J5 | Valider la robustesse et assurer les livrables |
|
||||
| J6 | Amélioration possible |
|
||||
|
||||
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
|
||||
|
||||
@@ -18,14 +20,15 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
||||
|
||||
| Domaine | Technologie | Emplacement | Etat |
|
||||
|------------|-------------------------------------|---------------------|---------------|
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
||||
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
|
||||
| Frontend | Angular 22, Node 26 | `apps/frontend` | En place |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
|
||||
| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs |
|
||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
||||
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | En place |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
|
||||
| ML | LightGBM, MLflow | `ml` | En place |
|
||||
|
||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
||||
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
|
||||
@@ -47,13 +50,15 @@ L'etat detaille de chaque brique et les vues d'architecture sont dans
|
||||
│ ├── migrations/ Migrations SQL versionnees
|
||||
│ └── seeds/ Jeux de donnees de reference
|
||||
├── etl/airflow/
|
||||
│ ├── dags/ DAGs d'ingestion et d'agregation
|
||||
│ ├── dags/ DAGs d'orchestration (pipeline ML, alertes, import historique)
|
||||
│ ├── plugins/ Operateurs et hooks maison
|
||||
│ ├── include/ Requetes SQL et ressources des DAGs
|
||||
│ └── tests/ Tests d'integrite des DAGs
|
||||
├── infra/terraform/
|
||||
│ ├── modules/ Modules reutilisables
|
||||
│ └── environments/ Racines Terraform, une par environnement
|
||||
├── infra/
|
||||
│ ├── proxy/ Reverse proxy Nginx : terminaison TLS et routage
|
||||
│ └── terraform/
|
||||
│ ├── modules/ Modules reutilisables
|
||||
│ └── environments/ Racines Terraform, une par environnement
|
||||
├── ml/ Pipeline d'entrainement LightGBM, suivi MLflow
|
||||
├── monitoring/
|
||||
│ ├── prometheus/ Collecte et regles d'alerte
|
||||
@@ -72,25 +77,53 @@ Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Pyth
|
||||
cp .env.example .env # variables de docker-compose
|
||||
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
|
||||
|
||||
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
|
||||
make install # dependances du backend et du frontend
|
||||
make migrate # applique les migrations Alembic
|
||||
make dev # backend sur http://localhost:8000 (docs sur /docs), frontend sur http://localhost:4200
|
||||
make install # dependances du backend, du frontend, du ML et des DAGs
|
||||
make dev # toute la stack, voir ci-dessous
|
||||
make check # lint + typage + tests
|
||||
```
|
||||
|
||||
`make dev` enchaine tout : demarrage des services conteneurises (base sur le port 5433, Mailpit,
|
||||
Airflow), migrations Alembic, peuplement de demonstration si les alertes manquent, puis backend
|
||||
et frontend en rechargement a chaud sur le poste.
|
||||
|
||||
| Service | Adresse |
|
||||
|---|---|
|
||||
| Backend | <http://localhost:8000> (documentation sur `/docs`) |
|
||||
| Frontend | <http://localhost:4200> |
|
||||
| Airflow | <http://localhost:8080> (`AIRFLOW_ADMIN_USERNAME` / `AIRFLOW_ADMIN_PASSWORD` du `.env`) |
|
||||
| Mailpit | <http://localhost:8025> |
|
||||
|
||||
Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`,
|
||||
`AIRFLOW_API_SECRET_KEY`, `AIRFLOW_JWT_SECRET`, `AIRFLOW_APP_SECRET_KEY` et
|
||||
`AIRFLOW_ADMIN_PASSWORD`. Sans elles `airflow-init` refuse de demarrer, et `airflow-apiserver`,
|
||||
`airflow-scheduler` et `airflow-dag-processor` avec lui.
|
||||
|
||||
Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`,
|
||||
`make airflow-up`, `make dev-backend`, `make dev-frontend`.
|
||||
|
||||
`make help` liste les cibles disponibles.
|
||||
|
||||
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`,
|
||||
`apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que
|
||||
5432, souvent deja pris par une autre base.
|
||||
|
||||
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
|
||||
conteneur, le backend et le frontend tournent tous les deux sur le poste, lances ensemble par
|
||||
`make dev` (logs entrelaces dans le meme terminal, Ctrl+C arrete les deux). `make dev-backend`
|
||||
et `make dev-frontend` restent disponibles pour lancer un seul des deux. Le service `backend`
|
||||
du `docker-compose.yml` sert la stack complete et la recette, et n'embarque pas le source, donc
|
||||
toute modification y demande un `docker compose up -d --build backend`.
|
||||
Le backend et le frontend tournent sur le poste, lances ensemble par `make dev` (logs
|
||||
entrelaces dans le meme terminal, Ctrl+C arrete les deux) ; la base, Mailpit et Airflow tournent
|
||||
en conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
|
||||
et n'embarque pas le source, donc toute modification y demande un
|
||||
`docker compose up -d --build backend`.
|
||||
|
||||
### Donnees de demonstration
|
||||
|
||||
Le jeu historique s'arrete au 31/12/2024. `make demo-data` renseigne les tables que les vues
|
||||
alertes, recommandations et previsions lisent, en ancrant le scoring et la detection a cette
|
||||
date (`DEMO_NOW`) plutot qu'a l'horloge reelle, qui ne verrait qu'un parc muet depuis des mois.
|
||||
La cible ne fait rien si des alertes existent deja ; `make demo-data-force` rejoue les trois
|
||||
etapes, toutes idempotentes en base.
|
||||
|
||||
Un volume `pgdata` cree avant `db/init/120-airflow-database.sql` n'a pas de base `airflow` :
|
||||
`db/init` ne rejoue qu'a la premiere initialisation. `make db-ensure-airflow`, appelee par
|
||||
`make dev` et `make airflow-up`, la cree au besoin, sans detruire les donnees applicatives.
|
||||
|
||||
Verifier que la base repond et que l'extension est chargee :
|
||||
|
||||
@@ -98,6 +131,27 @@ Verifier que la base repond et que l'extension est chargee :
|
||||
curl -s localhost:8000/api/v1/health/ready
|
||||
```
|
||||
|
||||
## Stack complète derrière le reverse proxy
|
||||
|
||||
Pour servir l'application comme sur la machine cible, en HTTPS et sous une seule origine.
|
||||
L'overlay emploie `!override` et `!reset`, donc **Docker Compose 2.24.4 ou plus récent** :
|
||||
|
||||
```bash
|
||||
make tls-selfsigned PUBLIC_HOST=enervision.local # certificat de démonstration
|
||||
make stack-up PUBLIC_HOST=enervision.local # nginx en 80/443, rien d'autre n'est publié
|
||||
```
|
||||
|
||||
Le navigateur avertit d'un émetteur inconnu : Let's Encrypt reste hors d'atteinte tant qu'aucun
|
||||
nom de domaine public ne résout vers la machine. Routage, mode ACME et renouvellement dans
|
||||
[`infra/proxy/README.md`](infra/proxy/README.md) ; la décision et ses motifs dans
|
||||
[l'ADR 0007](docs/adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||
|
||||
Sur la VM ENI, deux environnements cohabitent, recette sur `dev` et production sur `main`,
|
||||
chacun dans son dossier et son projet Compose : `scripts/provision-host.sh` les prépare, le
|
||||
workflow `deploy.yml` les redéploie à chaque push par un runner auto-hébergé. Ports, noms
|
||||
d'hôte et garde-fous dans [`docs/architecture/10-infra.md`](docs/architecture/10-infra.md) et
|
||||
[l'ADR 0009](docs/adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
|
||||
|
||||
@@ -18,3 +18,7 @@ APP_SMTP_HOST=localhost
|
||||
APP_SMTP_PORT=1025
|
||||
APP_SMTP_USE_TLS=false
|
||||
APP_SMTP_FROM_ADDRESS=no-reply@enervision.fr
|
||||
APP_MOCK_API_BASE_URL=https://api-mock.charlieandre.fr
|
||||
APP_MOCK_API_USERNAME=change_me
|
||||
APP_MOCK_API_PASSWORD=change_me
|
||||
APP_MOCK_API_TIMEOUT_SECONDS=10
|
||||
|
||||
@@ -11,13 +11,14 @@ WORKDIR /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--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
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
|
||||
FROM python:3.14-slim AS runtime
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ class Settings(BaseSettings):
|
||||
database_pool_size: int = 5
|
||||
database_max_overflow: int = 10
|
||||
|
||||
mock_api_base_url: str = "https://api-mock.charlieandre.fr"
|
||||
mock_api_username: str | None = None
|
||||
mock_api_password: SecretStr | None = None
|
||||
mock_api_timeout_seconds: float = Field(default=10.0, gt=0)
|
||||
|
||||
jwt_issuer: str = "enervision-api"
|
||||
jwt_audience: str = "enervision-web"
|
||||
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
# Contrainte : la réponse de l'API Mock est une entrée hostile, pas une source de confiance.
|
||||
# Voir OWASP API10 dans docs/architecture/owasp-traceabilite.md. Rien de ce qu'elle renvoie
|
||||
# n'atteint la base sans passer par build_site_row() ou build_reading_row() : seuls les champs
|
||||
# attendus sont recopiés, les grandeurs physiques sont bornées par PHYSICAL_BOUNDS et la taille
|
||||
# des tableaux est plafonnée par MAX_SITES et par --limit. Une valeur hors bornes devient NULL
|
||||
# et laisse sa trace dans null_reasons plutôt que de lever : le mock émet des anomalies par
|
||||
# construction, et raw_data conserve de toute façon la réponse d'origine intacte.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
SOURCE_HISTORY = "api_history"
|
||||
|
||||
MAX_SITES = 100
|
||||
|
||||
MAX_LIMIT = 1000
|
||||
|
||||
# Les quatre seules valeurs que la contrainte ck_reading_quality accepte.
|
||||
ACCEPTED_QUALITIES = frozenset({"good", "partial", "degraded", "critical"})
|
||||
|
||||
PHYSICAL_BOUNDS: dict[str, tuple[float, float]] = {
|
||||
"consumption_kw": (0.0, 100_000.0),
|
||||
"consumption_kwh": (0.0, 100_000.0),
|
||||
"voltage_v": (0.0, 1_000.0),
|
||||
"current_a": (0.0, 10_000.0),
|
||||
"power_factor": (0.0, 1.0),
|
||||
"temperature_celsius": (-90.0, 60.0),
|
||||
"humidity_percent": (0.0, 100.0),
|
||||
}
|
||||
|
||||
CAPACITY_BOUNDS = (0.0, 100_000.0)
|
||||
|
||||
|
||||
def create_mock_api_client() -> httpx.AsyncClient:
|
||||
settings = get_settings()
|
||||
|
||||
if settings.mock_api_username is None or settings.mock_api_password is None:
|
||||
raise ValueError("Les identifiants de l'API Mock ne sont pas configurés.")
|
||||
|
||||
return httpx.AsyncClient(
|
||||
base_url=settings.mock_api_base_url.rstrip("/"),
|
||||
auth=(
|
||||
settings.mock_api_username,
|
||||
settings.mock_api_password.get_secret_value(),
|
||||
),
|
||||
timeout=settings.mock_api_timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def read_text(payload: dict[str, Any], key: str) -> str:
|
||||
value = payload.get(key)
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"Champ {key} absent ou invalide dans la réponse de l'API Mock.")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def optional_text(value: Any) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def coerce_measure(
|
||||
value: Any,
|
||||
bounds: tuple[float, float],
|
||||
) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
|
||||
lower, upper = bounds
|
||||
|
||||
# Écarte aussi NaN et les infinis, qu'aucune comparaison de bornes ne retient.
|
||||
return float(value) if lower <= value <= upper else None
|
||||
|
||||
|
||||
def resolve_quality(
|
||||
value: Any,
|
||||
rejected: list[str],
|
||||
) -> str | None:
|
||||
quality = value if isinstance(value, str) and value in ACCEPTED_QUALITIES else None
|
||||
|
||||
if rejected:
|
||||
return "critical" if quality == "critical" else "degraded"
|
||||
|
||||
return quality
|
||||
|
||||
|
||||
def resolve_null_reasons(
|
||||
value: Any,
|
||||
rejected: list[str],
|
||||
) -> list[str]:
|
||||
reported = [str(reason) for reason in value] if isinstance(value, list) else []
|
||||
|
||||
return reported + rejected
|
||||
|
||||
|
||||
async def fetch_sites(
|
||||
client: httpx.AsyncClient,
|
||||
) -> list[dict[str, Any]]:
|
||||
response = await client.get("/api/v1/sites")
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
payload = response.json()
|
||||
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("La réponse /api/v1/sites doit être une liste.")
|
||||
|
||||
if len(payload) > MAX_SITES:
|
||||
raise ValueError(f"La réponse /api/v1/sites dépasse le plafond de {MAX_SITES} sites.")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def build_site_row(
|
||||
site: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"site_id": read_text(site, "site_id"),
|
||||
"site_type": read_text(site, "site_type"),
|
||||
"site_name": read_text(site, "site_name"),
|
||||
"location": optional_text(site.get("location")),
|
||||
"capacity_kw": coerce_measure(site.get("capacity_kw"), CAPACITY_BOUNDS),
|
||||
"status": optional_text(site.get("status")),
|
||||
}
|
||||
|
||||
|
||||
async def upsert_sites(
|
||||
connection: AsyncConnection,
|
||||
sites: list[dict[str, Any]],
|
||||
) -> None:
|
||||
rows = [build_site_row(site) for site in sites]
|
||||
|
||||
if not rows:
|
||||
return
|
||||
|
||||
await connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO site (
|
||||
site_id,
|
||||
site_type,
|
||||
site_name,
|
||||
location,
|
||||
capacity_kw,
|
||||
status
|
||||
)
|
||||
VALUES (
|
||||
:site_id,
|
||||
:site_type,
|
||||
:site_name,
|
||||
:location,
|
||||
:capacity_kw,
|
||||
:status
|
||||
)
|
||||
ON CONFLICT (site_id)
|
||||
DO UPDATE SET
|
||||
site_type = EXCLUDED.site_type,
|
||||
site_name = EXCLUDED.site_name,
|
||||
location = EXCLUDED.location,
|
||||
capacity_kw = EXCLUDED.capacity_kw,
|
||||
status = EXCLUDED.status
|
||||
"""
|
||||
),
|
||||
rows,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_readings(
|
||||
client: httpx.AsyncClient,
|
||||
site_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
limit: int = MAX_LIMIT,
|
||||
) -> list[dict[str, Any]]:
|
||||
response = await client.get(
|
||||
"/api/v1/readings",
|
||||
params={
|
||||
"site_id": site_id,
|
||||
"start_time": start_time.isoformat(),
|
||||
"end_time": end_time.isoformat(),
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
payload = response.json()
|
||||
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("La réponse /api/v1/readings doit être une liste.")
|
||||
|
||||
if len(payload) > limit:
|
||||
raise ValueError(f"La réponse /api/v1/readings dépasse la limite demandée de {limit}.")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def build_reading_row(
|
||||
reading: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
measures: dict[str, float | None] = {}
|
||||
rejected: list[str] = []
|
||||
|
||||
for name, bounds in PHYSICAL_BOUNDS.items():
|
||||
received = reading.get(name)
|
||||
measures[name] = coerce_measure(received, bounds)
|
||||
|
||||
if received is not None and measures[name] is None:
|
||||
rejected.append(f"out_of_physical_bounds:{name}")
|
||||
|
||||
return {
|
||||
"site_id": read_text(reading, "site_id"),
|
||||
"timestamp": parse_datetime(read_text(reading, "timestamp")),
|
||||
"source": SOURCE_HISTORY,
|
||||
"dataset_id": None,
|
||||
**measures,
|
||||
"consumption_euros": None,
|
||||
"solar_irradiance_wm2": None,
|
||||
"is_working_hours": None,
|
||||
"data_quality": resolve_quality(reading.get("data_quality"), rejected),
|
||||
"null_reasons": resolve_null_reasons(reading.get("null_reasons"), rejected),
|
||||
"imputed_values": None,
|
||||
"imputation_method": None,
|
||||
"raw_data": json.dumps(
|
||||
reading,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Le conflit vise l'index unique uq_reading_source plutôt que la table entière : sans cible
|
||||
# nommée, DO NOTHING avalerait aussi une violation de clé primaire.
|
||||
READING_INSERT = text(
|
||||
"""
|
||||
INSERT INTO reading (
|
||||
site_id,
|
||||
timestamp,
|
||||
source,
|
||||
dataset_id,
|
||||
consumption_kw,
|
||||
consumption_kwh,
|
||||
consumption_euros,
|
||||
voltage_v,
|
||||
current_a,
|
||||
power_factor,
|
||||
temperature_celsius,
|
||||
humidity_percent,
|
||||
solar_irradiance_wm2,
|
||||
is_working_hours,
|
||||
data_quality,
|
||||
null_reasons,
|
||||
imputed_values,
|
||||
imputation_method,
|
||||
raw_data
|
||||
)
|
||||
VALUES (
|
||||
:site_id,
|
||||
:timestamp,
|
||||
:source,
|
||||
:dataset_id,
|
||||
:consumption_kw,
|
||||
:consumption_kwh,
|
||||
:consumption_euros,
|
||||
:voltage_v,
|
||||
:current_a,
|
||||
:power_factor,
|
||||
:temperature_celsius,
|
||||
:humidity_percent,
|
||||
:solar_irradiance_wm2,
|
||||
:is_working_hours,
|
||||
:data_quality,
|
||||
:null_reasons,
|
||||
CAST(:imputed_values AS jsonb),
|
||||
:imputation_method,
|
||||
CAST(:raw_data AS jsonb)
|
||||
)
|
||||
ON CONFLICT (site_id, timestamp, source, (coalesce(dataset_id, 0)))
|
||||
DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def build_reading_batch(
|
||||
readings: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
return [build_reading_row(reading) for reading in readings]
|
||||
|
||||
|
||||
async def import_mock_api_history(
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
limit: int,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
settings = get_settings()
|
||||
|
||||
async with create_mock_api_client() as client:
|
||||
sites = await fetch_sites(client)
|
||||
|
||||
print(f"Sites récupérés : {len(sites)}")
|
||||
|
||||
all_readings: list[dict[str, Any]] = []
|
||||
|
||||
for site in sites:
|
||||
site_id = read_text(site, "site_id")
|
||||
|
||||
readings = await fetch_readings(
|
||||
client=client,
|
||||
site_id=site_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
print(f"{site_id}: {len(readings)} lectures")
|
||||
|
||||
all_readings.extend(readings)
|
||||
|
||||
print(f"Lectures récupérées : {len(all_readings)}")
|
||||
|
||||
if dry_run:
|
||||
print("Dry-run terminé : aucune donnée écrite.")
|
||||
return
|
||||
|
||||
engine = create_async_engine(
|
||||
str(settings.database_url),
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await upsert_sites(
|
||||
connection,
|
||||
sites,
|
||||
)
|
||||
|
||||
rows = build_reading_batch(all_readings)
|
||||
|
||||
if rows:
|
||||
await connection.execute(
|
||||
READING_INSERT,
|
||||
rows,
|
||||
)
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
print("Import API Mock terminé.")
|
||||
|
||||
|
||||
def parse_datetime(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=("Import historique depuis l'API Mock EnerVision"))
|
||||
|
||||
parser.add_argument(
|
||||
"--start-time",
|
||||
required=True,
|
||||
type=parse_datetime,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--end-time",
|
||||
required=True,
|
||||
type=parse_datetime,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=MAX_LIMIT,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
|
||||
if args.limit < 1 or args.limit > MAX_LIMIT:
|
||||
raise ValueError(f"--limit doit être compris entre 1 et {MAX_LIMIT}.")
|
||||
|
||||
if args.start_time >= args.end_time:
|
||||
raise ValueError("--start-time doit être antérieur à --end-time.")
|
||||
|
||||
asyncio.run(
|
||||
import_mock_api_history(
|
||||
start_time=args.start_time,
|
||||
end_time=args.end_time,
|
||||
limit=args.limit,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,6 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Alert
|
||||
|
||||
# Douze colonnes par alerte, contre quatre pour une recommandation : le plafond asyncpg de
|
||||
# 32 767 parametres tombe a 2 730 lignes, d'ou un lot plus petit que `recommendation.py`.
|
||||
TAILLE_DE_LOT = 1000
|
||||
|
||||
|
||||
class AlertRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
@@ -44,12 +48,17 @@ class AlertRepository:
|
||||
}
|
||||
for alerte in alerts
|
||||
]
|
||||
requete = (
|
||||
insert(Alert)
|
||||
.values(valeurs)
|
||||
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
|
||||
.returning(Alert)
|
||||
)
|
||||
resultat = await self._session.execute(requete)
|
||||
creees: list[Alert] = []
|
||||
# Piège : asyncpg plafonne une requête à 32 767 paramètres. Une détection sur une fenêtre
|
||||
# chargée dépasse ce seuil, et l'`INSERT` d'un seul tenant échouerait.
|
||||
for debut in range(0, len(valeurs), TAILLE_DE_LOT):
|
||||
requete = (
|
||||
insert(Alert)
|
||||
.values(valeurs[debut : debut + TAILLE_DE_LOT])
|
||||
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
|
||||
.returning(Alert)
|
||||
)
|
||||
resultat = await self._session.execute(requete)
|
||||
creees.extend(resultat.scalars().all())
|
||||
await self._session.flush()
|
||||
return resultat.scalars().all()
|
||||
return creees
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"argon2-cffi>=23.1",
|
||||
"anyio>=4.0",
|
||||
"aiosmtplib>=5.1.3",
|
||||
"httpx>=0.28.1",
|
||||
"pandas>=3.0.5",
|
||||
]
|
||||
|
||||
@@ -27,7 +28,6 @@ dev = [
|
||||
"pytest>=9.1.1",
|
||||
"pytest-asyncio>=1.4.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
"httpx>=0.28.1",
|
||||
"pandas-stubs>=3.0.5.260914",
|
||||
]
|
||||
|
||||
|
||||
@@ -112,8 +112,10 @@ async def test_duplicate_reading_is_rejected_when_key_matches(
|
||||
)
|
||||
await data_connection.execute(statement)
|
||||
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
@@ -147,9 +149,12 @@ async def test_invalid_reading_is_rejected_when_constraints_fail(
|
||||
}
|
||||
values.update(changes)
|
||||
|
||||
statement = insert(Reading).values(**values)
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
await data_connection.execute(insert(Reading).values(**values))
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
@@ -212,21 +219,22 @@ async def test_alert_rejects_prediction_when_site_differs(
|
||||
)
|
||||
).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):
|
||||
async with data_connection.begin_nested():
|
||||
await data_connection.execute(
|
||||
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 with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
savepoint = data_connection.begin_nested()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with data_connection.begin_nested():
|
||||
async with savepoint:
|
||||
await data_connection.execute(statement)
|
||||
|
||||
@@ -1,239 +1,245 @@
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from app.etl.historical_import import (
|
||||
SOURCE_NAME,
|
||||
build_reading_batch,
|
||||
classify_quality,
|
||||
compute_sha256,
|
||||
load_metadata,
|
||||
normalize_timestamps,
|
||||
validate_source,
|
||||
)
|
||||
|
||||
|
||||
def make_metadata() -> dict:
|
||||
return {
|
||||
"total_records": 2,
|
||||
"sites": {
|
||||
"SITE001": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def make_dataframe() -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"timestamp": "2023-01-01 00:00:00",
|
||||
"site_id": "SITE001",
|
||||
"site_type": "office",
|
||||
"site_name": "Site 1",
|
||||
"consumption_kwh": 10.5,
|
||||
"consumption_euros": 2.5,
|
||||
"temperature_celsius": 20.0,
|
||||
"humidity_percent": 50.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"hour": 0,
|
||||
"day_of_week": 6,
|
||||
"day_name": "Sunday",
|
||||
"month": 1,
|
||||
"is_weekend": True,
|
||||
"is_working_hours": False,
|
||||
},
|
||||
{
|
||||
"timestamp": "2023-01-01 01:00:00",
|
||||
"site_id": "SITE001",
|
||||
"site_type": "office",
|
||||
"site_name": "Site 1",
|
||||
"consumption_kwh": 11.0,
|
||||
"consumption_euros": 2.7,
|
||||
"temperature_celsius": 19.5,
|
||||
"humidity_percent": 52.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"hour": 1,
|
||||
"day_of_week": 6,
|
||||
"day_name": "Sunday",
|
||||
"month": 1,
|
||||
"is_weekend": True,
|
||||
"is_working_hours": False,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_compute_sha256(tmp_path):
|
||||
file_path = tmp_path / "dataset.csv"
|
||||
content = b"hello-enervision"
|
||||
|
||||
file_path.write_bytes(content)
|
||||
|
||||
expected = hashlib.sha256(content).hexdigest()
|
||||
|
||||
assert compute_sha256(file_path) == expected
|
||||
|
||||
|
||||
def test_load_metadata(tmp_path):
|
||||
metadata_path = tmp_path / "metadata.json"
|
||||
|
||||
metadata = {
|
||||
"total_records": 2,
|
||||
"sites": {
|
||||
"SITE001": {},
|
||||
},
|
||||
}
|
||||
|
||||
metadata_path.write_text(
|
||||
json.dumps(metadata),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert load_metadata(metadata_path) == metadata
|
||||
|
||||
|
||||
def test_validate_source_accepts_valid_dataset():
|
||||
frame = make_dataframe()
|
||||
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_missing_column():
|
||||
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Colonnes obligatoires absentes",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_duplicates():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[1, "timestamp"] = frame.loc[
|
||||
0,
|
||||
"timestamp",
|
||||
]
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="doublons",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_unknown_site():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[1, "site_id"] = "SITE999"
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Sites incohérents",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_timestamps_adds_timezone():
|
||||
frame = make_dataframe()
|
||||
|
||||
normalized = normalize_timestamps(
|
||||
frame,
|
||||
"UTC",
|
||||
)
|
||||
|
||||
assert normalized["timestamp"].dt.tz is not None
|
||||
|
||||
assert "_source_timestamp" in normalized.columns
|
||||
|
||||
|
||||
def test_classify_quality_good():
|
||||
row = make_dataframe().iloc[0].to_dict()
|
||||
|
||||
quality, reasons = classify_quality(row)
|
||||
|
||||
assert quality == "good"
|
||||
assert reasons == []
|
||||
|
||||
|
||||
def test_classify_quality_degraded_when_consumption_missing():
|
||||
row = make_dataframe().iloc[0].to_dict()
|
||||
row["consumption_kwh"] = None
|
||||
|
||||
quality, reasons = classify_quality(row)
|
||||
|
||||
assert quality == "degraded"
|
||||
|
||||
assert "missing:consumption_kwh" in reasons
|
||||
|
||||
|
||||
def test_build_reading_batch_respects_database_contract():
|
||||
frame = normalize_timestamps(
|
||||
make_dataframe(),
|
||||
"UTC",
|
||||
)
|
||||
|
||||
rows = build_reading_batch(
|
||||
frame.iloc[:1],
|
||||
dataset_id=3,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
|
||||
row = rows[0]
|
||||
|
||||
assert row["dataset_id"] == 3
|
||||
|
||||
# Important :
|
||||
# contrainte ck_reading_dataset_source.
|
||||
assert row["source"] == "csv"
|
||||
assert SOURCE_NAME == "csv"
|
||||
|
||||
# Important :
|
||||
# contrainte ck_reading_imputation.
|
||||
assert row["imputed_values"] is None
|
||||
assert row["imputation_method"] is None
|
||||
|
||||
assert row["data_quality"] == "good"
|
||||
assert row["null_reasons"] == []
|
||||
|
||||
|
||||
def test_build_reading_batch_keeps_missing_values():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[0, "temperature_celsius"] = None
|
||||
|
||||
frame = normalize_timestamps(
|
||||
frame,
|
||||
"UTC",
|
||||
)
|
||||
|
||||
rows = build_reading_batch(
|
||||
frame.iloc[:1],
|
||||
dataset_id=3,
|
||||
)
|
||||
|
||||
row = rows[0]
|
||||
|
||||
assert row["temperature_celsius"] is None
|
||||
|
||||
assert "missing:temperature_celsius" in row["null_reasons"]
|
||||
|
||||
# RAW ingestion : aucune imputation.
|
||||
assert row["imputed_values"] is None
|
||||
assert row["imputation_method"] is None
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from app.etl.historical_import import (
|
||||
SOURCE_NAME,
|
||||
build_reading_batch,
|
||||
classify_quality,
|
||||
compute_sha256,
|
||||
load_metadata,
|
||||
normalize_timestamps,
|
||||
validate_source,
|
||||
)
|
||||
|
||||
|
||||
def make_metadata() -> dict:
|
||||
return {
|
||||
"total_records": 2,
|
||||
"sites": {
|
||||
"SITE001": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def make_dataframe() -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"timestamp": "2023-01-01 00:00:00",
|
||||
"site_id": "SITE001",
|
||||
"site_type": "office",
|
||||
"site_name": "Site 1",
|
||||
"consumption_kwh": 10.5,
|
||||
"consumption_euros": 2.5,
|
||||
"temperature_celsius": 20.0,
|
||||
"humidity_percent": 50.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"hour": 0,
|
||||
"day_of_week": 6,
|
||||
"day_name": "Sunday",
|
||||
"month": 1,
|
||||
"is_weekend": True,
|
||||
"is_working_hours": False,
|
||||
},
|
||||
{
|
||||
"timestamp": "2023-01-01 01:00:00",
|
||||
"site_id": "SITE001",
|
||||
"site_type": "office",
|
||||
"site_name": "Site 1",
|
||||
"consumption_kwh": 11.0,
|
||||
"consumption_euros": 2.7,
|
||||
"temperature_celsius": 19.5,
|
||||
"humidity_percent": 52.0,
|
||||
"solar_irradiance_wm2": 0.0,
|
||||
"hour": 1,
|
||||
"day_of_week": 6,
|
||||
"day_name": "Sunday",
|
||||
"month": 1,
|
||||
"is_weekend": True,
|
||||
"is_working_hours": False,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_compute_sha256(tmp_path):
|
||||
file_path = tmp_path / "dataset.csv"
|
||||
content = b"hello-enervision"
|
||||
|
||||
file_path.write_bytes(content)
|
||||
|
||||
expected = hashlib.sha256(content).hexdigest()
|
||||
|
||||
assert compute_sha256(file_path) == expected
|
||||
|
||||
|
||||
def test_load_metadata(tmp_path):
|
||||
metadata_path = tmp_path / "metadata.json"
|
||||
|
||||
metadata = {
|
||||
"total_records": 2,
|
||||
"sites": {
|
||||
"SITE001": {},
|
||||
},
|
||||
}
|
||||
|
||||
metadata_path.write_text(
|
||||
json.dumps(metadata),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert load_metadata(metadata_path) == metadata
|
||||
|
||||
|
||||
def test_validate_source_accepts_valid_dataset():
|
||||
frame = make_dataframe()
|
||||
|
||||
validate_source(
|
||||
frame,
|
||||
make_metadata(),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_missing_column():
|
||||
frame = make_dataframe().drop(columns=["consumption_kwh"])
|
||||
|
||||
metadata = make_metadata()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Colonnes obligatoires absentes",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_duplicates():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[1, "timestamp"] = frame.loc[
|
||||
0,
|
||||
"timestamp",
|
||||
]
|
||||
|
||||
metadata = make_metadata()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="doublons",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_source_rejects_unknown_site():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[1, "site_id"] = "SITE999"
|
||||
|
||||
metadata = make_metadata()
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Sites incohérents",
|
||||
):
|
||||
validate_source(
|
||||
frame,
|
||||
metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_timestamps_adds_timezone():
|
||||
frame = make_dataframe()
|
||||
|
||||
normalized = normalize_timestamps(
|
||||
frame,
|
||||
"UTC",
|
||||
)
|
||||
|
||||
assert normalized["timestamp"].dt.tz is not None
|
||||
|
||||
assert "_source_timestamp" in normalized.columns
|
||||
|
||||
|
||||
def test_classify_quality_good():
|
||||
row = make_dataframe().iloc[0].to_dict()
|
||||
|
||||
quality, reasons = classify_quality(row)
|
||||
|
||||
assert quality == "good"
|
||||
assert reasons == []
|
||||
|
||||
|
||||
def test_classify_quality_degraded_when_consumption_missing():
|
||||
row = make_dataframe().iloc[0].to_dict()
|
||||
row["consumption_kwh"] = None
|
||||
|
||||
quality, reasons = classify_quality(row)
|
||||
|
||||
assert quality == "degraded"
|
||||
|
||||
assert "missing:consumption_kwh" in reasons
|
||||
|
||||
|
||||
def test_build_reading_batch_respects_database_contract():
|
||||
frame = normalize_timestamps(
|
||||
make_dataframe(),
|
||||
"UTC",
|
||||
)
|
||||
|
||||
rows = build_reading_batch(
|
||||
frame.iloc[:1],
|
||||
dataset_id=3,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
|
||||
row = rows[0]
|
||||
|
||||
assert row["dataset_id"] == 3
|
||||
|
||||
# Important :
|
||||
# contrainte ck_reading_dataset_source.
|
||||
assert row["source"] == "csv"
|
||||
assert SOURCE_NAME == "csv"
|
||||
|
||||
# Important :
|
||||
# contrainte ck_reading_imputation.
|
||||
assert row["imputed_values"] is None
|
||||
assert row["imputation_method"] is None
|
||||
|
||||
assert row["data_quality"] == "good"
|
||||
assert row["null_reasons"] == []
|
||||
|
||||
|
||||
def test_build_reading_batch_keeps_missing_values():
|
||||
frame = make_dataframe()
|
||||
|
||||
frame.loc[0, "temperature_celsius"] = None
|
||||
|
||||
frame = normalize_timestamps(
|
||||
frame,
|
||||
"UTC",
|
||||
)
|
||||
|
||||
rows = build_reading_batch(
|
||||
frame.iloc[:1],
|
||||
dataset_id=3,
|
||||
)
|
||||
|
||||
row = rows[0]
|
||||
|
||||
assert row["temperature_celsius"] is None
|
||||
|
||||
assert "missing:temperature_celsius" in row["null_reasons"]
|
||||
|
||||
# RAW ingestion : aucune imputation.
|
||||
assert row["imputed_values"] is None
|
||||
assert row["imputation_method"] is None
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import AsyncClient, MockTransport, Request, Response
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import app.etl.mock_api_import as mock_api_import
|
||||
from app.etl.mock_api_import import (
|
||||
MAX_SITES,
|
||||
READING_INSERT,
|
||||
SOURCE_HISTORY,
|
||||
build_reading_batch,
|
||||
build_reading_row,
|
||||
build_site_row,
|
||||
fetch_readings,
|
||||
fetch_sites,
|
||||
upsert_sites,
|
||||
)
|
||||
|
||||
|
||||
def make_site() -> dict[str, Any]:
|
||||
return {
|
||||
"site_id": "SITE001",
|
||||
"site_type": "office",
|
||||
"site_name": "Bureau Paris La Défense",
|
||||
"location": "Paris, France",
|
||||
"capacity_kw": 200,
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
|
||||
def make_reading() -> dict[str, Any]:
|
||||
return {
|
||||
"timestamp": "2024-06-15T12:00:00Z",
|
||||
"site_id": "SITE001",
|
||||
"site_type": "office",
|
||||
"consumption_kw": 87.34,
|
||||
"consumption_kwh": 87.34,
|
||||
"voltage_v": 401.2,
|
||||
"current_a": 132.5,
|
||||
"power_factor": 0.923,
|
||||
"temperature_celsius": 22.1,
|
||||
"humidity_percent": 58.4,
|
||||
"null_reasons": [],
|
||||
"data_quality": "good",
|
||||
}
|
||||
|
||||
|
||||
async def test_fetch_sites_returns_sites() -> None:
|
||||
def handler(request: Request) -> Response:
|
||||
assert request.url.path == "/api/v1/sites"
|
||||
|
||||
return Response(
|
||||
status_code=200,
|
||||
json=[make_site()],
|
||||
)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
) as client:
|
||||
sites = await fetch_sites(client)
|
||||
|
||||
assert len(sites) == 1
|
||||
assert sites[0]["site_id"] == "SITE001"
|
||||
assert sites[0]["site_type"] == "office"
|
||||
|
||||
|
||||
async def test_fetch_sites_rejects_non_list_response() -> None:
|
||||
def handler(request: Request) -> Response:
|
||||
return Response(
|
||||
status_code=200,
|
||||
json={"unexpected": "payload"},
|
||||
)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
) as client:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="La réponse /api/v1/sites doit être une liste",
|
||||
):
|
||||
await fetch_sites(client)
|
||||
|
||||
|
||||
async def test_fetch_readings_sends_expected_query_parameters() -> None:
|
||||
captured_params: dict[str, str] = {}
|
||||
|
||||
def handler(request: Request) -> Response:
|
||||
nonlocal captured_params
|
||||
|
||||
captured_params = dict(request.url.params)
|
||||
|
||||
return Response(
|
||||
status_code=200,
|
||||
json=[make_reading()],
|
||||
)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
start_time = datetime.fromisoformat("2024-06-15T12:00:00")
|
||||
end_time = datetime.fromisoformat("2024-06-15T13:00:00")
|
||||
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
) as client:
|
||||
readings = await fetch_readings(
|
||||
client=client,
|
||||
site_id="SITE001",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=60,
|
||||
)
|
||||
|
||||
assert len(readings) == 1
|
||||
assert captured_params["site_id"] == "SITE001"
|
||||
assert captured_params["start_time"] == "2024-06-15T12:00:00"
|
||||
assert captured_params["end_time"] == "2024-06-15T13:00:00"
|
||||
assert captured_params["limit"] == "60"
|
||||
|
||||
|
||||
async def test_fetch_readings_rejects_non_list_response() -> None:
|
||||
def handler(request: Request) -> Response:
|
||||
return Response(
|
||||
status_code=200,
|
||||
json={"unexpected": "payload"},
|
||||
)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
) as client:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="La réponse /api/v1/readings doit être une liste",
|
||||
):
|
||||
await fetch_readings(
|
||||
client=client,
|
||||
site_id="SITE001",
|
||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
||||
limit=60,
|
||||
)
|
||||
|
||||
|
||||
async def test_fetch_readings_raises_on_http_error() -> None:
|
||||
def handler(request: Request) -> Response:
|
||||
return Response(
|
||||
status_code=404,
|
||||
json={"detail": "Site non trouvé"},
|
||||
)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
) as client:
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await fetch_readings(
|
||||
client=client,
|
||||
site_id="SITE999",
|
||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
||||
limit=60,
|
||||
)
|
||||
|
||||
|
||||
def test_build_reading_row_respects_database_contract() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
assert row["site_id"] == "SITE001"
|
||||
assert row["source"] == SOURCE_HISTORY
|
||||
assert row["source"] == "api_history"
|
||||
assert row["dataset_id"] is None
|
||||
|
||||
assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00")
|
||||
|
||||
assert row["consumption_kw"] == 87.34
|
||||
assert row["consumption_kwh"] == 87.34
|
||||
assert row["data_quality"] == "good"
|
||||
assert row["null_reasons"] == []
|
||||
|
||||
assert row["imputed_values"] is None
|
||||
assert row["imputation_method"] is None
|
||||
|
||||
|
||||
def test_build_reading_row_keeps_null_values_and_quality() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
reading["consumption_kw"] = None
|
||||
reading["consumption_kwh"] = None
|
||||
reading["voltage_v"] = None
|
||||
reading["current_a"] = None
|
||||
reading["power_factor"] = None
|
||||
reading["data_quality"] = "degraded"
|
||||
reading["null_reasons"] = [
|
||||
"consumption_sensor_failure",
|
||||
"electrical_sensor_failure",
|
||||
]
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
assert row["consumption_kw"] is None
|
||||
assert row["consumption_kwh"] is None
|
||||
assert row["voltage_v"] is None
|
||||
assert row["current_a"] is None
|
||||
assert row["power_factor"] is None
|
||||
|
||||
assert row["data_quality"] == "degraded"
|
||||
assert row["null_reasons"] == [
|
||||
"consumption_sensor_failure",
|
||||
"electrical_sensor_failure",
|
||||
]
|
||||
|
||||
assert row["imputed_values"] is None
|
||||
assert row["imputation_method"] is None
|
||||
|
||||
|
||||
def test_build_reading_row_keeps_raw_source_data() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
raw_data = json.loads(row["raw_data"])
|
||||
|
||||
assert raw_data == reading
|
||||
|
||||
|
||||
def test_build_reading_batch_transforms_all_readings() -> None:
|
||||
first = make_reading()
|
||||
|
||||
second = make_reading()
|
||||
second["timestamp"] = "2024-06-15T12:01:00Z"
|
||||
second["consumption_kw"] = 90.5
|
||||
|
||||
rows = build_reading_batch([first, second])
|
||||
|
||||
assert len(rows) == 2
|
||||
|
||||
assert rows[0]["site_id"] == "SITE001"
|
||||
assert rows[0]["consumption_kw"] == 87.34
|
||||
|
||||
assert rows[1]["site_id"] == "SITE001"
|
||||
assert rows[1]["consumption_kw"] == 90.5
|
||||
|
||||
|
||||
def test_create_mock_api_client_requires_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
settings = SimpleNamespace(
|
||||
mock_api_username=None,
|
||||
mock_api_password=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"get_settings",
|
||||
lambda: settings,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Les identifiants de l'API Mock ne sont pas configurés",
|
||||
):
|
||||
mock_api_import.create_mock_api_client()
|
||||
|
||||
|
||||
async def test_create_mock_api_client_uses_configuration(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
password = MagicMock()
|
||||
password.get_secret_value.return_value = "test-password"
|
||||
|
||||
settings = SimpleNamespace(
|
||||
mock_api_base_url="https://mock.test/",
|
||||
mock_api_username="test-user",
|
||||
mock_api_password=password,
|
||||
mock_api_timeout_seconds=10.0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"get_settings",
|
||||
lambda: settings,
|
||||
)
|
||||
|
||||
client = mock_api_import.create_mock_api_client()
|
||||
|
||||
try:
|
||||
assert str(client.base_url) == "https://mock.test"
|
||||
assert client.timeout.connect == 10.0
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_upsert_sites_with_empty_list_does_nothing() -> None:
|
||||
connection = AsyncMock()
|
||||
|
||||
await upsert_sites(
|
||||
connection,
|
||||
[],
|
||||
)
|
||||
|
||||
connection.execute.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_import_mock_api_history_dry_run_does_not_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def handler(request: Request) -> Response:
|
||||
if request.url.path == "/api/v1/sites":
|
||||
return Response(
|
||||
status_code=200,
|
||||
json=[make_site()],
|
||||
)
|
||||
|
||||
if request.url.path == "/api/v1/readings":
|
||||
return Response(
|
||||
status_code=200,
|
||||
json=[make_reading()],
|
||||
)
|
||||
|
||||
return Response(status_code=404)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
client = AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"create_mock_api_client",
|
||||
lambda: client,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
database_url="postgresql+asyncpg://unused",
|
||||
),
|
||||
)
|
||||
|
||||
create_engine_mock = MagicMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"create_async_engine",
|
||||
create_engine_mock,
|
||||
)
|
||||
|
||||
await mock_api_import.import_mock_api_history(
|
||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
||||
limit=60,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
create_engine_mock.assert_not_called()
|
||||
|
||||
|
||||
async def test_import_mock_api_history_loads_data(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def handler(request: Request) -> Response:
|
||||
if request.url.path == "/api/v1/sites":
|
||||
return Response(
|
||||
status_code=200,
|
||||
json=[make_site()],
|
||||
)
|
||||
|
||||
if request.url.path == "/api/v1/readings":
|
||||
return Response(
|
||||
status_code=200,
|
||||
json=[make_reading()],
|
||||
)
|
||||
|
||||
return Response(status_code=404)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
client = AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"create_mock_api_client",
|
||||
lambda: client,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
database_url="postgresql+asyncpg://test:test@localhost/test",
|
||||
),
|
||||
)
|
||||
|
||||
connection = AsyncMock()
|
||||
|
||||
transaction_context = MagicMock()
|
||||
transaction_context.__aenter__ = AsyncMock(
|
||||
return_value=connection,
|
||||
)
|
||||
transaction_context.__aexit__ = AsyncMock(
|
||||
return_value=None,
|
||||
)
|
||||
|
||||
engine = MagicMock()
|
||||
engine.begin.return_value = transaction_context
|
||||
engine.dispose = AsyncMock()
|
||||
|
||||
create_engine_mock = MagicMock(
|
||||
return_value=engine,
|
||||
)
|
||||
|
||||
upsert_sites_mock = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"create_async_engine",
|
||||
create_engine_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"upsert_sites",
|
||||
upsert_sites_mock,
|
||||
)
|
||||
|
||||
await mock_api_import.import_mock_api_history(
|
||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
||||
limit=60,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
create_engine_mock.assert_called_once_with(
|
||||
"postgresql+asyncpg://test:test@localhost/test",
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
upsert_sites_mock.assert_awaited_once_with(
|
||||
connection,
|
||||
[make_site()],
|
||||
)
|
||||
|
||||
connection.execute.assert_awaited_once()
|
||||
engine.dispose.assert_awaited_once()
|
||||
|
||||
|
||||
def test_parse_datetime_accepts_z_suffix() -> None:
|
||||
result = mock_api_import.parse_datetime(
|
||||
"2024-06-15T12:00:00Z",
|
||||
)
|
||||
|
||||
assert result == datetime.fromisoformat(
|
||||
"2024-06-15T12:00:00+00:00",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_args_reads_cli_parameters(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"mock_api_import",
|
||||
"--start-time",
|
||||
"2024-06-15T12:00:00Z",
|
||||
"--end-time",
|
||||
"2024-06-15T13:00:00Z",
|
||||
"--limit",
|
||||
"60",
|
||||
"--dry-run",
|
||||
],
|
||||
)
|
||||
|
||||
args = mock_api_import.parse_args()
|
||||
|
||||
assert args.start_time == datetime.fromisoformat(
|
||||
"2024-06-15T12:00:00+00:00",
|
||||
)
|
||||
assert args.end_time == datetime.fromisoformat(
|
||||
"2024-06-15T13:00:00+00:00",
|
||||
)
|
||||
assert args.limit == 60
|
||||
assert args.dry_run is True
|
||||
|
||||
|
||||
def test_main_rejects_limit_out_of_bounds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"mock_api_import",
|
||||
"--start-time",
|
||||
"2024-06-15T12:00:00Z",
|
||||
"--end-time",
|
||||
"2024-06-15T13:00:00Z",
|
||||
"--limit",
|
||||
"0",
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="--limit doit être compris entre 1 et 1000",
|
||||
):
|
||||
mock_api_import.main()
|
||||
|
||||
|
||||
def test_main_rejects_invalid_period(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"mock_api_import",
|
||||
"--start-time",
|
||||
"2024-06-15T14:00:00Z",
|
||||
"--end-time",
|
||||
"2024-06-15T13:00:00Z",
|
||||
"--limit",
|
||||
"60",
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="--start-time doit être antérieur à --end-time",
|
||||
):
|
||||
mock_api_import.main()
|
||||
|
||||
|
||||
def test_main_runs_import(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
start_time = datetime.fromisoformat(
|
||||
"2024-06-15T12:00:00+00:00",
|
||||
)
|
||||
end_time = datetime.fromisoformat(
|
||||
"2024-06-15T13:00:00+00:00",
|
||||
)
|
||||
|
||||
import_mock = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"parse_args",
|
||||
lambda: SimpleNamespace(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=60,
|
||||
dry_run=True,
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_api_import,
|
||||
"import_mock_api_history",
|
||||
import_mock,
|
||||
)
|
||||
|
||||
mock_api_import.main()
|
||||
|
||||
import_mock.assert_awaited_once_with(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=60,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_fetch_sites_rejects_a_response_above_the_cap() -> None:
|
||||
def handler(request: Request) -> Response:
|
||||
return Response(
|
||||
status_code=200,
|
||||
json=[make_site() for _ in range(MAX_SITES + 1)],
|
||||
)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
) as client:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=f"dépasse le plafond de {MAX_SITES} sites",
|
||||
):
|
||||
await fetch_sites(client)
|
||||
|
||||
|
||||
async def test_fetch_readings_rejects_a_response_above_the_requested_limit() -> None:
|
||||
def handler(request: Request) -> Response:
|
||||
return Response(
|
||||
status_code=200,
|
||||
json=[make_reading(), make_reading(), make_reading()],
|
||||
)
|
||||
|
||||
transport = MockTransport(handler)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="https://mock.test",
|
||||
) as client:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="dépasse la limite demandée de 2",
|
||||
):
|
||||
await fetch_readings(
|
||||
client=client,
|
||||
site_id="SITE001",
|
||||
start_time=datetime.fromisoformat("2024-06-15T12:00:00"),
|
||||
end_time=datetime.fromisoformat("2024-06-15T13:00:00"),
|
||||
limit=2,
|
||||
)
|
||||
|
||||
|
||||
def test_build_reading_row_neutralises_values_outside_physical_bounds() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
reading["power_factor"] = 42.0
|
||||
reading["temperature_celsius"] = 1e30
|
||||
reading["humidity_percent"] = -1.0
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
assert row["power_factor"] is None
|
||||
assert row["temperature_celsius"] is None
|
||||
assert row["humidity_percent"] is None
|
||||
|
||||
assert row["null_reasons"] == [
|
||||
"out_of_physical_bounds:power_factor",
|
||||
"out_of_physical_bounds:temperature_celsius",
|
||||
"out_of_physical_bounds:humidity_percent",
|
||||
]
|
||||
|
||||
assert row["data_quality"] == "degraded"
|
||||
|
||||
assert json.loads(row["raw_data"])["power_factor"] == 42.0
|
||||
|
||||
|
||||
def test_build_reading_row_rejects_a_measure_that_is_not_a_number() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
reading["consumption_kw"] = "87.34"
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
assert row["consumption_kw"] is None
|
||||
assert "out_of_physical_bounds:consumption_kw" in row["null_reasons"]
|
||||
|
||||
|
||||
def test_build_reading_row_drops_a_quality_the_database_refuses() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
reading["data_quality"] = "unknown"
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
assert row["data_quality"] is None
|
||||
|
||||
|
||||
def test_build_reading_row_requires_an_identifier() -> None:
|
||||
reading = make_reading()
|
||||
|
||||
del reading["site_id"]
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Champ site_id absent ou invalide",
|
||||
):
|
||||
build_reading_row(reading)
|
||||
|
||||
|
||||
def test_build_site_row_keeps_only_the_expected_columns() -> None:
|
||||
site = make_site()
|
||||
|
||||
site["unexpected"] = "valeur hostile"
|
||||
site["capacity_kw"] = -5.0
|
||||
site["status"] = 12
|
||||
|
||||
row = build_site_row(site)
|
||||
|
||||
assert set(row) == {
|
||||
"site_id",
|
||||
"site_type",
|
||||
"site_name",
|
||||
"location",
|
||||
"capacity_kw",
|
||||
"status",
|
||||
}
|
||||
|
||||
assert row["capacity_kw"] is None
|
||||
assert row["status"] is None
|
||||
|
||||
|
||||
async def test_upsert_sites_sends_only_the_expected_columns() -> None:
|
||||
connection = AsyncMock()
|
||||
|
||||
site = make_site()
|
||||
site["unexpected"] = "valeur hostile"
|
||||
|
||||
await upsert_sites(
|
||||
connection,
|
||||
[site],
|
||||
)
|
||||
|
||||
rows = connection.execute.await_args.args[1]
|
||||
|
||||
assert "unexpected" not in rows[0]
|
||||
assert rows[0]["site_id"] == "SITE001"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_reading_insert_is_idempotent(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
reading = make_reading()
|
||||
row = build_reading_row(reading)
|
||||
|
||||
connection = await session.connection()
|
||||
|
||||
await upsert_sites(
|
||||
connection,
|
||||
[make_site()],
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
READING_INSERT,
|
||||
[row],
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
READING_INSERT,
|
||||
[row],
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM reading
|
||||
WHERE site_id = :site_id
|
||||
AND timestamp = :timestamp
|
||||
AND source = :source
|
||||
"""
|
||||
),
|
||||
{
|
||||
"site_id": row["site_id"],
|
||||
"timestamp": row["timestamp"],
|
||||
"source": row["source"],
|
||||
},
|
||||
)
|
||||
|
||||
assert result.scalar_one() == 1
|
||||
|
||||
await session.rollback()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_out_of_bounds_reading_is_stored_neutralised(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
reading = make_reading()
|
||||
reading["power_factor"] = 42.0
|
||||
|
||||
row = build_reading_row(reading)
|
||||
|
||||
connection = await session.connection()
|
||||
|
||||
await upsert_sites(
|
||||
connection,
|
||||
[make_site()],
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
READING_INSERT,
|
||||
[row],
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT power_factor, data_quality, null_reasons, raw_data ->> 'power_factor'
|
||||
FROM reading
|
||||
WHERE site_id = :site_id
|
||||
AND timestamp = :timestamp
|
||||
AND source = :source
|
||||
"""
|
||||
),
|
||||
{
|
||||
"site_id": row["site_id"],
|
||||
"timestamp": row["timestamp"],
|
||||
"source": row["source"],
|
||||
},
|
||||
)
|
||||
|
||||
stored = result.one()
|
||||
|
||||
await session.rollback()
|
||||
|
||||
assert stored[0] is None
|
||||
assert stored[1] == "degraded"
|
||||
assert stored[2] == ["out_of_physical_bounds:power_factor"]
|
||||
assert stored[3] == "42.0"
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Alert
|
||||
from app.repositories import alert as module_alert
|
||||
from app.repositories.alert import AlertRepository
|
||||
from app.schemas.alert import AlertSeverity
|
||||
from tests.repositories.test_site import creer as creer_site
|
||||
@@ -146,3 +147,20 @@ async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession)
|
||||
creees = await depot.create_many([])
|
||||
|
||||
assert creees == []
|
||||
|
||||
|
||||
async def test_create_many_inserts_every_alert_across_several_batches(
|
||||
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(module_alert, "TAILLE_DE_LOT", 2)
|
||||
site = await creer_site(session)
|
||||
depot = AlertRepository(session)
|
||||
a_inserer = [
|
||||
_alerte_a_inserer(site_id=site.site_id, source_alert_id=f"threshold:lot-{index}")
|
||||
for index in range(5)
|
||||
]
|
||||
|
||||
creees = await depot.create_many(a_inserer)
|
||||
await session.rollback()
|
||||
|
||||
assert len(creees) == 5
|
||||
|
||||
@@ -49,8 +49,10 @@ async def test_the_database_refuses_to_mutate_the_audit_log(
|
||||
) -> None:
|
||||
await une_ligne(session)
|
||||
|
||||
requete = text(instruction)
|
||||
|
||||
with pytest.raises(DBAPIError, match="ajout seul"):
|
||||
await session.execute(text(instruction))
|
||||
await session.execute(requete)
|
||||
await session.rollback()
|
||||
|
||||
|
||||
|
||||
@@ -131,11 +131,14 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
empreinte = fingerprint_refresh(secret)
|
||||
expiration = datetime.now(UTC) + DUREE
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await depot.create(
|
||||
user_id=compte,
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + DUREE,
|
||||
token_hash=empreinte,
|
||||
expires_at=expiration,
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
@@ -178,12 +178,16 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
famille = uuid.uuid4()
|
||||
empreinte = fingerprint_refresh(secret)
|
||||
expiration = datetime.now(UTC) + DUREE
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await depot.create(
|
||||
user_id=compte,
|
||||
family_id=uuid.uuid4(),
|
||||
token_hash=fingerprint_refresh(secret),
|
||||
expires_at=datetime.now(UTC) + DUREE,
|
||||
family_id=famille,
|
||||
token_hash=empreinte,
|
||||
expires_at=expiration,
|
||||
client_ip=None,
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
@@ -31,14 +31,12 @@ async def test_the_database_refuses_an_email_written_in_upper_case(
|
||||
) -> None:
|
||||
saisie = adresse().upper()
|
||||
|
||||
requete = text(
|
||||
"insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
await session.execute(
|
||||
text(
|
||||
"insert into app_user (email, password_hash, role) "
|
||||
"values (:e, '$argon2id$x', 'lecteur')"
|
||||
),
|
||||
{"e": saisie},
|
||||
)
|
||||
await session.execute(requete, {"e": saisie})
|
||||
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:
|
||||
service = ReadingService(readings=FakeRepository([]))
|
||||
|
||||
debut = datetime(2026, 9, 2, tzinfo=UTC)
|
||||
fin = datetime(2026, 9, 1, tzinfo=UTC)
|
||||
|
||||
with pytest.raises(FenetreInverseeError):
|
||||
await service.list_history(
|
||||
start=datetime(2026, 9, 2, tzinfo=UTC),
|
||||
end=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
limit=500,
|
||||
offset=0,
|
||||
)
|
||||
await service.list_history(start=debut, end=fin, limit=500, offset=0)
|
||||
|
||||
|
||||
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":
|
||||
arguments["is_active"] = False
|
||||
|
||||
methode = getattr(attirail.service, action)
|
||||
|
||||
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:
|
||||
parser = cli.build_parser()
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.build_parser().parse_args([])
|
||||
parser.parse_args([])
|
||||
|
||||
|
||||
def test_build_parser_requires_an_email() -> None:
|
||||
parser = cli.build_parser()
|
||||
|
||||
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(
|
||||
|
||||
Generated
+39
-39
@@ -326,6 +326,7 @@ dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pandas" },
|
||||
{ name = "prometheus-fastapi-instrumentator" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
@@ -338,7 +339,6 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pandas-stubs" },
|
||||
{ name = "pytest" },
|
||||
@@ -355,6 +355,7 @@ requires-dist = [
|
||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "pandas", specifier = ">=3.0.5" },
|
||||
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
|
||||
@@ -367,7 +368,6 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "mypy", specifier = ">=2.3.1" },
|
||||
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
@@ -655,31 +655,31 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pandas"
|
||||
version = "3.0.5"
|
||||
version = "3.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -936,27 +936,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.7"
|
||||
version = "0.16.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# ==================
|
||||
|
||||
# Image pour frontend
|
||||
FROM node:24-alpine3.22 AS builder
|
||||
FROM node:26-alpine3.22 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -23,7 +23,7 @@ RUN npm run build
|
||||
# ==================
|
||||
|
||||
|
||||
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner
|
||||
FROM nginx:1.31-alpine AS runner
|
||||
|
||||
# Copie de la configuration de nginx
|
||||
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Conventions de tests unitaires — Frontend
|
||||
# Conventions de tests unitaires : Frontend
|
||||
|
||||
## Outil
|
||||
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
|
||||
@@ -83,3 +83,6 @@ describe('MonComposant', () => {
|
||||
## Lancer les tests
|
||||
- Développement (mode watch) : `npm test`
|
||||
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
|
||||
- Un fichier ou un dossier seulement :
|
||||
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
|
||||
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": {
|
||||
"styles": {
|
||||
"inlineCritical": false
|
||||
}
|
||||
},
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
|
||||
Generated
+95
-174
@@ -8,12 +8,12 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@angular/common": "^22.1.0",
|
||||
"@angular/compiler": "^22.1.0",
|
||||
"@angular/core": "^22.1.0",
|
||||
"@angular/forms": "^22.1.0",
|
||||
"@angular/platform-browser": "^22.1.0",
|
||||
"@angular/router": "^22.1.0",
|
||||
"@angular/common": "^22.1.7",
|
||||
"@angular/compiler": "^22.1.7",
|
||||
"@angular/core": "^22.1.7",
|
||||
"@angular/forms": "^22.1.7",
|
||||
"@angular/platform-browser": "^22.1.7",
|
||||
"@angular/router": "^22.1.7",
|
||||
"chart.js": "^4.5.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -21,21 +21,14 @@
|
||||
"devDependencies": {
|
||||
"@angular/build": "^22.1.8",
|
||||
"@angular/cli": "^22.1.8",
|
||||
"@angular/compiler-cli": "^22.1.0",
|
||||
"@angular/compiler-cli": "^22.1.7",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"jsdom": "^30.1.0",
|
||||
"prettier": "^3.9.8",
|
||||
"typescript": "~6.0.2",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@acemir/cssom": {
|
||||
"version": "0.9.31",
|
||||
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
|
||||
"integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
@@ -255,9 +248,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.6.tgz",
|
||||
"integrity": "sha512-giuH+jJvo6YbBxbKofJCXvq6k8g1Z/xCAvh4piNFSSS6/toXTLCeZ+snr6Stw5b2wRbArM5Q5nDeuto3NqVPnQ==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.7.tgz",
|
||||
"integrity": "sha512-SLW4AdH2Y8G9SM/iFRNpCQtkmvq55RcIQWsADT9ypZ7FxxcnJRsr4Q/WnbUBbuCslsCkrgzU1i9WDiW0m7gcnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -266,14 +259,14 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "22.1.6",
|
||||
"@angular/core": "22.1.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.6.tgz",
|
||||
"integrity": "sha512-JjOUm/qD338+fGfZvxSNn/vTUiVqNwOiPzacInVUq1eVp7Jev+cnvEwXA2cFJkYZoy3Imz6wHoMvTUZNN8cbKQ==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.7.tgz",
|
||||
"integrity": "sha512-qhoHAByh805nT4Xy4Wd2kE5FvkTc7wcg8Qps+EH6+z4KRfwGiL5cwdgooGWjTAldFCeqTgJ0BO/0dB6GHZjdkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -283,9 +276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler-cli": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.6.tgz",
|
||||
"integrity": "sha512-C1fQuaSLnibhfbb7Im/vurdBEcfQk+/GqPkY4+dgEKd4EPleO0xWlD+k5DwyNFX8a9w7HebWfo0Zl66HuaEwOQ==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.7.tgz",
|
||||
"integrity": "sha512-VGg8Kyt0YymQfnUUFk79vFaM+x5xmIMHy+BZh3P2Lh/uxqCXTWhJJH8kOHKtIGGH5/wKhBEmSy2CezdcIuM9xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -306,7 +299,7 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "22.1.6",
|
||||
"@angular/compiler": "22.1.7",
|
||||
"typescript": ">=6.0 <6.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -316,9 +309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.6.tgz",
|
||||
"integrity": "sha512-3Ln9YYOhsaU2vPufnpcu6C4dlmX4e/nJTlggVcKMT7bGZH5KlEtw3h0uh9YfANv8YhQCEk1AcuRI7KpBnh5ing==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.7.tgz",
|
||||
"integrity": "sha512-T9bt1PIgoN7n+TxGHTUgu0EsomlGMQ84YMXbMB+N6HhD8C1qZxn0uCbnAtCUjcecg1am8ioKxNTO0eII9ouioQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -327,7 +320,7 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "22.1.6",
|
||||
"@angular/compiler": "22.1.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"zone.js": "~0.15.0 || ~0.16.0"
|
||||
},
|
||||
@@ -341,9 +334,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/forms": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.6.tgz",
|
||||
"integrity": "sha512-rfV4G4UB4l69yXSRvhaHPzTMIruvBlRO+ak9NtTcYMnsoj8O5ZCyPv0ledGIrLTBwGSI3X1j4nr9aoV+Rj/n+Q==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.7.tgz",
|
||||
"integrity": "sha512-oc0DT39C3ZboJpDx8xKUCltgn56LHi0kbv+ThNK/dCSZLgjF2nd+muMMRYF+amdljQp2q7+hm2ORmhON3CNFog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -354,16 +347,16 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "22.1.6",
|
||||
"@angular/core": "22.1.6",
|
||||
"@angular/platform-browser": "22.1.6",
|
||||
"@angular/common": "22.1.7",
|
||||
"@angular/core": "22.1.7",
|
||||
"@angular/platform-browser": "22.1.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.6.tgz",
|
||||
"integrity": "sha512-jrRi6zpdz+jOle5l0OW7QL0a8xPgdnxWT5FrJabbiNKfYzQfqKcaAu02l8uJoJxtGb8fLQ8pbjkPpZEvKO2z+w==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.7.tgz",
|
||||
"integrity": "sha512-4dT3qGw3ZHVQUcHKALzMx9MTo+pxAMzHIPxc8HUESklqXfq6GRaIIfFMvE0RsquI9iqu6admD6lOAEzLWS8DxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -372,9 +365,9 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "22.1.6",
|
||||
"@angular/common": "22.1.6",
|
||||
"@angular/core": "22.1.6"
|
||||
"@angular/animations": "22.1.7",
|
||||
"@angular/common": "22.1.7",
|
||||
"@angular/core": "22.1.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/animations": {
|
||||
@@ -383,9 +376,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "22.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.6.tgz",
|
||||
"integrity": "sha512-ex0vrkcVyJJdn7NzZXun+5ctaPPLTZJ/gE7A3g1dun7BM3g4Z6zT738mODFeJgtPtJNf4bTs2gpgvyegvzWH7w==",
|
||||
"version": "22.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.7.tgz",
|
||||
"integrity": "sha512-CxYa2Ym5L0WmqToHU2I0Z4VEpocdryLlvx5svLfo9M3uOaP3/9icB/gjS/vXPbbQxV8/GZ59E/7/LF14anIoRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
@@ -394,60 +387,45 @@
|
||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "22.1.6",
|
||||
"@angular/core": "22.1.6",
|
||||
"@angular/platform-browser": "22.1.6",
|
||||
"@angular/common": "22.1.7",
|
||||
"@angular/core": "22.1.7",
|
||||
"@angular/platform-browser": "22.1.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz",
|
||||
"integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-calc": "^3.3.0",
|
||||
"@csstools/css-color-parser": "^4.2.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "6.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
|
||||
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
|
||||
"version": "9.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.1.4.tgz",
|
||||
"integrity": "sha512-ynDE7RDZM1z+YuSU+iGhRp8WcSXHqK9+G32ZEzsL35TKSoy72fYR7VfrJCQkWe3ciiIWGSTAIBc9XB5jVpqyTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.1.0",
|
||||
"bidi-js": "^1.1.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"lru-cache": "^11.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz",
|
||||
@@ -854,9 +832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz",
|
||||
"integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
|
||||
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -879,9 +857,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
|
||||
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4490,22 +4468,6 @@
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
|
||||
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.0.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
|
||||
"css-tree": "^3.1.0",
|
||||
"lru-cache": "^11.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
@@ -5283,30 +5245,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent/node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
|
||||
@@ -5529,39 +5467,38 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "28.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
|
||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||
"version": "30.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz",
|
||||
"integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||
"@asamuzakjp/css-color": "^7.0.0",
|
||||
"@asamuzakjp/dom-selector": "^9.1.2",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"cssstyle": "^6.0.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"parse5": "^8.0.0",
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"undici": "^7.21.0",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.10.2",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0",
|
||||
"whatwg-url": "^17.1.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
"canvas": "^3.2.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
@@ -5569,28 +5506,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"node_modules/jsdom/node_modules/whatwg-url": {
|
||||
"version": "17.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz",
|
||||
"integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
@@ -6708,9 +6636,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
|
||||
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -7308,13 +7236,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -7469,13 +7390,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
|
||||
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
|
||||
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
|
||||
+10
-10
@@ -12,12 +12,12 @@
|
||||
"private": true,
|
||||
"packageManager": "npm@11.19.0",
|
||||
"dependencies": {
|
||||
"@angular/common": "^22.1.0",
|
||||
"@angular/compiler": "^22.1.0",
|
||||
"@angular/core": "^22.1.0",
|
||||
"@angular/forms": "^22.1.0",
|
||||
"@angular/platform-browser": "^22.1.0",
|
||||
"@angular/router": "^22.1.0",
|
||||
"@angular/common": "^22.1.7",
|
||||
"@angular/compiler": "^22.1.7",
|
||||
"@angular/core": "^22.1.7",
|
||||
"@angular/forms": "^22.1.7",
|
||||
"@angular/platform-browser": "^22.1.7",
|
||||
"@angular/router": "^22.1.7",
|
||||
"chart.js": "^4.5.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -25,11 +25,11 @@
|
||||
"devDependencies": {
|
||||
"@angular/build": "^22.1.8",
|
||||
"@angular/cli": "^22.1.8",
|
||||
"@angular/compiler-cli": "^22.1.0",
|
||||
"@angular/compiler-cli": "^22.1.7",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"jsdom": "^30.1.0",
|
||||
"prettier": "^3.9.8",
|
||||
"typescript": "~6.0.2",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import {authGuard} from './core/guards/auth-guard';
|
||||
import { authGuard } from './core/guards/auth-guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
|
||||
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
|
||||
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
|
||||
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
|
||||
},
|
||||
{
|
||||
path: 'change-password',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/change-password/change-password').then((m) => m.ChangePassword),
|
||||
},
|
||||
{
|
||||
path: 'forgot-password',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
|
||||
},
|
||||
{
|
||||
path: 'reset-password',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
|
||||
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
|
||||
},
|
||||
{
|
||||
path: 'sites',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
|
||||
loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList),
|
||||
},
|
||||
{
|
||||
path: 'sites/:siteId',
|
||||
@@ -23,4 +38,17 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
||||
},
|
||||
{
|
||||
path: 'recommendations',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/recommendations/recommendations').then((m) => m.RecommendationsView),
|
||||
},
|
||||
{
|
||||
path: 'monitoring/sensors',
|
||||
canActivate: [authGuard],
|
||||
data: { role: 'admin' },
|
||||
loadComponent: () =>
|
||||
import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,8 +8,10 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
|
||||
describe('mockApiInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
let useMockFixturesInitial: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
useMockFixturesInitial = environment.useMockFixtures;
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
||||
@@ -21,7 +23,7 @@ describe('mockApiInterceptor', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.useMockFixtures = true;
|
||||
environment.useMockFixtures = useMockFixturesInitial;
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
|
||||
|
||||
export const ALERTS_FIXTURE: Alert[] = [
|
||||
{
|
||||
alert_id: 'ALR-SITE002-1718458320',
|
||||
timestamp: '2026-09-15T11:12:00',
|
||||
alert_id: 5,
|
||||
site_id: 'SITE002',
|
||||
timestamp: '2026-09-15T11:12:00Z',
|
||||
type: 'threshold',
|
||||
severity: 'critical',
|
||||
type: 'outage',
|
||||
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
|
||||
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
|
||||
value: 812.5,
|
||||
threshold: 720.0,
|
||||
metric: 'consumption_kw',
|
||||
prediction_id: null,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE003-1718458321',
|
||||
timestamp: '2026-09-15T11:05:00',
|
||||
alert_id: 4,
|
||||
site_id: 'SITE003',
|
||||
timestamp: '2026-09-15T11:05:00Z',
|
||||
type: 'outage',
|
||||
severity: 'critical',
|
||||
type: 'sensor',
|
||||
message: 'Perte réseau totale sur Data Center Marseille',
|
||||
value: 0,
|
||||
threshold: 0,
|
||||
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
|
||||
value: null,
|
||||
threshold: null,
|
||||
metric: null,
|
||||
prediction_id: null,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE005-1718458322',
|
||||
timestamp: '2026-09-15T10:47:00',
|
||||
alert_id: 3,
|
||||
site_id: 'SITE005',
|
||||
timestamp: '2026-09-15T10:47:00Z',
|
||||
type: 'spike',
|
||||
severity: 'high',
|
||||
type: 'threshold',
|
||||
message: 'Usine Toulouse approche de son seuil de capacité',
|
||||
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
|
||||
value: 410.0,
|
||||
threshold: 480.0,
|
||||
threshold: 260.0,
|
||||
metric: 'consumption_kw',
|
||||
prediction_id: null,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE006-1718458323',
|
||||
timestamp: '2026-09-15T10:30:00',
|
||||
alert_id: 2,
|
||||
site_id: 'SITE006',
|
||||
severity: 'medium',
|
||||
timestamp: '2026-09-15T10:30:00Z',
|
||||
type: 'sensor',
|
||||
message: 'Capteur de température défaillant sur Bureau Lille',
|
||||
value: 0,
|
||||
threshold: 0,
|
||||
severity: 'medium',
|
||||
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
|
||||
value: null,
|
||||
threshold: null,
|
||||
metric: null,
|
||||
prediction_id: null,
|
||||
},
|
||||
{
|
||||
alert_id: 'ALR-SITE004-1718458324',
|
||||
timestamp: '2026-09-15T09:58:00',
|
||||
alert_id: 1,
|
||||
site_id: 'SITE004',
|
||||
severity: 'low',
|
||||
timestamp: '2026-09-15T09:58:00Z',
|
||||
type: 'anomaly',
|
||||
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
|
||||
severity: 'low',
|
||||
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
|
||||
value: 62.0,
|
||||
threshold: 55.0,
|
||||
metric: 'consumption_kwh',
|
||||
prediction_id: 42,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Alert } from '../../shared/models/alert.model';
|
||||
|
||||
const ALERT_API: Alert = {
|
||||
alert_id: 1,
|
||||
site_id: 'site-1',
|
||||
timestamp: '2026-09-16T00:00:00Z',
|
||||
type: 'threshold',
|
||||
severity: 'high',
|
||||
message: 'Dépassement du seuil configuré',
|
||||
value: 812.5,
|
||||
threshold: 720.0,
|
||||
metric: 'consumption_kw',
|
||||
prediction_id: null,
|
||||
};
|
||||
|
||||
describe('AlertsService', () => {
|
||||
let service: AlertsService;
|
||||
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
|
||||
let result: unknown;
|
||||
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
|
||||
let result: Alert[] = [];
|
||||
service.getAlerts().subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
const req = httpMock.expectOne(
|
||||
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
|
||||
);
|
||||
expect(req.request.params.keys()).toEqual([]);
|
||||
req.flush([ALERT_API]);
|
||||
|
||||
req.flush([
|
||||
{
|
||||
alert_id: 'ALR-TEST-1',
|
||||
timestamp: '2026-09-15T12:00:00',
|
||||
site_id: 'SITE001',
|
||||
severity: 'high',
|
||||
type: 'threshold',
|
||||
message: 'Test',
|
||||
value: 100,
|
||||
threshold: 90,
|
||||
},
|
||||
]);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].alert_id).toBe(1);
|
||||
expect(result[0].prediction_id).toBeNull();
|
||||
});
|
||||
|
||||
expect((result as unknown[]).length).toBe(1);
|
||||
it('transmet les filtres site_id et severity en paramètres de requête', () => {
|
||||
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||
expect(req.request.params.get('severity')).toBe('high');
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('ne pose pas de paramètre pour un filtre omis', () => {
|
||||
service.getAlerts({ site_id: 'SITE001' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||
expect(req.request.params.has('severity')).toBe(false);
|
||||
req.flush([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { Service, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Alert } from '../../shared/models/alert.model';
|
||||
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
||||
|
||||
export interface AlertFilters {
|
||||
site_id?: string;
|
||||
severity?: AlertSeverity;
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class AlertsService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getAlerts() {
|
||||
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
|
||||
getAlerts(filters: AlertFilters = {}) {
|
||||
let params = new HttpParams();
|
||||
if (filters.site_id) {
|
||||
params = params.set('site_id', filters.site_id);
|
||||
}
|
||||
if (filters.severity) {
|
||||
params = params.set('severity', filters.severity);
|
||||
}
|
||||
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { RecommendationsService } from './recommendations.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Recommendation } from '../../shared/models/recommendation.model';
|
||||
|
||||
const RECOMMANDATION_API: Recommendation = {
|
||||
recommendation_id: 1,
|
||||
alert_id: 1,
|
||||
action: 'Vérifier la consommation',
|
||||
explanation: 'Pic détecté',
|
||||
rule_reference: 'spike-v1',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('RecommendationsService', () => {
|
||||
let service: RecommendationsService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
service = TestBed.inject(RecommendationsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('liste les recommandations depuis le bon endpoint', () => {
|
||||
let result: Recommendation[] = [];
|
||||
service.getRecommendations().subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush([RECOMMANDATION_API]);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].alert_id).toBe(1);
|
||||
});
|
||||
|
||||
it('décrit une recommandation par son identifiant', () => {
|
||||
service.getRecommendation(42).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations/42`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush({ ...RECOMMANDATION_API, recommendation_id: 42 });
|
||||
});
|
||||
|
||||
it('déclenche la génération en POST avec le site en paramètre de requête', () => {
|
||||
let result: unknown;
|
||||
service.generate('SITE001').subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
|
||||
);
|
||||
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||
expect(req.request.body).toBeNull();
|
||||
req.flush({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
|
||||
|
||||
expect(result).toEqual({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
|
||||
});
|
||||
|
||||
it('génère pour tout le parc quand aucun site n’est donné', () => {
|
||||
service.generate().subscribe();
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
|
||||
);
|
||||
expect(req.request.params.has('site_id')).toBe(false);
|
||||
req.flush({ alerts_examined: 0, recommendations_created: 0, already_present: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Service, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
Recommendation,
|
||||
RecommendationGenerationReport,
|
||||
} from '../../shared/models/recommendation.model';
|
||||
|
||||
@Service()
|
||||
export class RecommendationsService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getRecommendations() {
|
||||
return this.http.get<Recommendation[]>(`${environment.apiUrl}/recommendations`);
|
||||
}
|
||||
|
||||
getRecommendation(recommendationId: number) {
|
||||
return this.http.get<Recommendation>(
|
||||
`${environment.apiUrl}/recommendations/${recommendationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
generate(siteId?: string) {
|
||||
let params = new HttpParams();
|
||||
if (siteId) {
|
||||
params = params.set('site_id', siteId);
|
||||
}
|
||||
return this.http.post<RecommendationGenerationReport>(
|
||||
`${environment.apiUrl}/recommendations/generate`,
|
||||
null,
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { SensorsService } from './sensors.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
describe('SensorsService', () => {
|
||||
let service: SensorsService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
service = TestBed.inject(SensorsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it("appelle l'endpoint /sensors/status et retourne la réponse", () => {
|
||||
let result: unknown;
|
||||
service.getStatus().subscribe((r) => (result = r));
|
||||
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/sensors/status`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
|
||||
req.flush({
|
||||
timestamp: '2026-09-18T08:00:00',
|
||||
sites: [
|
||||
{
|
||||
site_id: 'SITE001',
|
||||
site_name: 'Test',
|
||||
overall: 'ok',
|
||||
sensors: {
|
||||
consumption: { status: 'ok', since: null },
|
||||
electrical: { status: 'ok', since: null },
|
||||
temperature: { status: 'ok', since: null },
|
||||
humidity: { status: 'ok', since: null },
|
||||
network: { status: 'ok', since: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect((result as { sites: unknown[] }).sites.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Service, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {SensorStatusResponse} from '../../shared/models/sensor-status.model';
|
||||
|
||||
@Service()
|
||||
export class SensorsService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getStatus() {
|
||||
return this.http.get<SensorStatusResponse>(`${environment.apiUrl}/sensors/status`);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard__actions">
|
||||
@if (auth.principal()?.role === 'admin') {
|
||||
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
|
||||
}
|
||||
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
||||
<a routerLink="/recommendations" class="ev-link">Recommandations</a>
|
||||
<ev-button
|
||||
class="logout-button"
|
||||
variant="secondary"
|
||||
@@ -24,87 +28,120 @@
|
||||
@if (statsError(); as message) {
|
||||
<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) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
@if (stats(); as s) {
|
||||
<section class="overview">
|
||||
<ev-card class="card card--gauge">
|
||||
<span class="card__label">Consommation vs capacité</span>
|
||||
<p class="dashboard__status">
|
||||
<span class="dashboard__pulse" aria-hidden="true"></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
|
||||
[consumption]="s.total_consumption_kw"
|
||||
[capacity]="s.total_capacity_kw"
|
||||
/>
|
||||
<span class="card__value"
|
||||
>{{ s.total_consumption_kw | number: '1.0-1' }} /
|
||||
{{ s.total_capacity_kw | number }} kW</span
|
||||
<span class="kpi__value">
|
||||
{{ s.total_consumption_kw | number: '1.0-1' }}
|
||||
<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>
|
||||
|
||||
<ev-card class="card">
|
||||
<span class="card__label">Charge moyenne du parc</span>
|
||||
<span class="card__value">{{ s.average_load_percent }} %</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
|
||||
<div
|
||||
class="progress-bar"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
[attr.aria-valuenow]="s.average_load_percent"
|
||||
>
|
||||
<div
|
||||
class="progress-bar__fill"
|
||||
[class]="'progress-bar__fill--' + loadTone(s.average_load_percent)"
|
||||
[style.width.%]="s.average_load_percent"
|
||||
></div>
|
||||
</div>
|
||||
<span class="kpi__hint">{{ loadHint(s.average_load_percent) }}</span>
|
||||
</ev-card>
|
||||
|
||||
<ev-card class="card">
|
||||
<span class="card__label">Sites suivis</span>
|
||||
<span class="card__value">{{ s.total_sites }}</span>
|
||||
<ev-card class="kpi">
|
||||
<span class="kpi__label">Sites suivis</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>
|
||||
</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) {
|
||||
<section class="alerts-section">
|
||||
<h2>Alertes actives</h2>
|
||||
<ul class="alerts-list">
|
||||
@for (alert of alerts(); track alert.alert_id) {
|
||||
<li class="alert-item">
|
||||
<ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
|
||||
<span class="alert-item__message">{{ alert.message }}</span>
|
||||
</li>
|
||||
<div class="dashboard__grid">
|
||||
<div class="dashboard__main">
|
||||
@if (stats(); as s) {
|
||||
<section class="chart-section">
|
||||
<h2>Charge par site</h2>
|
||||
<ev-card class="chart-card">
|
||||
<app-site-load-chart [sites]="s.sites" />
|
||||
</ev-card>
|
||||
</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) {
|
||||
<section class="predictions-section">
|
||||
<h2>Prévisions de consommation</h2>
|
||||
<ul class="predictions-list">
|
||||
@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>
|
||||
}
|
||||
<aside class="dashboard__side" aria-label="Alertes actives">
|
||||
<app-alert-feed />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
|
||||
.dashboard__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.dashboard__brand {
|
||||
@@ -20,8 +22,9 @@
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,19 +34,69 @@
|
||||
|
||||
.dashboard__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dashboard__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.banner-error {
|
||||
@@ -54,107 +107,124 @@ h2 {
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.25rem;
|
||||
gap: 0.35rem;
|
||||
.kpi {
|
||||
position: relative;
|
||||
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;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card__label {
|
||||
font-size: 0.8rem;
|
||||
.kpi__label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.card__value {
|
||||
font-size: 1.6rem;
|
||||
.kpi__value {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.kpi__hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.kpi__link {
|
||||
margin-top: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
height: 8px;
|
||||
margin: var(--space-1) 0;
|
||||
background: var(--color-border-light);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-success);
|
||||
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 {
|
||||
margin-bottom: 2.5rem;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.alerts-list {
|
||||
list-style: none;
|
||||
.chart-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.dashboard__empty {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-danger-bg);
|
||||
border: 1px solid var(--color-danger-border);
|
||||
}
|
||||
|
||||
.alert-item__message {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.predictions-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.prediction-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.prediction-item__site {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prediction-item__value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prediction-item__target {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
font-size: var(--font-size-sm);
|
||||
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 { 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 { StatsService } from '../../core/services/stats.service';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
import { SitesService } from '../../core/services/sites.service';
|
||||
import { PredictionsService } from '../../core/services/predictions.service';
|
||||
import {AuthService} from '../../core/services/auth.service';
|
||||
import {Router, provideRouter} from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
vi.mock('chart.js', () => {
|
||||
class ChartMock {
|
||||
@@ -18,66 +19,73 @@ vi.mock('chart.js', () => {
|
||||
return { Chart: ChartMock, registerables: [] };
|
||||
});
|
||||
|
||||
const STATS = { total_sites: 7, sites: [] };
|
||||
|
||||
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', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('charge les stats, les alertes et les prévisions au démarrage', async () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
|
||||
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictions },
|
||||
provideRouter([]),
|
||||
],
|
||||
it('charge les stats et les prévisions au démarrage', async () => {
|
||||
const { fixture, statsMock, predictions } = setup({
|
||||
predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]),
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
// laisse le timer(0, ...) se déclencher avant de vérifier
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(statsMock.getSummary).toHaveBeenCalled();
|
||||
expect(alertsMock.getAlerts).toHaveBeenCalled();
|
||||
expect(predictions.getPredictions).toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||
expect(fixture.componentInstance.predictions().length).toBe(1);
|
||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||
expect(fixture.componentInstance.alertsError()).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", () => {
|
||||
vi.useFakeTimers();
|
||||
const statsMock = {
|
||||
getSummary: vi
|
||||
.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([]),
|
||||
],
|
||||
const { fixture, statsMock } = setup({
|
||||
stats: throwError(() => new Error('API injoignable')),
|
||||
});
|
||||
statsMock.getSummary
|
||||
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||
.mockReturnValue(of(STATS));
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
@@ -91,45 +99,11 @@ describe('Dashboard', () => {
|
||||
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", () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
const predictions = {
|
||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictions },
|
||||
provideRouter([]),
|
||||
],
|
||||
const { fixture } = setup({
|
||||
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
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", () => {
|
||||
vi.useFakeTimers();
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
const predictions = {
|
||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictions },
|
||||
provideRouter([]),
|
||||
],
|
||||
const { fixture } = setup({
|
||||
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
||||
|
||||
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
|
||||
@@ -168,105 +128,106 @@ describe('Dashboard', () => {
|
||||
});
|
||||
|
||||
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() };
|
||||
const authMock = {
|
||||
logout: vi.fn().mockReturnValue(of(undefined)),
|
||||
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);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
provideRouter([]),
|
||||
],
|
||||
fixture.nativeElement.querySelector('.logout-button').click();
|
||||
|
||||
expect(authMock.logout).toHaveBeenCalled();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
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 = {
|
||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||
clearSession: vi.fn(),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
provideRouter([]),
|
||||
],
|
||||
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);
|
||||
|
||||
fixture.nativeElement.querySelector('.logout-button').click();
|
||||
|
||||
expect(authMock.clearSession).toHaveBeenCalled();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
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.clearSession).toHaveBeenCalled();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
it('distingue le ton des sévérités high et critical', () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
provideRouter([]),
|
||||
],
|
||||
it('affiche l’heure du dernier relevé et les indicateurs du parc', () => {
|
||||
vi.useFakeTimers();
|
||||
const { fixture } = setup({
|
||||
stats: of({
|
||||
timestamp: '2026-09-18T09:00:00Z',
|
||||
total_sites: 7,
|
||||
total_consumption_kw: 1234.5,
|
||||
total_capacity_kw: 5000,
|
||||
average_load_percent: 24.7,
|
||||
sites: [],
|
||||
}),
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
expect(dashboard.badgeToneForSeverity('low')).toBe('success');
|
||||
expect(dashboard.badgeToneForSeverity('medium')).toBe('warning');
|
||||
expect(dashboard.badgeToneForSeverity('high')).toBe('danger');
|
||||
expect(dashboard.badgeToneForSeverity('critical')).toBe('critical');
|
||||
expect(dashboard.badgeToneForSeverity('high')).not.toBe(
|
||||
dashboard.badgeToneForSeverity('critical'),
|
||||
);
|
||||
expect(dashboard.loadTone(69.9)).toBe('success');
|
||||
expect(dashboard.loadTone(70)).toBe('warning');
|
||||
expect(dashboard.loadTone(89.9)).toBe('warning');
|
||||
expect(dashboard.loadTone(90)).toBe('danger');
|
||||
expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc');
|
||||
});
|
||||
|
||||
it('distingue le ton des statuts de prévision', () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
const { fixture } = setup();
|
||||
const dashboard = fixture.componentInstance;
|
||||
|
||||
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
|
||||
|
||||
@@ -6,11 +6,10 @@ import { Router, RouterLink } from '@angular/router';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
||||
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 { AuthService } from '../../core/services/auth.service';
|
||||
import { StatsSummary } from '../../shared/models/stats.model';
|
||||
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
||||
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
|
||||
import { Card } from '../../shared/components/ui/card/card';
|
||||
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||
@@ -22,22 +21,19 @@ const REFRESH_INTERVAL_MS = 10000;
|
||||
const UNAVAILABLE_MESSAGE =
|
||||
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
||||
|
||||
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
||||
low: 'success',
|
||||
medium: 'warning',
|
||||
high: 'danger',
|
||||
critical: 'critical',
|
||||
};
|
||||
|
||||
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
|
||||
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
|
||||
// tomber sur `undefined` si ce statut apparaît un jour.
|
||||
// `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` :
|
||||
// mieux vaut un ton défini que `undefined` le jour où ce statut apparaît.
|
||||
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
||||
available: 'success',
|
||||
insufficient_data: 'warning',
|
||||
error: 'danger',
|
||||
};
|
||||
|
||||
const SEUIL_CHARGE_SOUTENUE = 70;
|
||||
const SEUIL_CHARGE_CRITIQUE = 90;
|
||||
|
||||
export type LoadTone = 'success' | 'warning' | 'danger';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
@@ -47,6 +43,7 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
||||
RouterLink,
|
||||
ConsumptionGauge,
|
||||
SiteLoadChart,
|
||||
AlertFeed,
|
||||
Card,
|
||||
EvAlert,
|
||||
Badge,
|
||||
@@ -58,32 +55,20 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
||||
})
|
||||
export class Dashboard implements OnInit {
|
||||
private statsService = inject(StatsService);
|
||||
private alertsService = inject(AlertsService);
|
||||
public auth = inject(AuthService);
|
||||
private predictionsService = inject(PredictionsService);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
stats = signal<StatsSummary | null>(null);
|
||||
alerts = signal<Alert[]>([]);
|
||||
predictions = signal<SitePredictionSummary[]>([]);
|
||||
|
||||
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
|
||||
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
|
||||
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
|
||||
// Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en
|
||||
// silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur.
|
||||
statsError = signal<string | null>(null);
|
||||
alertsError = signal<string | null>(null);
|
||||
predictionsError = signal<string | null>(null);
|
||||
|
||||
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
|
||||
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
|
||||
this.predictionsService
|
||||
@@ -99,7 +84,9 @@ export class Dashboard implements OnInit {
|
||||
timer(0, REFRESH_INTERVAL_MS)
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
||||
this.statsService
|
||||
.getSummary()
|
||||
.pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
||||
),
|
||||
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 {
|
||||
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 {
|
||||
this.auth.logout().subscribe({
|
||||
next: () => this.router.navigate(['/login']),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<div class="sensor-status">
|
||||
<nav class="ev-breadcrumb">
|
||||
<a routerLink="/dashboard">Tableau de bord</a>
|
||||
</nav>
|
||||
|
||||
<header class="sensor-status__header">
|
||||
<a routerLink="/dashboard" class="ev-brand-link">
|
||||
<ev-brand class="sensor-status__logo" />
|
||||
</a>
|
||||
<div>
|
||||
<h1>Supervision des capteurs</h1>
|
||||
<p class="sensor-status__subtitle">État de santé par capteur et par site</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
@if (data(); as d) {
|
||||
<div class="sites-grid">
|
||||
@for (site of d.sites; track site.site_id) {
|
||||
<ev-card class="site-card">
|
||||
<div class="site-card__header">
|
||||
<span class="site-card__name">{{ site.site_name }}</span>
|
||||
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
|
||||
</div>
|
||||
|
||||
<ul class="sensor-list">
|
||||
@for (entry of sensorEntries; track entry[0]) {
|
||||
@let diagnostic = sensorOf(site.sensors, entry[0]);
|
||||
<li class="sensor-item">
|
||||
<span class="sensor-dot" [class]="'sensor-dot--' + diagnostic.status"></span>
|
||||
<span class="sensor-item__label">{{ entry[1] }}</span>
|
||||
@if (diagnostic.status === 'failing') {
|
||||
<span class="sensor-item__since">
|
||||
@if (diagnostic.since; as since) {
|
||||
dernière lecture le {{ since | date: 'dd/MM/yyyy HH:mm' }}
|
||||
} @else {
|
||||
aucune lecture reçue
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</ev-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--color-text);
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sensor-status__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-status__logo {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.sensor-status__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.banner-error {
|
||||
display: block;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.sites-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.site-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.site-card__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sensor-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sensor-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sensor-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--ok {
|
||||
background: var(--color-success);
|
||||
}
|
||||
&--failing {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-item__label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sensor-item__since {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { SensorStatusView } from './sensor-status';
|
||||
import { SensorsService } from '../../../core/services/sensors.service';
|
||||
import { SiteSensors } from '../../../shared/models/sensor-status.model';
|
||||
import {provideRouter} from '@angular/router';
|
||||
|
||||
const OK_SENSORS: SiteSensors = {
|
||||
consumption: { status: 'ok', since: null },
|
||||
electrical: { status: 'ok', since: null },
|
||||
temperature: { status: 'ok', since: null },
|
||||
humidity: { status: 'ok', since: null },
|
||||
network: { status: 'ok', since: null },
|
||||
};
|
||||
|
||||
describe('SensorStatusView', () => {
|
||||
let sensorsMock: { getStatus: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
sensorsMock = { getStatus: vi.fn() };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SensorStatusView],
|
||||
providers: [
|
||||
{ provide: SensorsService, useValue: sensorsMock },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('charge et affiche les données au démarrage', () => {
|
||||
sensorsMock.getStatus.mockReturnValue(
|
||||
of({
|
||||
timestamp: '2026-09-18T08:00:00',
|
||||
sites: [
|
||||
{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'ok', sensors: OK_SENSORS },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const fixture = TestBed.createComponent(SensorStatusView);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.data()?.sites.length).toBe(1);
|
||||
expect(fixture.componentInstance.error()).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Bureau Test');
|
||||
});
|
||||
|
||||
it("affiche un message d'erreur si l'appel échoue", () => {
|
||||
sensorsMock.getStatus.mockReturnValue(throwError(() => new Error('boom')));
|
||||
|
||||
const fixture = TestBed.createComponent(SensorStatusView);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.error()).toBe(
|
||||
'État des capteurs indisponible, réessayez plus tard.'
|
||||
);
|
||||
expect(fixture.componentInstance.data()).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('État des capteurs indisponible');
|
||||
});
|
||||
|
||||
it('associe le bon ton de badge à chaque statut global', () => {
|
||||
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
|
||||
const fixture = TestBed.createComponent(SensorStatusView);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component.badgeToneForOverall('ok')).toBe('success');
|
||||
expect(component.badgeToneForOverall('degraded')).toBe('warning');
|
||||
expect(component.badgeToneForOverall('critical')).toBe('critical');
|
||||
expect(component.badgeToneForOverall('inconnu')).toBe('neutral');
|
||||
});
|
||||
|
||||
it('retourne le bon diagnostic via sensorOf', () => {
|
||||
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
|
||||
const fixture = TestBed.createComponent(SensorStatusView);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null });
|
||||
});
|
||||
|
||||
it('affiche la date de la dernière lecture reçue pour un capteur en panne', () => {
|
||||
const sensors: SiteSensors = {
|
||||
...OK_SENSORS,
|
||||
temperature: { status: 'failing', since: '2026-09-18T08:00:00' },
|
||||
};
|
||||
sensorsMock.getStatus.mockReturnValue(
|
||||
of({
|
||||
timestamp: '2026-09-18T08:00:00',
|
||||
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }],
|
||||
})
|
||||
);
|
||||
|
||||
const fixture = TestBed.createComponent(SensorStatusView);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('dernière lecture le');
|
||||
expect(fixture.nativeElement.textContent).toContain('18/09/2026 08:00');
|
||||
});
|
||||
|
||||
it("annonce l'absence de lecture quand un site n'en a jamais reçu", () => {
|
||||
const sensors: SiteSensors = {
|
||||
consumption: { status: 'failing', since: null },
|
||||
electrical: { status: 'failing', since: null },
|
||||
temperature: { status: 'failing', since: null },
|
||||
humidity: { status: 'failing', since: null },
|
||||
network: { status: 'failing', since: null },
|
||||
};
|
||||
sensorsMock.getStatus.mockReturnValue(
|
||||
of({
|
||||
timestamp: '2026-09-18T08:00:00',
|
||||
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'critical', sensors }],
|
||||
})
|
||||
);
|
||||
|
||||
const fixture = TestBed.createComponent(SensorStatusView);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('aucune lecture reçue');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('dernière lecture le');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { catchError, EMPTY, Observable } from 'rxjs';
|
||||
import {Badge, BadgeTone} from '../../../shared/components/ui/badge/badge';
|
||||
import {Card} from '../../../shared/components/ui/card/card';
|
||||
import {Alert} from '../../../shared/components/ui/alert/alert';
|
||||
import {Brand} from '../../../shared/components/ui/brand/brand';
|
||||
import {SensorsService} from '../../../core/services/sensors.service';
|
||||
import {SensorDiagnostic, SensorStatusResponse} from '../../../shared/models/sensor-status.model';
|
||||
import { DatePipe } from '@angular/common';
|
||||
|
||||
const UNAVAILABLE_MESSAGE = 'État des capteurs indisponible, réessayez plus tard.';
|
||||
|
||||
const SENSOR_LABELS: Record<string, string> = {
|
||||
consumption: 'Consommation',
|
||||
electrical: 'Électrique',
|
||||
temperature: 'Température',
|
||||
humidity: 'Humidité',
|
||||
network: 'Réseau',
|
||||
};
|
||||
|
||||
const TON_PAR_OVERALL: Record<string, BadgeTone> = {
|
||||
ok: 'success',
|
||||
degraded: 'warning',
|
||||
critical: 'critical',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-sensor-status',
|
||||
standalone: true,
|
||||
imports: [RouterLink, Card, Alert, Badge, Brand, DatePipe],
|
||||
templateUrl: './sensor-status.html',
|
||||
styleUrl: './sensor-status.scss',
|
||||
})
|
||||
export class SensorStatusView implements OnInit {
|
||||
private sensorsService = inject(SensorsService);
|
||||
|
||||
data = signal<SensorStatusResponse | null>(null);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
readonly sensorEntries = Object.entries(SENSOR_LABELS);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.sensorsService
|
||||
.getStatus()
|
||||
.pipe(catchError(() => this.reportUnavailable()))
|
||||
.subscribe((response) => {
|
||||
this.error.set(null);
|
||||
this.data.set(response);
|
||||
});
|
||||
}
|
||||
|
||||
sensorOf(sensors: Record<string, SensorDiagnostic>, key: string): SensorDiagnostic {
|
||||
return sensors[key];
|
||||
}
|
||||
|
||||
badgeToneForOverall(overall: string): BadgeTone {
|
||||
return TON_PAR_OVERALL[overall] ?? 'neutral';
|
||||
}
|
||||
|
||||
private reportUnavailable(): Observable<never> {
|
||||
this.error.set(UNAVAILABLE_MESSAGE);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="recommendations">
|
||||
<nav class="ev-breadcrumb">
|
||||
<a routerLink="/dashboard">Tableau de bord</a>
|
||||
</nav>
|
||||
|
||||
<header class="recommendations__header">
|
||||
<a routerLink="/dashboard" class="ev-brand-link">
|
||||
<ev-brand class="recommendations__logo" />
|
||||
</a>
|
||||
<div>
|
||||
<h1>Recommandations</h1>
|
||||
<p class="recommendations__subtitle">
|
||||
Actions proposées par le moteur de règles à partir des alertes
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="recommendations__toolbar">
|
||||
<label class="recommendations__filter">
|
||||
<span class="form-label">Site</span>
|
||||
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
|
||||
<option value="" [selected]="!siteFilter()">Tous les sites</option>
|
||||
@for (site of sites(); track site.site_id) {
|
||||
<option [value]="site.site_id" [selected]="site.site_id === siteFilter()">
|
||||
{{ site.site_name }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (isAdmin()) {
|
||||
<ev-button
|
||||
variant="secondary"
|
||||
[fullWidth]="false"
|
||||
[disabled]="generating()"
|
||||
data-testid="generate"
|
||||
(click)="onGenerate()"
|
||||
>
|
||||
{{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }}
|
||||
</ev-button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (generationReport(); as report) {
|
||||
<ev-alert severity="success" class="recommendations__banner">{{ bilan(report) }}.</ev-alert>
|
||||
}
|
||||
@if (generationError(); as message) {
|
||||
<ev-alert severity="danger" class="recommendations__banner">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
@if (alertId(); as id) {
|
||||
<p class="recommendations__focus">
|
||||
Alerte n° {{ id }} ·
|
||||
<a routerLink="/recommendations" class="ev-link">Toutes les recommandations</a>
|
||||
</p>
|
||||
}
|
||||
|
||||
<app-recommendation-list [siteId]="siteFilter()" [alertId]="alertId()" [sites]="sites()" />
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--color-text);
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.recommendations__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.recommendations__logo {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.recommendations__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.recommendations__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.recommendations__filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 14rem;
|
||||
|
||||
.form-label {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.recommendations__banner {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.recommendations__focus {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { vi } from 'vitest';
|
||||
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||
import { RecommendationsView, parseAlertId } from './recommendations';
|
||||
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
|
||||
import { SitesService } from '../../core/services/sites.service';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
import { RecommendationsService } from '../../core/services/recommendations.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
const SITES = [
|
||||
{
|
||||
site_id: 'SITE001',
|
||||
site_name: 'Usine Nantes',
|
||||
site_type: 'industriel',
|
||||
location: 'Nantes',
|
||||
capacity_kw: 500,
|
||||
status: 'actif',
|
||||
},
|
||||
];
|
||||
|
||||
const BILAN = { alerts_examined: 2, recommendations_created: 3, already_present: 1 };
|
||||
|
||||
function setup(options: { query?: Record<string, string>; role?: string } = {}) {
|
||||
const query = options.query ?? {};
|
||||
const queryParamMap = new BehaviorSubject(convertToParamMap(query));
|
||||
const generate = vi.fn().mockReturnValue(of(BILAN));
|
||||
const getRecommendations = vi.fn().mockReturnValue(of([]));
|
||||
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||
TestBed.configureTestingModule({
|
||||
imports: [RecommendationsView],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { queryParamMap, snapshot: { queryParamMap: convertToParamMap(query) } },
|
||||
},
|
||||
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of(SITES)) } },
|
||||
{ provide: AlertsService, useValue: { getAlerts } },
|
||||
{ provide: RecommendationsService, useValue: { getRecommendations, generate } },
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: { principal: vi.fn().mockReturnValue({ role: options.role ?? 'lecteur' }) },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(RecommendationsView);
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
return { fixture, queryParamMap, generate, getRecommendations, getAlerts };
|
||||
}
|
||||
|
||||
function listeEnfant(fixture: ReturnType<typeof setup>['fixture']): RecommendationList {
|
||||
return fixture.debugElement.query(By.directive(RecommendationList)).componentInstance;
|
||||
}
|
||||
|
||||
describe('parseAlertId', () => {
|
||||
it("n'accepte qu'un entier strictement positif", () => {
|
||||
expect(parseAlertId('12')).toBe(12);
|
||||
expect(parseAlertId('0')).toBeNull();
|
||||
expect(parseAlertId('-3')).toBeNull();
|
||||
expect(parseAlertId('abc')).toBeNull();
|
||||
expect(parseAlertId('12abc')).toBeNull();
|
||||
expect(parseAlertId(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RecommendationsView', () => {
|
||||
it("cible l'alerte donnée par ?alert= et la transmet à la liste", () => {
|
||||
const { fixture } = setup({ query: { alert: '12' } });
|
||||
|
||||
expect(fixture.componentInstance.alertId()).toBe(12);
|
||||
expect(listeEnfant(fixture).alertId()).toBe(12);
|
||||
expect(fixture.nativeElement.textContent).toContain('Alerte n° 12');
|
||||
expect(fixture.nativeElement.querySelector('a[href="/recommendations"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('ignore un paramètre alert invalide', () => {
|
||||
const { fixture } = setup({ query: { alert: 'abc' } });
|
||||
|
||||
expect(fixture.componentInstance.alertId()).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Alerte n°');
|
||||
});
|
||||
|
||||
it('applique le site donné par ?site= au filtre et à la liste', () => {
|
||||
const { fixture, getAlerts } = setup({ query: { site: 'SITE001' } });
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||
const option = fixture.nativeElement.querySelector(
|
||||
'option[value="SITE001"]',
|
||||
) as HTMLOptionElement;
|
||||
expect(option.selected).toBe(true);
|
||||
});
|
||||
|
||||
it('relance la liste sur le site choisi dans le filtre', () => {
|
||||
const { fixture, getAlerts } = setup();
|
||||
const select = fixture.nativeElement.querySelector(
|
||||
'[data-testid="site-filter"]',
|
||||
) as HTMLSelectElement;
|
||||
|
||||
select.value = 'SITE001';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getAlerts).toHaveBeenLastCalledWith({ site_id: 'SITE001' });
|
||||
expect(listeEnfant(fixture).siteId()).toBe('SITE001');
|
||||
});
|
||||
|
||||
it('cache le bouton de génération aux lecteurs', () => {
|
||||
const { fixture } = setup({ role: 'lecteur' });
|
||||
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="generate"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('permet à un admin de générer pour le site filtré, affiche le bilan et recharge la liste', () => {
|
||||
const { fixture, generate, getRecommendations } = setup({
|
||||
role: 'admin',
|
||||
query: { site: 'SITE001' },
|
||||
});
|
||||
|
||||
fixture.nativeElement.querySelector('[data-testid="generate"]').click();
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(generate).toHaveBeenCalledWith('SITE001');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'3 recommandations créées, 1 déjà présente, 2 alertes examinées.',
|
||||
);
|
||||
expect(getRecommendations).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.componentInstance.generating()).toBe(false);
|
||||
});
|
||||
|
||||
it('génère pour tout le parc quand aucun site n’est filtré', () => {
|
||||
const { fixture, generate } = setup({ role: 'admin' });
|
||||
|
||||
fixture.componentInstance.onGenerate();
|
||||
|
||||
expect(generate).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("signale l'échec de la génération sans casser la page", () => {
|
||||
const { fixture, generate } = setup({ role: 'admin' });
|
||||
generate.mockReturnValue(throwError(() => new Error('403')));
|
||||
|
||||
fixture.componentInstance.onGenerate();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.generationError()).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'La génération des recommandations a échoué',
|
||||
);
|
||||
expect(fixture.componentInstance.generating()).toBe(false);
|
||||
});
|
||||
|
||||
it('accorde le bilan au singulier', () => {
|
||||
const { fixture } = setup();
|
||||
|
||||
expect(
|
||||
fixture.componentInstance.bilan({
|
||||
alerts_examined: 1,
|
||||
recommendations_created: 1,
|
||||
already_present: 0,
|
||||
}),
|
||||
).toBe('1 recommandation créée, 0 déjà présente, 1 alerte examinée');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, computed, inject, signal, viewChild } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { catchError, map, of } from 'rxjs';
|
||||
import { SitesService } from '../../core/services/sites.service';
|
||||
import { RecommendationsService } from '../../core/services/recommendations.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { Site } from '../../shared/models/site.model';
|
||||
import { RecommendationGenerationReport } from '../../shared/models/recommendation.model';
|
||||
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
|
||||
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||
import { Brand } from '../../shared/components/ui/brand/brand';
|
||||
import { Button } from '../../shared/components/ui/button/button';
|
||||
|
||||
const GENERATION_FAILED_MESSAGE =
|
||||
'La génération des recommandations a échoué, réessayez plus tard.';
|
||||
|
||||
export function parseAlertId(raw: string | null): number | null {
|
||||
return raw !== null && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
|
||||
}
|
||||
|
||||
function pluriel(nombre: number, singulier: string, plurielForme: string): string {
|
||||
return `${nombre} ${nombre > 1 ? plurielForme : singulier}`;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-recommendations',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RecommendationList, EvAlert, Brand, Button],
|
||||
templateUrl: './recommendations.html',
|
||||
styleUrl: './recommendations.scss',
|
||||
})
|
||||
export class RecommendationsView {
|
||||
private route = inject(ActivatedRoute);
|
||||
private sitesService = inject(SitesService);
|
||||
private recommendationsService = inject(RecommendationsService);
|
||||
private auth = inject(AuthService);
|
||||
|
||||
alertId = toSignal(
|
||||
this.route.queryParamMap.pipe(map((params) => parseAlertId(params.get('alert')))),
|
||||
{ initialValue: null },
|
||||
);
|
||||
siteFilter = signal<string | null>(this.route.snapshot.queryParamMap.get('site'));
|
||||
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
|
||||
initialValue: [] as Site[],
|
||||
});
|
||||
|
||||
list = viewChild.required(RecommendationList);
|
||||
|
||||
isAdmin = computed(() => this.auth.principal()?.role === 'admin');
|
||||
generating = signal(false);
|
||||
generationReport = signal<RecommendationGenerationReport | null>(null);
|
||||
generationError = signal<string | null>(null);
|
||||
|
||||
onSiteChange(event: Event): void {
|
||||
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
|
||||
}
|
||||
|
||||
onGenerate(): void {
|
||||
if (this.generating()) {
|
||||
return;
|
||||
}
|
||||
this.generating.set(true);
|
||||
this.generationError.set(null);
|
||||
this.recommendationsService.generate(this.siteFilter() ?? undefined).subscribe({
|
||||
next: (report) => {
|
||||
this.generating.set(false);
|
||||
this.generationReport.set(report);
|
||||
this.list().reload();
|
||||
},
|
||||
error: () => {
|
||||
this.generating.set(false);
|
||||
this.generationError.set(GENERATION_FAILED_MESSAGE);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
bilan(report: RecommendationGenerationReport): string {
|
||||
return [
|
||||
pluriel(report.recommendations_created, 'recommandation créée', 'recommandations créées'),
|
||||
pluriel(report.already_present, 'déjà présente', 'déjà présentes'),
|
||||
pluriel(report.alerts_examined, 'alerte examinée', 'alertes examinées'),
|
||||
].join(', ');
|
||||
}
|
||||
}
|
||||
@@ -81,5 +81,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
<section class="recommendations-section">
|
||||
<h2>Recommandations</h2>
|
||||
<app-recommendation-list [siteId]="siteId() ?? null" [sites]="siteAsList()" />
|
||||
<a
|
||||
routerLink="/recommendations"
|
||||
[queryParams]="{ site: siteId() }"
|
||||
class="ev-link recommendations-section__link"
|
||||
>Voir dans la vue recommandations</a
|
||||
>
|
||||
</section>
|
||||
|
||||
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||
</div>
|
||||
|
||||
@@ -117,3 +117,12 @@ h2 {
|
||||
.chart-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.recommendations-section {
|
||||
margin: 2.5rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.recommendations-section__link {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||
import { SiteDetail } from './site-detail';
|
||||
import { SitesService } from '../../../core/services/sites.service';
|
||||
import { ReadingsService } from '../../../core/services/readings.service';
|
||||
import { AlertsService } from '../../../core/services/alerts.service';
|
||||
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||
|
||||
const SITE = {
|
||||
site_id: 'SITE001',
|
||||
@@ -69,6 +71,7 @@ function setup(
|
||||
readingsMock: Partial<ReadingsService>,
|
||||
) {
|
||||
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
|
||||
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SiteDetail],
|
||||
providers: [
|
||||
@@ -76,9 +79,14 @@ function setup(
|
||||
{ provide: ActivatedRoute, useValue: { paramMap } },
|
||||
{ provide: SitesService, useValue: sitesMock },
|
||||
{ provide: ReadingsService, useValue: readingsMock },
|
||||
{ provide: AlertsService, useValue: { getAlerts } },
|
||||
{
|
||||
provide: RecommendationsService,
|
||||
useValue: { getRecommendations: vi.fn().mockReturnValue(of([])) },
|
||||
},
|
||||
],
|
||||
});
|
||||
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
|
||||
return { fixture: TestBed.createComponent(SiteDetail), paramMap, getAlerts };
|
||||
}
|
||||
|
||||
describe('SiteDetail', () => {
|
||||
@@ -261,6 +269,27 @@ describe('SiteDetail', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('demande les recommandations du site consulté à travers ses alertes', () => {
|
||||
const { fixture, getAlerts } = setup(
|
||||
'SITE001',
|
||||
{
|
||||
getSite: vi.fn().mockReturnValue(of(SITE)),
|
||||
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
|
||||
},
|
||||
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||
expect(fixture.nativeElement.querySelector('app-recommendation-list')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Recommandations');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('a[href="/recommendations?site=SITE001"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
|
||||
const getHistory = vi.fn().mockReturnValue(of([]));
|
||||
const { fixture } = setup(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
|
||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
|
||||
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
||||
import { RecommendationList } from '../../../shared/components/recommendation-list/recommendation-list';
|
||||
|
||||
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
||||
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
||||
@@ -96,7 +97,16 @@ export interface MetricView {
|
||||
@Component({
|
||||
selector: 'app-site-detail',
|
||||
standalone: true,
|
||||
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
|
||||
imports: [
|
||||
RouterLink,
|
||||
Card,
|
||||
Alert,
|
||||
Badge,
|
||||
Brand,
|
||||
ConsumptionGauge,
|
||||
ReadingHistoryChart,
|
||||
RecommendationList,
|
||||
],
|
||||
templateUrl: './site-detail.html',
|
||||
styleUrl: './site-detail.scss',
|
||||
})
|
||||
@@ -117,6 +127,11 @@ export class SiteDetail {
|
||||
|
||||
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
||||
|
||||
siteAsList = computed<Site[]>(() => {
|
||||
const site = this.site();
|
||||
return site ? [site] : [];
|
||||
});
|
||||
|
||||
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
|
||||
|
||||
consumptionLabel = computed(() => {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
<ev-card class="table-card">
|
||||
<table class="sites-table">
|
||||
<ev-card class="ev-table-card">
|
||||
<table class="ev-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
@@ -36,7 +36,9 @@
|
||||
<td>{{ site.site_type }}</td>
|
||||
<td>{{ site.location || '-' }}</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>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -32,32 +32,3 @@
|
||||
display: block;
|
||||
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 AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
||||
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
|
||||
|
||||
export interface Alert {
|
||||
alert_id: string;
|
||||
timestamp: string;
|
||||
alert_id: number;
|
||||
site_id: string;
|
||||
severity: AlertSeverity;
|
||||
timestamp: string;
|
||||
type: AlertType;
|
||||
severity: AlertSeverity;
|
||||
message: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
value: number | null;
|
||||
threshold: number | null;
|
||||
metric: AlertMetric | null;
|
||||
prediction_id: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { libelleRegle, tonRegle } from './recommendation-presentation';
|
||||
|
||||
describe('recommendation-presentation', () => {
|
||||
it('traduit les sept règles connues du moteur', () => {
|
||||
expect(libelleRegle('spike-delestage-v1')).toBe('Délestage');
|
||||
expect(libelleRegle('threshold-reduction-v1')).toBe('Réduction de puissance');
|
||||
expect(libelleRegle('outage-secours-v1')).toBe('Alimentation de secours');
|
||||
expect(libelleRegle('sensor-maintenance-v1')).toBe('Maintenance capteur');
|
||||
expect(libelleRegle('anomaly-verification-v1')).toBe('Vérification');
|
||||
expect(libelleRegle('escalade-astreinte-v1')).toBe('Escalade astreinte');
|
||||
expect(libelleRegle('contrat-puissance-v1')).toBe('Contrat de puissance');
|
||||
});
|
||||
|
||||
it('affiche telle quelle une référence de règle inconnue', () => {
|
||||
expect(libelleRegle('spike-delestage-v2')).toBe('spike-delestage-v2');
|
||||
});
|
||||
|
||||
it("réserve le ton critique à l'escalade vers l'astreinte", () => {
|
||||
expect(tonRegle('escalade-astreinte-v1')).toBe('critical');
|
||||
expect(tonRegle('spike-delestage-v1')).toBe('neutral');
|
||||
expect(tonRegle('inconnue-v9')).toBe('neutral');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BadgeTone } from '../components/ui/badge/badge';
|
||||
|
||||
// Contrainte : une règle dont le sens change reçoit un suffixe -v2 côté backend (ADR 0006) ;
|
||||
// une référence inconnue s'affiche donc telle quelle plutôt que de casser la vue.
|
||||
const LIBELLE_PAR_REGLE: Record<string, string> = {
|
||||
'spike-delestage-v1': 'Délestage',
|
||||
'threshold-reduction-v1': 'Réduction de puissance',
|
||||
'outage-secours-v1': 'Alimentation de secours',
|
||||
'sensor-maintenance-v1': 'Maintenance capteur',
|
||||
'anomaly-verification-v1': 'Vérification',
|
||||
'escalade-astreinte-v1': 'Escalade astreinte',
|
||||
'contrat-puissance-v1': 'Contrat de puissance',
|
||||
};
|
||||
|
||||
const REGLE_ESCALADE = 'escalade-astreinte-v1';
|
||||
|
||||
export function libelleRegle(reference: string): string {
|
||||
return LIBELLE_PAR_REGLE[reference] ?? reference;
|
||||
}
|
||||
|
||||
export function tonRegle(reference: string): BadgeTone {
|
||||
return reference === REGLE_ESCALADE ? 'critical' : 'neutral';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface Recommendation {
|
||||
recommendation_id: number;
|
||||
alert_id: number;
|
||||
action: string;
|
||||
explanation: string;
|
||||
rule_reference: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RecommendationGenerationReport {
|
||||
alerts_examined: number;
|
||||
recommendations_created: number;
|
||||
already_present: number;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type SensorStatus = 'ok' | 'failing';
|
||||
export type OverallStatus = 'ok' | 'degraded' | 'critical';
|
||||
|
||||
export interface SensorDiagnostic {
|
||||
status: SensorStatus;
|
||||
since: string | null;
|
||||
}
|
||||
|
||||
export interface SiteSensors {
|
||||
consumption: SensorDiagnostic;
|
||||
electrical: SensorDiagnostic;
|
||||
temperature: SensorDiagnostic;
|
||||
humidity: SensorDiagnostic;
|
||||
network: SensorDiagnostic;
|
||||
[key: string]: SensorDiagnostic;
|
||||
}
|
||||
|
||||
export interface SiteSensorStatus {
|
||||
site_id: string;
|
||||
site_name: string;
|
||||
sensors: SiteSensors;
|
||||
overall: OverallStatus;
|
||||
}
|
||||
|
||||
export interface SensorStatusResponse {
|
||||
timestamp: string;
|
||||
sites: SiteSensorStatus[];
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
@use 'styles/forms';
|
||||
@use 'styles/auth-page';
|
||||
@use 'styles/links';
|
||||
@use 'styles/tables';
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
@@ -29,3 +29,18 @@
|
||||
color: var(--color-disabled);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur
|
||||
// reprend en dur la valeur de --color-text-muted.
|
||||
.form-select {
|
||||
@extend .form-input;
|
||||
padding-right: 2.25rem;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-surface);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.6rem center;
|
||||
background-size: 1rem;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
--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-md: 12px;
|
||||
--radius-pill: 999px;
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--shadow-card-hover: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
|
||||
// Espacements
|
||||
--space-1: 0.35rem;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Base de metadonnees Airflow (api-server + scheduler + dag-processor, LocalExecutor). Separee de la base
|
||||
-- applicative : les tables internes d'Airflow (dag_run, task_instance, ...) n'ont rien a faire
|
||||
-- dans le schema metier. Meme conteneur Postgres que `enervision`/`enervision_test` plutot qu'un
|
||||
-- service dedie, pour ne pas ajouter un conteneur de plus (issue #115).
|
||||
CREATE DATABASE airflow;
|
||||
@@ -0,0 +1,79 @@
|
||||
# Piège : `APP_ENV` et `APP_DEBUG` sont en dur et non en `${APP_ENV:-prod}` : le `.env` du poste
|
||||
# vaut `local` et reprendrait le dessus, ce qui laisserait le cookie sans `__Secure-` et
|
||||
# rouvrirait `/docs`. Hors `local`, l'API exige en retour une origine CORS non vide.
|
||||
# Piège : les listes de ports se cumulent à la fusion des deux fichiers. `!reset` est le seul
|
||||
# moyen de dépublier 8000 et 3000 : sans lui, l'API resterait joignable en clair à côté du proxy.
|
||||
# Piège : pas de `:?` sur `PUBLIC_HOST`. Compose interpole tout le fichier, y compris pour
|
||||
# `stop` et `logs` : la garde vit dans `make stack-up`, qui la compare au certificat servi.
|
||||
# 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
|
||||
|
||||
services:
|
||||
db:
|
||||
ports: !override
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
||||
environment:
|
||||
TS_TUNE_MEMORY: ${TS_TUNE_MEMORY:-2GB}
|
||||
TS_TUNE_NUM_CPUS: ${TS_TUNE_NUM_CPUS:-2}
|
||||
|
||||
mailpit:
|
||||
ports: !override
|
||||
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
|
||||
|
||||
airflow-apiserver:
|
||||
ports: !override
|
||||
- "127.0.0.1:${AIRFLOW_PORT:-8080}:8080"
|
||||
|
||||
backend:
|
||||
ports: !reset null
|
||||
command:
|
||||
- uvicorn
|
||||
- app.main:create_app
|
||||
- --factory
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "8000"
|
||||
- --proxy-headers
|
||||
- --forwarded-allow-ips=*
|
||||
environment:
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: "false"
|
||||
APP_TRUST_PROXY_HEADERS: "true"
|
||||
APP_CORS_ORIGINS: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}
|
||||
APP_FRONTEND_RESET_PASSWORD_URL: ${PUBLIC_ORIGIN:-https://${PUBLIC_HOST:-enervision.local}}/reset-password
|
||||
|
||||
frontend:
|
||||
ports: !reset null
|
||||
|
||||
proxy:
|
||||
image: nginx:1.31-alpine
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${PROXY_HTTP_PORT:-80}:80"
|
||||
- "${PROXY_HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./infra/proxy/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./infra/proxy/tls:/etc/nginx/tls:ro
|
||||
- acme_webroot:/var/www/certbot
|
||||
restart: unless-stopped
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot:v5.8.0
|
||||
profiles: ["acme"]
|
||||
volumes:
|
||||
- letsencrypt:/etc/letsencrypt
|
||||
- acme_webroot:/var/www/certbot
|
||||
- ./infra/proxy/tls:/tls
|
||||
- ./infra/proxy/acme-deploy-hook.sh:/deploy-hook.sh:ro
|
||||
|
||||
volumes:
|
||||
acme_webroot:
|
||||
letsencrypt:
|
||||
+121
-2
@@ -5,6 +5,49 @@
|
||||
|
||||
name: enervision
|
||||
|
||||
# Piege : LocalExecutor fait tourner les taches comme sous-processus du scheduler, jamais de
|
||||
# l'api-server. `airflow_ml_state` (modele entraine, magasin MLflow) n'a donc besoin d'etre monte
|
||||
# que sur `airflow-scheduler` en pratique, mais reste partage avec l'api-server pour que ce
|
||||
# dernier puisse au besoin l'inspecter sans en devenir dependant.
|
||||
x-airflow-common: &airflow-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: etl/airflow/Dockerfile
|
||||
environment: &airflow-common-env
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||
# Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de
|
||||
# filtrer les services : une variable requise manquante casserait aussi `make db-up`,
|
||||
# `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par
|
||||
# `airflow-init` (ci-dessous), dont `api-server`, `dag-processor` et `scheduler` dependent.
|
||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
|
||||
AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-}
|
||||
# Signe les jetons entre scheduler, tâches et api-server. Conteneurs distincts : un secret
|
||||
# généré au démarrage ne serait pas partagé, il doit venir du .env.
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-}
|
||||
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
|
||||
# FabAuthManager plutôt que le SimpleAuthManager par défaut d'Airflow 3 : seul le provider
|
||||
# FAB sait créer le compte admin que `airflow-init` pose via `_AIRFLOW_WWW_USER_*`.
|
||||
AIRFLOW__CORE__AUTH_MANAGER: airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow
|
||||
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) :
|
||||
# memes identifiants que le backend en attendant.
|
||||
ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db
|
||||
# Le DAG `alertes` lance le backend en sous-processus : il lit `DATABASE_URL`, en
|
||||
# dialecte asyncpg, là où le pipeline ML lit `ML_DATABASE_URL`.
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
# Clé distincte de celle de l'API : la détection ne signe ni ne vérifie aucun jeton, et
|
||||
# Airflow permet d'exécuter du code depuis son interface (cf. ADR 0008).
|
||||
APP_SECRET_KEY: ${AIRFLOW_APP_SECRET_KEY:-}
|
||||
volumes:
|
||||
- ./etl/airflow/dags:/opt/airflow/dags
|
||||
- ./etl/airflow/plugins:/opt/airflow/plugins
|
||||
- ./data/raw:/opt/data/raw:ro
|
||||
- airflow_logs:/opt/airflow/logs
|
||||
- airflow_ml_state:/opt/ml/state
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
db:
|
||||
image: timescale/timescaledb-ha:pg17
|
||||
@@ -19,6 +62,7 @@ services:
|
||||
- pgdata:/home/postgres/pgdata/data
|
||||
- ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro
|
||||
- ./db/init/110-test-database.sql:/docker-entrypoint-initdb.d/110-test-database.sql:ro
|
||||
- ./db/init/120-airflow-database.sql:/docker-entrypoint-initdb.d/120-airflow-database.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
@@ -50,6 +94,12 @@ services:
|
||||
APP_SECRET_KEY: ${APP_SECRET_KEY:?}
|
||||
APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200}
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
|
||||
APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr}
|
||||
APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-}
|
||||
APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-}
|
||||
APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10}
|
||||
|
||||
APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password}
|
||||
APP_SMTP_HOST: mailpit
|
||||
APP_SMTP_PORT: "1025"
|
||||
@@ -62,9 +112,78 @@ services:
|
||||
frontend:
|
||||
build: ./apps/frontend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
|
||||
# portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte
|
||||
# aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume
|
||||
# `pgdata` deja peuple) fait echouer ce service, et api-server, dag-processor et scheduler,
|
||||
# qui attendent son succes, ne demarrent pas sur une base non migree. Le mot de passe passe
|
||||
# par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
|
||||
# Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous
|
||||
# refuse en plus les cles et secrets vides.
|
||||
airflow-init:
|
||||
<<: *airflow-common
|
||||
restart: "no"
|
||||
environment:
|
||||
<<: *airflow-common-env
|
||||
_AIRFLOW_DB_MIGRATE: "true"
|
||||
_AIRFLOW_WWW_USER_CREATE: "true"
|
||||
_AIRFLOW_WWW_USER_USERNAME: ${AIRFLOW_ADMIN_USERNAME:-admin}
|
||||
_AIRFLOW_WWW_USER_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-}
|
||||
_AIRFLOW_WWW_USER_EMAIL: ${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
set -euo pipefail
|
||||
: "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}"
|
||||
: "$${AIRFLOW__API__SECRET_KEY:?AIRFLOW_API_SECRET_KEY manquant dans .env}"
|
||||
: "$${AIRFLOW__API_AUTH__JWT_SECRET:?AIRFLOW_JWT_SECRET manquant dans .env}"
|
||||
: "$${APP_SECRET_KEY:?AIRFLOW_APP_SECRET_KEY manquant dans .env}"
|
||||
exec airflow version
|
||||
|
||||
airflow-apiserver:
|
||||
<<: *airflow-common
|
||||
command: api-server
|
||||
ports:
|
||||
- "${AIRFLOW_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/api/v2/monitor/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
airflow-scheduler:
|
||||
<<: *airflow-common
|
||||
command: scheduler
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
# Obligatoire depuis Airflow 3 : le scheduler ne parse plus les fichiers de dags/ lui-même.
|
||||
airflow-dag-processor:
|
||||
<<: *airflow-common
|
||||
command: dag-processor
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
airflow_logs:
|
||||
airflow_ml_state:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# ML-START : accès aux données, scoring, frontière API et ML
|
||||
|
||||
Document de référence du module `ml/`, cité par le code (`enervision_ml/config.py`, `data.py`,
|
||||
`train.py`, `score.py`, `features.py`), par l'[ADR 0005](adr/0005-modele-prediction-lightgbm.md)
|
||||
et par les vues d'architecture. Il répond à trois questions, et à elles seules :
|
||||
|
||||
1. **comment le pipeline accède aux données**, et pourquoi pas par l'API ;
|
||||
2. **ce que fait un run de scoring**, étape par étape ;
|
||||
3. **où passe la frontière entre l'API et le ML**, et pourquoi elle est là.
|
||||
|
||||
Le mode d'emploi (installation, commandes, options) est dans [`ml/README.md`](../ml/README.md).
|
||||
Le choix du modèle est dans l'ADR 0005. Ce document ne les répète pas.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mécanisme d'accès aux données
|
||||
|
||||
### Deux sources, un seul schéma de sortie
|
||||
|
||||
`enervision_ml.data` expose trois chargeurs qui produisent **exactement les mêmes neuf colonnes**
|
||||
(`site_id`, `timestamp`, `consumption_kwh`, `temperature_celsius`, `humidity_percent`,
|
||||
`solar_irradiance_wm2`, `is_working_hours`, `site_type`, `capacity_kw`) :
|
||||
|
||||
| Fonction | Source | Usage |
|
||||
|---|---|---|
|
||||
| `load_from_csv(path)` | `ml/data/all_sites_combined.csv` | Chemin de démarrage, tant que la base n'est pas peuplée |
|
||||
| `load_from_database(connection)` | `reading` joint à `site`, **historique complet** | Entraînement |
|
||||
| `load_recent_from_database(connection, since=…)` | `reading` joint à `site`, **borné par `since`** | Scoring |
|
||||
|
||||
L'égalité des schémas n'est pas un confort : c'est ce qui permet de valider tout le pipeline sur
|
||||
CSV, sans base joignable, et d'obtenir le même comportement une fois la base peuplée. Une
|
||||
divergence entre les deux chemins ne se verrait pas au chargement, elle se verrait en production
|
||||
sous forme de prédictions silencieusement fausses.
|
||||
|
||||
### Connexion directe à PostgreSQL, pas l'API
|
||||
|
||||
Le pipeline lit `reading` et `site` **en SQL direct**, jamais par `GET /api/v1/readings`. Trois
|
||||
raisons, à défendre telles quelles :
|
||||
|
||||
- **Volume.** L'entraînement lit l'historique complet d'une hypertable TimescaleDB. Le faire
|
||||
passer par une API REST paginée, sérialisée en JSON et contrôlée route par route, c'est payer
|
||||
trois fois pour un `SELECT`.
|
||||
- **Couplage.** Le pipeline n'est pas un client de l'application, c'est un consommateur du
|
||||
schéma. Passer par l'API le rendrait dépendant du contrat HTTP, de l'authentification et de la
|
||||
disponibilité du service, pour lire des données dont il connaît déjà la forme.
|
||||
- **Droits.** Un rôle de lecture sur deux tables est une surface plus petite qu'un compte
|
||||
applicatif porteur d'un rôle métier.
|
||||
|
||||
### `ML_DATABASE_URL`, et pourquoi ce n'est pas `DATABASE_URL`
|
||||
|
||||
La chaîne de connexion est lue dans **`ML_DATABASE_URL`**, jamais dans `DATABASE_URL`. Ce n'est
|
||||
pas une préférence de nommage : `DATABASE_URL` est celle du backend applicatif, **propriétaire du
|
||||
schéma**, avec les droits d'écriture complets. Réutiliser cette variable par défaut ferait tourner
|
||||
l'entraînement et le scoring avec ces droits, **en silence**. `enervision_ml.config.database_url()`
|
||||
lève donc plutôt que de retomber sur une valeur par défaut.
|
||||
|
||||
**Dette assumée, à dire à l'oral et non à masquer** : le rôle PostgreSQL dédié `enervision_ml`,
|
||||
restreint en lecture sur `reading` et `site`, **n'est pas provisionné**. En développement,
|
||||
`ML_DATABASE_URL` pointe sur la même base que le backend. La cible est un rôle séparé, cohérente
|
||||
avec le principe de moindre privilège posé par l'[ADR 0003](adr/0003-autorisation-rbac-a-trois-roles.md).
|
||||
|
||||
### Le seul endroit qui construit les features
|
||||
|
||||
`enervision_ml.features.build_features` est **l'unique** constructeur de features, à
|
||||
l'entraînement comme au scoring. Le piège que cela évite : si les deux divergent, même d'une
|
||||
fenêtre de moyenne glissante, le modèle reçoit en service des features qui ne ressemblent plus à
|
||||
ce qu'il a appris, et ses prédictions se dégradent **sans qu'aucune erreur ne se déclenche**.
|
||||
Ne jamais réécrire cette logique ailleurs : importer le module.
|
||||
|
||||
Conséquence sur la validation : la coupure entraînement / validation est **chronologique**, jamais
|
||||
un tirage aléatoire de lignes. Un tirage aléatoire laisserait des lignes de validation voir des
|
||||
lignes d'entraînement à travers leurs lags et leurs moyennes glissantes, une fuite qui masquerait
|
||||
un surapprentissage.
|
||||
|
||||
---
|
||||
|
||||
## 2. Les étapes d'un run de scoring
|
||||
|
||||
`python -m enervision_ml.score` calcule, pour chaque site ou pour un seul avec `--site-id`, la
|
||||
consommation prévue de **l'heure suivant sa dernière lecture connue**, et écrit une ligne dans
|
||||
`prediction`.
|
||||
|
||||
| # | Étape | Point de vigilance |
|
||||
|---|---|---|
|
||||
| 1 | Charger une **fenêtre récente** de `reading` joint à `site` : 21 jours par défaut | Une marge au-dessus des 168 h qu'exige le lag hebdomadaire. Un `SELECT` non borné sur l'hypertable serait la même erreur que celle corrigée sur `GET /readings` |
|
||||
| 2 | Ajouter **une ligne future par site**, l'heure suivante, et calculer ses features par `build_features` | La même fonction qu'à l'entraînement, cf. section 1 |
|
||||
| 3 | Si le **lag de 168 h est absent** (moins d'une semaine d'historique) : écrire `status = "insufficient_data"` | **LightGBM n'est jamais appelé.** Un modèle interrogé sans son lag principal rendrait un nombre, et ce nombre serait faux sans le dire |
|
||||
| 4 | Sinon : `booster.predict(...)`, puis écrire `status = "available"` et la valeur prévue | |
|
||||
|
||||
### Ce que le run écrit, et ce qu'il n'écrase pas
|
||||
|
||||
La table `prediction` **n'a pas de contrainte d'unicité sur `(site_id, target_at)`** : chaque run
|
||||
insère une ligne de plus au lieu d'écraser la précédente. C'est délibéré, et c'est ce qui rendra
|
||||
possible la comparaison prévision contre réalisé, donc la surveillance de dérive (#44, #45), qui
|
||||
n'existe pas encore.
|
||||
|
||||
Trois contraintes de cohérence sont portées par la base et non par le code applicatif :
|
||||
`status = 'available'` exige une `predicted_value` et interdit un `failure_reason` ;
|
||||
`insufficient_data` et `error` exigent l'inverse ; `target_metric` est bornée à
|
||||
`consumption_kwh` ou `consumption_kw`, et la forme énergie impose une `period_minutes`.
|
||||
|
||||
### `model_reference` est un hachage, pas un nom de fichier
|
||||
|
||||
`train.py` réécrit **toujours le même chemin** (`models/lightgbm-consumption.txt`) à chaque
|
||||
entraînement. Le nom de fichier ne distinguerait donc pas deux versions du modèle. `prediction`
|
||||
porte pour cela le **SHA-256 tronqué du fichier modèle**. C'est ce qui permet, devant une
|
||||
prédiction douteuse, de savoir quel modèle l'a produite.
|
||||
|
||||
### Mode CSV : rien n'est écrit en base
|
||||
|
||||
En `--csv`, le run ne touche pas la base. L'heure future calculée depuis la fin du CSV n'existe
|
||||
dans aucune base réelle : ce serait inscrire une prévision pour un instant déjà passé. Le mode
|
||||
sert à valider le pipeline sans base joignable.
|
||||
|
||||
### Limite assumée
|
||||
|
||||
La feature `is_working_hours` de la ligne future est **recopiée** depuis la dernière lecture
|
||||
réelle, pas recalculée : il n'existe aucune règle d'heures ouvrables dans ce dépôt, elle vit dans
|
||||
le générateur du jeu de données d'origine. L'approximation n'est fausse qu'aux heures de bascule,
|
||||
sur une feature parmi une dizaine, pour une prévision à un seul pas.
|
||||
|
||||
---
|
||||
|
||||
## 3. La frontière entre l'API et le ML
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph ml["ml/ · projet Python indépendant"]
|
||||
train["enervision_ml.train<br/>LightGBM + MLflow"]
|
||||
score["enervision_ml.score<br/>prévision à un pas"]
|
||||
end
|
||||
subgraph db["PostgreSQL + TimescaleDB"]
|
||||
reading[("reading, site")]
|
||||
prediction[("prediction")]
|
||||
end
|
||||
subgraph api["apps/backend · FastAPI"]
|
||||
route["GET /api/v1/predictions"]
|
||||
end
|
||||
|
||||
reading -- "SQL direct, ML_DATABASE_URL" --> train
|
||||
reading -- "fenêtre récente" --> score
|
||||
train -- "models/*.txt + run MLflow" --> score
|
||||
score -- "INSERT" --> prediction
|
||||
prediction -- "lecture seule" --> route
|
||||
```
|
||||
|
||||
**La règle, en une phrase : FastAPI ne fait jamais tourner LightGBM.**
|
||||
`GET /api/v1/predictions` lit la dernière prévision par site dans `prediction`, jamais un recalcul
|
||||
à la volée. Ce qui en découle, et qui est l'argument à tenir devant le jury :
|
||||
|
||||
- **La latence de l'API ne dépend pas du modèle.** Une route de lecture indexée
|
||||
(`ix_prediction_site_target`) répond en temps constant, qu'un run de scoring dure une seconde
|
||||
ou une minute.
|
||||
- **Le service de production n'embarque ni LightGBM ni MLflow.** `ml/` est un projet Python
|
||||
séparé, avec son propre `uv.lock`. Le backend n'a aucune raison de porter ces dépendances, ni
|
||||
leur surface de vulnérabilités, pour un script lancé hors du chemin de requête.
|
||||
- **Une panne du pipeline dégrade, elle n'interrompt pas.** Si le scoring ne tourne plus, l'API
|
||||
continue de servir la dernière prévision connue, avec son `created_at` et son
|
||||
`model_reference`, au lieu de rendre une erreur.
|
||||
- **Le contrat est la table, pas un appel.** Ce qui traverse la frontière, ce sont des lignes de
|
||||
`prediction` et leurs contraintes de cohérence, vérifiables en SQL.
|
||||
|
||||
Le corollaire est qu'il n'y a **aucune prévision à la demande** : la fraîcheur d'une prévision est
|
||||
celle du dernier run de scoring. Ce run est ordonnancé par Airflow, DAG `ml_score` en `@hourly`
|
||||
(issue #115) ; seuls le mode `--csv` et un lancement local restent manuels, tout comme
|
||||
l'entraînement, dont le DAG `ml_train` n'a pas de planification. La dette qui subsiste est la
|
||||
surveillance de dérive, portée par les issues #44 et #45.
|
||||
|
||||
---
|
||||
|
||||
## Voir aussi
|
||||
|
||||
- [`ml/README.md`](../ml/README.md) : installation, commandes, options, où écrire les tests
|
||||
- [ADR 0005](adr/0005-modele-prediction-lightgbm.md) : pourquoi LightGBM, et les 6 candidats écartés
|
||||
- [ADR 0006](adr/0006-moteur-de-regles-dans-le-backend.md) : ce qui consomme les prédictions
|
||||
- [`architecture/20-backend.md`](architecture/20-backend.md) : le contrat de `GET /predictions`
|
||||
- [`architecture/40-data.md`](architecture/40-data.md) : le modèle de données
|
||||
@@ -11,3 +11,8 @@
|
||||
| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
|
||||
| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête |
|
||||
| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
||||
| [0005](adr/0005-modele-prediction-lightgbm.md) | LightGBM pour la prédiction de consommation, un modèle global |
|
||||
| [0006](adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||
| [0007](adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||
| [0008](adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
||||
| [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,120 @@
|
||||
# 0007 - Terminaison TLS par un reverse proxy Nginx, en Docker Compose
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-21
|
||||
|
||||
## Contexte
|
||||
|
||||
Quatre documents désignaient le même trou. `10-infra.md` ouvrait ses questions par « Quel ingress
|
||||
remplace Traefik, et qui termine le TLS ». `00-vue-ensemble.md` rangeait « TLS, HSTS et CSP » dans
|
||||
« Absent, et assumé ». `owasp-traceabilite.md` laissait la ligne API8 transport ouverte.
|
||||
`31-contrat-authentification.md` listait deux corrections « à faire avant la démonstration » :
|
||||
servir le SPA et l'API sous la même origine, et servir en HTTPS.
|
||||
|
||||
Ce n'est pas un durcissement facultatif, c'est une condition de fonctionnement. Les deux fichiers
|
||||
`apps/frontend/src/environments/environment*.ts` portent `apiUrl: '/api/v1'`, en relatif. En
|
||||
développement, `proxy.conf.json` route `/api` vers l'API. Une fois en conteneur, plus rien ne le
|
||||
fait : l'application déployée ne peut pas appeler son API. Et le cookie de rafraîchissement prend
|
||||
le préfixe `__Secure-` dès que `APP_ENV` sort de `local`, donc sans HTTPS il n'est jamais posé et
|
||||
l'authentification ne tient pas au rechargement de page.
|
||||
|
||||
La contrainte qui cadre tout le reste : **aucun nom de domaine public n'existe**. La cible
|
||||
documentée est le serveur on-premise de l'école, `ssh_host = "10.0.0.10"` dans le
|
||||
`terraform.tfvars.example`. Sur une adresse privée, le défi HTTP-01 de Let's Encrypt ne peut pas
|
||||
aboutir, faute de DNS public et de port 80 entrant.
|
||||
|
||||
## Décision
|
||||
|
||||
**Un service `proxy` dans Docker Compose**, image officielle `nginx:1.28-alpine`, seul composant à
|
||||
publier des ports sur la machine : 80 et 443. Backend et frontend ne sont plus publiés du tout, la
|
||||
base et l'interface Mailpit sont ramenées sur la boucle locale. La stack complète est décrite par
|
||||
l'overlay `docker-compose.prod.yml`, le `docker-compose.yml` restant la boucle de développement.
|
||||
|
||||
**Le SPA et l'API sont servis sous la même origine** : `/` vers le conteneur frontend, `/api/` vers
|
||||
l'API en préservant le préfixe `/api/v1`. Le CORS cesse d'être un mécanisme de production et
|
||||
redevient ce qu'il est, un filet pour les appels croisés qui ne devraient plus exister.
|
||||
|
||||
**nginx lit toujours les deux mêmes fichiers**, `/etc/nginx/tls/fullchain.pem` et `privkey.pem`.
|
||||
Seule leur fabrication varie : un script `openssl` pour la démonstration, le `--deploy-hook` de
|
||||
certbot quand un domaine existera. La configuration nginx ne connaît pas la différence et n'aura
|
||||
pas à changer le jour de la bascule.
|
||||
|
||||
**Le proxy pose HSTS et CSP**, que l'application refuse de poser. Ce refus est verrouillé par
|
||||
`tests/api/test_hardening.py::test_the_application_never_sets_hsts_itself` : l'application ne peut
|
||||
pas savoir si elle est jointe en HTTPS, le terminateur, si.
|
||||
|
||||
## Pourquoi Compose et pas l'ingress k3s
|
||||
|
||||
Le module `infra/terraform/modules/k3s/` installe un cluster et rien d'autre. Il ne déclare que le
|
||||
provider `null`, aucun namespace, aucun déploiement, aucun service, aucun ingress, et il n'a jamais
|
||||
été appliqué. Passer par un ingress supposait d'abord de combler tout ce qui manque entre les deux
|
||||
topologies : un registre d'images alimenté, des manifestes pour le front, l'API et la base, un
|
||||
stockage persistant pour PostgreSQL. C'est le chantier que `10-infra.md` nomme « le trou entre les
|
||||
deux topologies », et il ne tient pas dans le jalon.
|
||||
|
||||
Compose, lui, fait déjà tourner les quatre services sur un réseau commun. Le proxy y entre comme un
|
||||
cinquième service, sans rien déplacer. La décision de désactiver Traefik reste valable : le choix
|
||||
d'ingress n'est pas tranché ici, il est repoussé avec le reste de la bascule Kubernetes.
|
||||
|
||||
## Ce que le proxy n'expose pas, et pourquoi c'est structurel
|
||||
|
||||
`/docs`, `/redoc`, `/openapi.json`, `/static` et `/metrics` sont montés par l'API **à la racine**,
|
||||
pas sous le préfixe `/api`. Avec un routage où seul `/api/` part vers l'API, ils tombent dans
|
||||
`location /`, donc sur le SPA, donc hors d'atteinte publique. Aucune règle de blocage n'est
|
||||
nécessaire, et il n'y en a pas : le jour où quelqu'un routera la racine vers l'API pour « réparer »
|
||||
Swagger, il publiera les métriques avec.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- `APP_ENV`, `APP_DEBUG`, `APP_CORS_ORIGINS`, `APP_TRUST_PROXY_HEADERS` et le TLS changent
|
||||
ensemble, dans le même fichier. Hors `local`, la configuration refuse de démarrer sans origine
|
||||
CORS, et le cookie devient `__Secure-ev_refresh`.
|
||||
- `APP_TRUST_PROXY_HEADERS` passe à vrai, et le proxy écrit `X-Forwarded-For` avec
|
||||
`$proxy_add_x_forwarded_for`, qui ajoute l'IP réelle en fin de chaîne. C'est exactement ce que
|
||||
lit `get_client_ip()`. Toute autre forme ferait compter la limitation de débit par IP sur l'IP
|
||||
du proxy, c'est-à-dire globalement.
|
||||
- `--forwarded-allow-ips=*` reste sans conséquence : uvicorn s'en sert pour réécrire
|
||||
`request.client` depuis `X-Forwarded-For`, et `get_client_ip()` est le seul lecteur de
|
||||
`request.client` du backend, en dernier recours quand l'en-tête est absent.
|
||||
- Une limitation de débit au frontal existe désormais, distincte de celle de l'application : 20
|
||||
requêtes par seconde sur l'API, et 30 par minute sur les seules routes qui vérifient un secret,
|
||||
`login`, `password`, `forgot-password` et `reset-password`. `/auth/me` et `/auth/refresh` en
|
||||
sont exclues : elles partent à chaque chargement de page, et le NAT de l'école donnant une seule
|
||||
adresse à toute la promotion, la zone resserrée les aurait transformées en 429 en démonstration.
|
||||
- **La CSP contraint le build du frontend.** `script-src 'self'` interdit les gestionnaires
|
||||
d'événements en ligne, et l'inlining du CSS critique d'Angular produisait exactement cela :
|
||||
`<link rel="stylesheet" media="print" onload="this.media='all'">`. La feuille serait restée en
|
||||
`media="print"`, donc l'application entière sans style. D'où `styles.inlineCritical: false` dans
|
||||
`angular.json`. `style-src` garde `'unsafe-inline'`, dont Angular a besoin pour les styles de
|
||||
composants injectés à l'exécution.
|
||||
- **La redirection 80 vers 443 conserve `$host`.** Un client qui forge son en-tête `Host` obtient
|
||||
donc une redirection vers l'hôte de son choix. Risque accepté : un navigateur ne peut pas être
|
||||
amené à envoyer un `Host` étranger, aucun cache ne s'intercale, et figer un nom canonique
|
||||
couperait l'accès par adresse IP, seule voie ouverte sur `10.0.0.10`.
|
||||
- **Aucun `:?` dans l'overlay.** Compose interpole tout le fichier avant n'importe quelle
|
||||
sous-commande : une garde y casserait `stop` et `logs` autant que `up`. `PUBLIC_HOST` retombe
|
||||
donc sur `enervision.local`, et `make stack-up` vérifie à la place que le certificat présent
|
||||
couvre l'hôte demandé, ce qui est la condition réelle à tenir.
|
||||
- Le proxy attend une API saine et pas seulement démarrée : le `HEALTHCHECK` de l'image du backend
|
||||
sert de condition à `depends_on`, faute de quoi les premiers appels à `/api/` répondent 502.
|
||||
- La ligne API8 transport de `owasp-traceabilite.md` se referme.
|
||||
- **Let's Encrypt n'est pas prouvé.** Le chemin ACME est livré, monté et documenté ; il n'a pas
|
||||
été exercé faute de domaine. Le certificat de démonstration est auto-signé, le navigateur
|
||||
avertit, et c'est la situation réelle du projet, pas un raccourci.
|
||||
- Le proxy résout ses cibles par le résolveur interne de Docker plutôt que par un bloc `upstream`,
|
||||
sans quoi recréer le seul conteneur backend suffirait à produire des 502 jusqu'au rechargement.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Ingress k3s avec cert-manager** : la bonne cible, et elle reste la cible. Elle suppose un
|
||||
registre et des manifestes qui n'existent pas, à quatre jours du rendu.
|
||||
- **Étendre le `nginx.conf` du conteneur frontend** avec un `location /api` et l'écoute TLS :
|
||||
moins de pièces, mais les certificats entrent dans l'image du front et tout rebuild du front
|
||||
redéploie le terminateur TLS. La séparation des cycles de vie vaut le conteneur supplémentaire.
|
||||
- **Traefik ou Caddy**, qui automatisent ACME : ils déplacent le problème sans le résoudre, le
|
||||
défi HTTP-01 échouant pour la même raison. Et l'issue nomme Nginx.
|
||||
- **Let's Encrypt par défi DNS-01** : fonctionne derrière une IP privée, mais exige un domaine
|
||||
possédé et un jeton d'API chez le fournisseur DNS. Rouvrable sans rien changer à la
|
||||
configuration nginx le jour où ces deux éléments existent.
|
||||
- **Un `Dockerfile` de proxy** : inutile, la configuration est montée en volume. Cela évite aussi
|
||||
la dépendance à un registre authentifié, piège déjà présent dans `apps/frontend/Dockerfile`.
|
||||
@@ -0,0 +1,79 @@
|
||||
# 0008 - Airflow exécute le code du backend en sous-processus
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-21
|
||||
|
||||
## Contexte
|
||||
|
||||
L'issue #116 demande un DAG d'alertes. Ce qu'il a à ordonnancer existe déjà et n'est pas à
|
||||
réécrire : `AlertService.detect()` et ses cinq règles (#104), puis le moteur de recommandations
|
||||
(#38). Les deux vivent dans `apps/backend/app/`, et
|
||||
l'[ADR 0006](0006-moteur-de-regles-dans-le-backend.md) a précisément décidé qu'ils y restent parce
|
||||
qu'ils s'appuient sur les repositories ORM de l'API plutôt que sur du SQL brut. Les deux
|
||||
sont décrits par la documentation comme « lancés à la main ».
|
||||
|
||||
L'image Airflow livrée par #115 ne porte que `ml/`, dans un environnement `uv` distinct
|
||||
(`/opt/ml/.venv`, Python 3.14) de celui d'Airflow lui-même (Python 3.12, contraint par
|
||||
apache-airflow 2.10). Les DAGs `ml_train` et `ml_score` shellent vers cet environnement. Rien
|
||||
d'équivalent n'existe pour `apps/backend` : un `BashOperator` sur
|
||||
`python -m app.detection.internal_alerts` échouerait en `ModuleNotFoundError`.
|
||||
|
||||
## Décision
|
||||
|
||||
**L'image Airflow porte un troisième environnement, `/opt/backend/.venv`**, construit depuis le
|
||||
`pyproject.toml`, le `uv.lock` et le paquet `app/` du backend. Le DAG `alertes` shelle vers lui
|
||||
exactement comme `ml_score` shelle vers `/opt/ml/.venv`.
|
||||
|
||||
Trois raisons :
|
||||
|
||||
- **Le patron existe et vient d'être revu.** #115 a posé `BashOperator` + `uv run --no-sync` +
|
||||
`env -u VIRTUAL_ENV`, avec les tests d'intégrité qui le verrouillent. Introduire une seconde
|
||||
forme d'appel dans le même dossier `dags/` coûterait plus cher à lire qu'un second environnement
|
||||
dans le même `Dockerfile`.
|
||||
- **Aucune surface réseau n'est ajoutée.** La détection n'a pas de route HTTP, contrairement à la
|
||||
génération de recommandations (`POST /recommendations/generate`, rôle `admin`). En créer une pour
|
||||
qu'Airflow l'appelle donnerait à l'ordonnanceur un compte administrateur de l'API, en plus des
|
||||
identifiants PostgreSQL complets qu'il détient déjà, et ferait dépendre la production d'alertes
|
||||
de la disponibilité du conteneur `backend`.
|
||||
- **La logique reste où l'ADR 0006 l'a mise.** Le DAG n'apprend rien du domaine : ni les seuils, ni
|
||||
les cinq règles, ni les clés d'idempotence. Il ne sait que l'heure à laquelle appeler.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- **Airflow reçoit une `APP_SECRET_KEY` délibérément distincte de celle de l'API.** La
|
||||
configuration du backend refuse de se construire sans elle (`app/core/config.py`), et
|
||||
`internal_alerts.main()` appelle `get_settings()` avant toute requête pour échouer tôt. Mais la
|
||||
détection ne signe ni ne vérifie aucun jeton, et Airflow permet d'exécuter du code arbitraire
|
||||
depuis son interface : un Airflow compromis ne doit pas livrer la clé de signature des JWT. D'où
|
||||
`AIRFLOW_APP_SECRET_KEY`, avec sa propre garde dans `airflow-init`.
|
||||
- **`DATABASE_URL`, en dialecte asyncpg, rejoint `ML_DATABASE_URL`** dans l'environnement du
|
||||
conteneur. Le cantonnement des rôles PostgreSQL reste la dette de
|
||||
l'[ADR 0003](0003-autorisation-rbac-a-trois-roles.md), et cette décision l'alourdit d'un
|
||||
consommateur de plus.
|
||||
- **La CI Airflow se déclenche sur les changements du backend.** L'image le `COPY` : sans
|
||||
`apps/backend/app/**`, `pyproject.toml` et `uv.lock` dans les déclencheurs du workflow, une
|
||||
dépendance modifiée casserait la construction sans que rien ne le signale avant le déploiement.
|
||||
En contrepartie, l'image grossit de ce que pèsent SQLAlchemy, asyncpg et pandas.
|
||||
- **Aucune variable ne départage les deux environnements, et c'est voulu.** `uv` place par défaut
|
||||
le venv d'un projet dans `<projet>/.venv` : `cd /opt/ml` ou `cd /opt/backend` suffit à choisir le
|
||||
bon. L'image ne pose donc plus de `UV_PROJECT_ENVIRONMENT` global, hérité de #115 : il vaudrait
|
||||
pour les deux projets, et `uv run` dans l'un résoudrait le venv de l'autre. Le symptôme n'est pas
|
||||
une construction ratée mais un `ModuleNotFoundError` à la première tâche, d'où la vérification
|
||||
d'import sans réseau que la CI fait maintenant sur chacun des deux.
|
||||
- Airflow lui-même reste étranger au domaine : ni LightGBM, ni SQLAlchemy, ni FastAPI n'entrent
|
||||
dans son interpréteur. C'est la propriété que #115 avait établie, et elle tient toujours.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Route HTTP `POST /alerts/detect` réservée `admin`, appelée par le DAG.** L'image ne bougeait
|
||||
pas, mais Airflow détenait alors un compte administrateur de l'API, la détection devenait
|
||||
tributaire du conteneur `backend`, et l'API gagnait une route d'écriture dont aucun client
|
||||
humain n'a l'usage. À rouvrir si un jour un tiers doit déclencher la détection.
|
||||
- **`DockerOperator` lançant l'image du backend.** Demande la socket Docker de l'hôte dans le
|
||||
conteneur Airflow, c'est-à-dire un équivalent root sur la machine, pour un service qui permet
|
||||
déjà d'exécuter du code depuis son interface. Le provider n'est d'ailleurs pas installé.
|
||||
- **Réécrire les cinq règles en SQL dans le DAG.** Contredit frontalement l'ADR 0006, duplique le
|
||||
domaine, et fait diverger les deux copies au premier changement de seuil.
|
||||
- **Monter `apps/backend` en volume plutôt que le copier.** L'environnement ne serait plus figé à
|
||||
la construction, `uv` resynchroniserait au premier lancement, et la CI ne prouverait plus rien
|
||||
de ce qui tourne réellement.
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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. Airflow 3 n'a rien à régler de ce côté : son api-server lance un seul worker par défaut,
|
||||
là où le webserver d'Airflow 2 en lançait quatre. 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.
|
||||
@@ -46,6 +46,7 @@ flowchart TB
|
||||
navigateur["Navigateur"]
|
||||
|
||||
subgraph machine["Machine on-premise"]
|
||||
proxy["Reverse proxy Nginx<br/>:80 et :443"]
|
||||
front["Frontend Angular 22<br/>apps/frontend"]
|
||||
api["API FastAPI<br/>apps/backend"]
|
||||
db[("PostgreSQL 17<br/>TimescaleDB")]
|
||||
@@ -54,10 +55,12 @@ flowchart TB
|
||||
grafana["Grafana"]
|
||||
end
|
||||
|
||||
navigateur --> front
|
||||
navigateur --> proxy
|
||||
proxy --> front
|
||||
proxy --> api
|
||||
front -.-> api
|
||||
api --> db
|
||||
airflow -.-> db
|
||||
airflow --> db
|
||||
prom -.-> api
|
||||
grafana -.-> db
|
||||
grafana -.-> prom
|
||||
@@ -67,6 +70,12 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API,
|
||||
intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
|
||||
[30-frontend.md](30-frontend.md).
|
||||
|
||||
Le lien `airflow --> db` est maintenant en trait plein : quatre DAGs tournent, deux pour
|
||||
l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la
|
||||
génération des recommandations (issue #116), et `historical_import` pour l'ingestion du dataset
|
||||
historique (issue #119). L'orchestration de l'import API Mock et la réconciliation globale des
|
||||
deux sources restent à compléter dans l'issue #15.
|
||||
|
||||
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
|
||||
collecteur ne vient le lire.
|
||||
|
||||
@@ -77,15 +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) |
|
||||
| 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`) |
|
||||
| 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`. Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../../ML-START.md). Automatisation (Airflow) et surveillance de dérive (EC06, #44/#45) pas encore construites |
|
||||
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||
| ML | LightGBM, MLflow | `ml` | `En cours` | Pipeline d'entraînement et de scoring (`enervision_ml.train`/`.score`, features par lags/moyennes glissantes partagées entre les deux, baseline de persistance saisonnière, suivi MLflow local), exposé en lecture via `GET /predictions`, orchestré par Airflow (`ml_train`/`ml_score`). Voir [ADR 0005](../adr/0005-modele-prediction-lightgbm.md) et [ML-START.md](../ML-START.md). Surveillance de dérive (EC06, #44/#45) pas encore construite |
|
||||
| Infra | Docker Compose, Nginx, Terraform, k3s single-node | `infra`, `docker-compose.prod.yml` | `En cours` | Reverse proxy et overlay de déploiement écrits et validés, jamais lancés sur le serveur ([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)). Module d'installation k3s jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | `Cible` | Rien |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | `En cours` | 6 workflows, 18 jobs : lint, typage, tests avec seuil de couverture bloquant, tests d'intégration sur TimescaleDB réel, audit de dépendances, SAST Bandit, quality gate SonarCloud, intégrité des DAGs Airflow. Déploiement continu vers la VM ENI écrit par `deploy.yml`, `dev` en recette et `main` en production après approbation ([ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md)), mais jamais exécuté : la machine n'est pas provisionnée et le runner n'y est pas enregistré. Détail dans [50-cicd.md](50-cicd.md) |
|
||||
|
||||
## Flux bout en bout
|
||||
|
||||
Statut : `Cible`. Aucun maillon de cette chaîne n'existe aujourd'hui, à l'exception de la base.
|
||||
Statut : `En cours`. **Le chemin de lecture tourne** : base, API et frontend. **Le chemin
|
||||
d'ingestion dessiné ci-dessous n'existe pas** : les trois DAGs livrés (`ml_train`, `ml_score`,
|
||||
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
|
||||
sequenceDiagram
|
||||
@@ -137,6 +149,11 @@ consolidée.
|
||||
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
|
||||
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
|
||||
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
|
||||
- **Terminaison TLS au frontal** : un reverse proxy Nginx est le seul service publié, il redirige
|
||||
80 vers 443, sert le SPA et l'API sous la même origine, pose **HSTS** et **CSP** que
|
||||
l'application refuse délibérément de poser, et ajoute une **limitation de débit au frontal**
|
||||
distincte de celle de l'application. Voir
|
||||
[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||
- **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root`
|
||||
sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les
|
||||
`.example`.
|
||||
@@ -150,13 +167,12 @@ consolidée.
|
||||
arrêteraient une application compromise. Même raison de report.
|
||||
- **Portée par site** dans l'autorisation : les rôles sont globaux, un opérateur du site A peut
|
||||
agir sur le site B. C'est la limite connue du modèle.
|
||||
- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore.
|
||||
- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas
|
||||
l'infrastructure.
|
||||
- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD.
|
||||
- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP
|
||||
simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir
|
||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||
- **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
|
||||
est livré et documenté mais pas exercé.
|
||||
- **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
|
||||
|
||||
@@ -170,3 +186,6 @@ Elles vivent dans `../adr/`, pas ici.
|
||||
| [0004](../adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
||||
| [0005](../adr/0005-modele-prediction-lightgbm.md) | Modèle de prédiction de consommation : LightGBM |
|
||||
| [0006](../adr/0006-moteur-de-regles-dans-le-backend.md) | Le moteur de règles de recommandation vit dans le backend, pas dans `ml/` |
|
||||
| [0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) | Terminaison TLS par un reverse proxy Nginx, en Docker Compose |
|
||||
| [0008](../adr/0008-airflow-execute-le-code-du-backend.md) | Airflow exécute le code du backend en sous-processus, dans son propre environnement |
|
||||
| [0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) | Deux environnements sur la VM ENI, un projet Compose chacun, déployés par un runner auto-hébergé |
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Infrastructure
|
||||
|
||||
Deux topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
|
||||
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre les deux.
|
||||
Trois topologies coexistent et ne servent pas la même chose. Ce document dit laquelle vaut dans
|
||||
quel contexte, quelles décisions sont arrêtées, et ce qui manque encore entre elles.
|
||||
|
||||
| Topologie | Sert à | Statut |
|
||||
|---|---|---|
|
||||
| Docker Compose | Développer et recetter sur le poste | `Fait` |
|
||||
| k3s single-node | Déployer sur le serveur on-premise | `En cours` |
|
||||
| Docker Compose plus reverse proxy | Déployer sur la machine on-premise | `Fait` |
|
||||
| Deux projets Compose sur la VM ENI, recette et production | Déploiement continu depuis GitHub | `En cours` |
|
||||
| k3s single-node | Cible à terme | `En cours` |
|
||||
|
||||
## Poste de développement
|
||||
|
||||
@@ -40,15 +42,174 @@ seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste ave
|
||||
des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le
|
||||
port 8000, ils ne se lancent donc pas ensemble.
|
||||
|
||||
Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
|
||||
Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
|
||||
|
||||
- `PGDATA` vaut `/home/postgres/pgdata/data` pour l'image `-ha`, et non le chemin habituel de
|
||||
l'image `postgres`. Monté ailleurs, le volume ne retient rien, sans le moindre message.
|
||||
- `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de
|
||||
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
|
||||
le compose. Voir [`db/README.md`](../../db/README.md).
|
||||
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais de l'api-server :
|
||||
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
|
||||
|
||||
## Cible de déploiement
|
||||
### Airflow (issues #115, #116 et #119)
|
||||
|
||||
Quatre services (Airflow 3.3), `docker compose profiles` non utilisés (démarrage explicite via `make
|
||||
airflow-up`, pas dans `make dev`) :
|
||||
|
||||
| Service | Rôle | Points notables |
|
||||
|---|---|---|
|
||||
| `airflow-init` | Migre la base de métadonnées, crée le compte admin | Conteneur jetable (`restart: "no"`), ne redémarre jamais. `api-server`, `dag-processor` et `scheduler` attendent qu'il se termine avec succès |
|
||||
| `airflow-apiserver` | UI et API REST (`/api/v2`), port `8080` | `LocalExecutor` : n'exécute aucune tâche lui-même. Sert aussi l'Execution API que les tâches appellent, d'où le secret JWT partagé |
|
||||
| `airflow-dag-processor` | Parse `dags/` et publie les DAGs sérialisés | Composant à part entière depuis Airflow 3 : le scheduler ne lit plus les fichiers de DAG |
|
||||
| `airflow-scheduler` | Planifie et **exécute** les tâches (`LocalExecutor`) | Les DAGs y tournent en sous-processus (`uv run --no-sync python -m ...`), c'est lui qui a besoin du volume `airflow_ml_state` |
|
||||
|
||||
Airflow 3 impose deux choses que le compose reflète : les tâches ne touchent plus la base de
|
||||
métadonnées et passent par l'Execution API de l'`api-server`, avec un jeton signé par
|
||||
`AIRFLOW_JWT_SECRET` (secret partagé entre conteneurs, jamais celui généré au démarrage) ; et
|
||||
l'authentification par défaut (`SimpleAuthManager`) ne sait pas créer de compte, d'où le
|
||||
`FabAuthManager` qui garde le compte admin posé par `airflow-init`. Pas de `triggerer` : aucun
|
||||
opérateur déférable dans les DAGs.
|
||||
|
||||
Construits depuis `etl/airflow/Dockerfile`, contexte `.` (racine du repo, pas `etl/airflow/`) :
|
||||
l'image doit pouvoir `COPY` les sources de `ml/` **et** de `apps/backend/` pour se synchroniser
|
||||
deux environnements Python **3.14** (`/opt/ml/.venv` et `/opt/backend/.venv`, `uv sync --locked` à
|
||||
la construction), distincts du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent
|
||||
vers ces venvs plutôt que d'importer LightGBM, MLflow ou SQLAlchemy dans le process Airflow.
|
||||
Le choix et ses contreparties sont dans
|
||||
l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
|
||||
|
||||
| DAG | Planification | Ce qu'il lance, et où |
|
||||
|---|---|---|
|
||||
| `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` |
|
||||
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
||||
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
||||
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
||||
|
||||
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
|
||||
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
|
||||
`./data/raw:/opt/data/raw:ro` permet au scheduler de lire les fichiers CSV/JSON sans pouvoir les
|
||||
modifier.
|
||||
|
||||
**Pourquoi `alertes` tourne à la quinzième minute.** Sa règle `anomaly` compare une lecture à la
|
||||
`prediction` du même instant, que `ml_score` écrit à l'heure pile. Le décalage laisse le scoring
|
||||
finir. Aucune dépendance n'est déclarée entre les deux DAGs pour autant, ni `ExternalTaskSensor` ni
|
||||
tâche greffée : quatre règles de détection sur cinq ne touchent pas au modèle, et un modèle jamais
|
||||
entraîné ne doit pas priver le parc de ses alertes. Le décalage est donc une convention et non une
|
||||
garantie : le plafond de `ml_score` est de 30 minutes, et un scoring qui déborde de `:15` prive
|
||||
`anomaly` de la `prediction` de l'heure, qu'elle ne retrouvera au passage suivant que si sa fenêtre
|
||||
la couvre encore. Les quatre autres règles ne s'en aperçoivent pas.
|
||||
|
||||
Ses deux tâches s'enchaînent en revanche (`recommendation.alert_id` est une clé étrangère `NOT
|
||||
NULL`), et toutes deux sont rejouables sans risque : l'idempotence est portée par la base,
|
||||
`uq_alert_source_reference` et `uq_recommendation_alert_rule`. Chacune a 2 tentatives, 2 minutes
|
||||
d'attente entre elles et un plafond de 5 minutes **par tentative** : au pire, reprises comprises,
|
||||
l'enchaînement occupe 38 minutes, ce qui le garde sous le pas horaire qu'un `max_active_runs=1`
|
||||
rend contraignant.
|
||||
|
||||
`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`,
|
||||
`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une
|
||||
migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et
|
||||
`api-server`, `dag-processor` et `scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
|
||||
passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
|
||||
|
||||
Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier
|
||||
entier avant de filtrer les services, une variable requise manquante casserait `make db-up`,
|
||||
`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est
|
||||
`airflow-init` qui refuse de démarrer (clé Fernet, clé de session de l'API, secret JWT, mot de
|
||||
passe ou `AIRFLOW_APP_SECRET_KEY` vides).
|
||||
|
||||
Le conteneur reçoit deux variables du backend en plus de `ML_DATABASE_URL` : `DATABASE_URL`, en
|
||||
dialecte asyncpg, et `APP_SECRET_KEY`, alimentée par `AIRFLOW_APP_SECRET_KEY`. Cette dernière est
|
||||
**délibérément différente** de celle de l'API. La configuration du backend refuse de se construire
|
||||
sans clé, mais la détection ne signe ni ne vérifie aucun jeton : un Airflow compromis, qui permet
|
||||
déjà d'exécuter du code depuis son interface, ne doit pas livrer par-dessus la clé de signature
|
||||
des JWT.
|
||||
|
||||
**Pourquoi `ml_train` est manuel.** Réentraîner est coûteux et sa cadence n'est pas une décision
|
||||
prise. Surtout, `train.py` écrase le modèle sans comparer ses métriques à celles de l'ancien : un
|
||||
cron déploierait silencieusement un modèle dégradé. Tant que ce garde-fou n'existe pas, le
|
||||
déclenchement reste humain. `ml_score`, lui, est planifié à l'heure, avec `max_active_runs=1`
|
||||
(pas deux scorings simultanés dans `prediction`), 2 tentatives et un plafond de 30 minutes.
|
||||
|
||||
CI : `.github/workflows/airflow.yml` (Python 3.12 via `etl/airflow/.python-version`) lance lint et
|
||||
tests d'intégrité des DAGs, et construit l'image (elle `COPY` `ml/` et `apps/backend/`, une
|
||||
modification de l'un ou de l'autre peut donc la casser, d'où leurs chemins dans les déclencheurs)
|
||||
avant de vérifier que les deux environnements s'y importent sans réseau.
|
||||
|
||||
Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier
|
||||
`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute
|
||||
`docker-entrypoint-initdb.d/` que sur un volume vide). Créer la base `airflow` à la main une fois :
|
||||
`docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE DATABASE airflow;"`.
|
||||
|
||||
`libgomp1` est installé explicitement dans l'image (`apt-get`, en root) : l'image Airflow de base
|
||||
est minimale et n'embarque pas la runtime OpenMP dont LightGBM a besoin, sans quoi l'erreur
|
||||
(`OSError: libgomp.so.1`) n'apparaît qu'à la première tâche réellement exécutée, pas à la
|
||||
construction de l'image.
|
||||
|
||||
## Machine cible, exécution Docker
|
||||
|
||||
Statut : `Fait`. Défini par l'overlay `docker-compose.prod.yml`, appliqué par-dessus le
|
||||
`docker-compose.yml`. Écrit et validé sur le poste, **jamais encore lancé sur le serveur de
|
||||
l'école**. Décision et motifs dans l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
navigateur["Navigateur"]
|
||||
|
||||
subgraph machine["Machine on-premise"]
|
||||
proxy["service proxy<br/>nginx:1.28-alpine<br/>:80 et :443"]
|
||||
front["service frontend<br/>nginx statique :3000"]
|
||||
api["service backend<br/>uvicorn :8000"]
|
||||
db[("service db<br/>:5432")]
|
||||
mail["service mailpit"]
|
||||
end
|
||||
|
||||
navigateur -->|"HTTPS"| proxy
|
||||
proxy -->|"/"| front
|
||||
proxy -->|"/api/"| api
|
||||
api --> db
|
||||
api --> mail
|
||||
```
|
||||
|
||||
Le proxy est **le seul service à publier des ports** sur le réseau. Backend et frontend ne sont
|
||||
plus publiés du tout, la base et l'interface Mailpit sont ramenées sur `127.0.0.1`, donc joignables
|
||||
par tunnel SSH et pas autrement. Le détail du routage, les deux modes d'obtention du certificat et
|
||||
la commande de validation hors exécution sont dans [`infra/proxy/README.md`](../../infra/proxy/README.md).
|
||||
|
||||
Deux conséquences se propagent jusqu'à l'application, et elles ne se devinent pas :
|
||||
|
||||
- Servir le SPA et l'API sous la même origine est ce qui rend le cookie `__Secure-ev_refresh`
|
||||
utilisable. Sans cela, `apiUrl: '/api/v1'` ne mène nulle part une fois en conteneur.
|
||||
- `APP_TRUST_PROXY_HEADERS` passe à vrai en même temps, sinon la limitation de débit par IP
|
||||
compte sur l'IP du proxy et devient globale.
|
||||
|
||||
### Deux environnements sur la même machine
|
||||
|
||||
Statut : `En cours`, la machine n'étant pas encore provisionnée. Décision et motifs dans
|
||||
l'[ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md).
|
||||
La VM `eadl-2025-nantes-g3` portera la recette et la production, chacune dans son clone du dépôt,
|
||||
son `.env` et son projet Compose. Le nom de projet préfixe volumes, réseau et conteneurs : rien
|
||||
n'est partagé. `scripts/provision-host.sh` prépare les deux dossiers, génère les secrets et les
|
||||
certificats, et ne démarre rien.
|
||||
|
||||
| | Recette | Production |
|
||||
|---|---|---|
|
||||
| Branche, environnement GitHub | `dev`, `rec` | `main`, `prod` |
|
||||
| Dossier, projet Compose | `/srv/enervision/rec`, `enervision-rec` | `/srv/enervision/prod`, `enervision-prod` |
|
||||
| URL | `https://rec.enervision.local:8443` | `https://enervision.local` |
|
||||
| Proxy HTTP, HTTPS | `127.0.0.1:8081`, `8443` | `80`, `443` |
|
||||
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
|
||||
|
||||
Les deux noms d'hôte visent la même IP, à déclarer dans le `/etc/hosts` des postes. Deux noms
|
||||
distincts sont nécessaires : le cookie `__Secure-ev_refresh` est posé par hôte, pas par port.
|
||||
La redirection HTTP de la recette est ramenée sur la boucle locale parce que la configuration
|
||||
Nginx renvoie vers `https://$host` sans port, c'est-à-dire vers la production.
|
||||
|
||||
Le déploiement est décrit dans [50-cicd.md](50-cicd.md) : un runner GitHub Actions installé sur
|
||||
la VM aligne le dossier sur la branche poussée et lance `make stack-up`.
|
||||
|
||||
## Cible à terme, k3s
|
||||
|
||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster. Il n'a jamais
|
||||
été appliqué.
|
||||
@@ -104,6 +265,11 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
||||
| `*.tfvars` ignoré, `*.tfvars.example` versionné | Les tfvars portent l'adresse du serveur et le chemin de la clé | `.gitignore` |
|
||||
| Désinstallation gérée au `destroy` | `k3s-uninstall.sh` en `on_failure = continue` : un serveur injoignable ne bloque pas le `destroy` | `modules/k3s/main.tf` |
|
||||
| Deux racines, `dev` et `prod` | Séparation des états et des variables par environnement | `environments/` |
|
||||
| Terminaison TLS par un reverse proxy Nginx en Compose | L'ingress k3s supposait un registre et des manifestes qui n'existent pas, à quatre jours du rendu | `docker-compose.prod.yml`, [ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md) |
|
||||
| Certificat auto-signé par défaut, chemin ACME câblé | Aucun domaine public ne résout vers la machine : le défi HTTP-01 ne peut pas aboutir | `scripts/tls-selfsigned.sh`, `infra/proxy/acme-deploy-hook.sh` |
|
||||
| Un projet Compose par environnement, sur la même machine | Une seule VM, et l'isolation par nom de projet ne demande ni cluster ni registre | `.env` de chaque dossier, [ADR 0009](../adr/0009-deux-environnements-compose-sur-la-vm-eni.md) |
|
||||
| Runner GitHub Actions auto-hébergé sur la VM | Les runners hébergés par GitHub ne joignent pas une adresse privée d'école | `.github/workflows/deploy.yml` |
|
||||
| Secrets dans le `.env` de chaque environnement, sur la machine | Ni dans git, ni dans GitHub : le runner n'a rien à recevoir | `scripts/provision-host.sh` |
|
||||
|
||||
## Ports et noms
|
||||
|
||||
@@ -112,12 +278,16 @@ Ces arbitrages sont pris. Ils ne vivaient jusqu'ici que dans des commentaires de
|
||||
| PostgreSQL, côté hôte | `5433` | Redirigé vers 5432 dans le conteneur. 5432 est souvent déjà pris |
|
||||
| PostgreSQL, côté réseau Compose | `db:5432` | Nom de service, utilisé par `DATABASE_URL` du service `backend` |
|
||||
| API | `8000` | Identique en conteneur et hors conteneur |
|
||||
| Frontend, `ng serve` | `4200` | Valeur par défaut d'`APP_CORS_ORIGINS`. Le compose n'a aucun service frontend |
|
||||
| Frontend, `ng serve` | `4200` | Boucle de développement. Valeur par défaut d'`APP_CORS_ORIGINS` |
|
||||
| Frontend en conteneur | `3000` | Ce qu'écoute le nginx de l'image, en conteneur comme côté hôte |
|
||||
| Reverse proxy | `80` et `443` | Les seuls ports publiés par `docker-compose.prod.yml`, via `PROXY_HTTP_PORT` et `PROXY_HTTPS_PORT`. 80 ne sert que la redirection et le défi ACME. La recette publie `8443` et `127.0.0.1:8081` |
|
||||
| SSH du serveur | `22` par défaut | `ssh_port`, redéfinissable |
|
||||
| Base applicative | `enervision` | Variable `POSTGRES_DB` |
|
||||
| Base de test | `enervision_test` | Créée par `db/init/110-test-database.sql`, nom attendu en dur par `apps/backend/tests/conftest.py` |
|
||||
| Base de métadonnées Airflow | `airflow` | Créée par `db/init/120-airflow-database.sql`, même conteneur `db` |
|
||||
| API server Airflow | `8080` | `make airflow-up`. Api-server, scheduler et dag-processor ne publient que ce port ; les tâches (`LocalExecutor`) tournent côté scheduler, sans port propre |
|
||||
|
||||
## Le trou entre les deux topologies
|
||||
## Le trou vers k3s
|
||||
|
||||
Rien ne relie aujourd'hui ce qui est construit par Compose et ce qui tournerait sur k3s. Compose
|
||||
construit une image backend localement ; k3s ne saurait pas où la trouver. C'est la première
|
||||
@@ -125,7 +295,11 @@ question à trancher, avant toute ressource Kubernetes.
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
- **Quel ingress** remplace Traefik, et qui termine le TLS.
|
||||
- **Quel ingress** remplace Traefik le jour de la bascule k3s. Qui termine le TLS est tranché par
|
||||
l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md), mais la réponse vaut pour la
|
||||
topologie Compose, pas pour Kubernetes.
|
||||
- **Quel nom de domaine public**, sans lequel Let's Encrypt reste hors d'atteinte et le certificat
|
||||
reste auto-signé.
|
||||
- **Quel registre d'images**, et comment il est alimenté sans CI.
|
||||
- **Quel stockage persistant** côté Kubernetes pour PostgreSQL, et si la base tourne dans le
|
||||
cluster ou à côté.
|
||||
|
||||
@@ -193,7 +193,7 @@ mécanisme que `ReadingRepository.latest_by_site()`. Un site jamais scoré rend
|
||||
plutôt qu'un statut inventé : le domaine `available`/`insufficient_data`/`error` de la contrainte
|
||||
`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.
|
||||
[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
|
||||
le moteur de règles d'`app/services/recommendation_rules.py` aux lignes d'`alert`, sans modèle ni
|
||||
@@ -256,12 +256,19 @@ auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spi
|
||||
explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul
|
||||
instant réel, pas une variation).
|
||||
|
||||
Comme `enervision_ml.score`, la détection est un script lancé à la main, pas encore ordonnancé par
|
||||
Airflow : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans
|
||||
`apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une
|
||||
connexion SQL directe (contrairement à `app/etl/historical_import.py`). Cette issue (#104)
|
||||
débloquait #38 (moteur de règles pour recommandations), dont la FK `alert_id` `NOT NULL` n'avait
|
||||
jusqu'ici rien à référencer côté `source="enervision"`.
|
||||
La détection s'exécute dans `apps/backend`, puisque les règles s'appuient sur les repositories ORM
|
||||
de l'API plutôt que sur une connexion SQL directe (contrairement à
|
||||
`app/etl/historical_import.py`) : `uv run python -m app.detection.internal_alerts [--site-id ...]
|
||||
[--now ...]`, ou `make detect-alerts`. Cette issue (#104) débloquait #38 (moteur de règles pour
|
||||
recommandations), dont la FK `alert_id` `NOT NULL` n'avait jusqu'ici rien à référencer côté
|
||||
`source="enervision"`.
|
||||
|
||||
Depuis l'issue #116, le lancement n'est plus manuel : le DAG Airflow `alertes` enchaîne cette
|
||||
détection et la génération des recommandations, toutes les heures à la quinzième minute. Airflow
|
||||
exécute le code du backend en sous-processus, dans son propre environnement, ce que décide
|
||||
l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md) ; le détail de l'ordonnancement est
|
||||
dans [10-infra.md](10-infra.md). La ligne de commande reste le moyen de rejouer une fenêtre
|
||||
passée, ce que `--now` permet et que le DAG ne fait pas.
|
||||
|
||||
### `/health/ready`
|
||||
|
||||
@@ -390,9 +397,12 @@ Le reste, par ordre de surface :
|
||||
de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL.
|
||||
- En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`,
|
||||
`Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au
|
||||
terminateur TLS, que l'application ne connaît pas.
|
||||
terminateur TLS, que l'application ne connaît pas : le reverse proxy les pose
|
||||
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)).
|
||||
- Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`.
|
||||
- Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative.
|
||||
- TLS, limitation de débit au frontal et journal d'accès sont portés par le reverse proxy.
|
||||
`APP_TRUST_PROXY_HEADERS` doit alors valoir vrai, sinon le compteur par IP devient global.
|
||||
- Pas de journalisation des accès applicative.
|
||||
|
||||
## Observabilité
|
||||
|
||||
|
||||
@@ -4,39 +4,47 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
|
||||
|
||||
## État actuel
|
||||
|
||||
Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée
|
||||
par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API.
|
||||
Statut : `En cours`. L'application sert le tableau de bord, la liste et le détail des sites, la
|
||||
supervision des capteurs (admin) et le flux des alertes actives, tous branchés sur l'API réelle.
|
||||
|
||||
Ce qui est en place :
|
||||
|
||||
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
|
||||
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
|
||||
`provideHttpClient(withInterceptors([mockApiInterceptor]))`.
|
||||
- Une route `/dashboard` en composant différé, et une redirection depuis la racine.
|
||||
- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et
|
||||
`AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification
|
||||
(jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`,
|
||||
`features/dashboard` la page principale, `shared/components` la jauge de consommation et le
|
||||
graphique de charge par site, tous deux construits sur Chart.js.
|
||||
`provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor]))`.
|
||||
- Des routes en composants différés (`/dashboard`, `/sites`, `/sites/:siteId`,
|
||||
`/monitoring/sensors` réservée au rôle `admin`) et une redirection depuis la racine.
|
||||
- `core/services` porte un service HTTP par domaine (`StatsService`, `AlertsService` avec ses
|
||||
filtres `site_id` et `severity`, `PredictionsService`, `SitesService`, `ReadingsService`,
|
||||
`SensorsService`, `AuthService`), `core/interceptors` l'intercepteur de fixtures et l'intercepteur
|
||||
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,
|
||||
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).
|
||||
- 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
|
||||
réutiliser plutôt que redéfinir ses propres styles. Détail :
|
||||
`ev-badge`, `ev-brand`, `ev-icon`, tokens CSS dans `styles/_tokens.scss`, classes globales de
|
||||
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).
|
||||
- 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.
|
||||
- Prettier configuré, parser `angular` pour les gabarits HTML.
|
||||
|
||||
Ce qui n'existe pas encore :
|
||||
|
||||
- **`stats`/`alerts` restent sur fixtures.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
|
||||
sont servis par l'intercepteur de fixtures ; l'API expose bien ces routes désormais, mais rien
|
||||
ne bascule `useMockFixtures` à `false` en développement pour les consommer réellement.
|
||||
`GET /api/v1/predictions` fait exception : jamais mocké, branché sur l'API réelle depuis cette
|
||||
PR (voir plus bas).
|
||||
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
|
||||
- **Le mode fixtures est inactif.** `useMockFixtures` vaut `false` dans `environment.ts` comme dans
|
||||
`environment.development.ts` : `mockApiInterceptor` ne sert `/stats/summary` et `/alerts` que
|
||||
dans son propre spec. En développement, toutes les pages exigent un backend joignable et un jeton
|
||||
valide.
|
||||
- Un état de chargement généralisé : seul `app-alert-feed` en a un, les autres pages restent vides
|
||||
tant que la première réponse n'est pas arrivée.
|
||||
- Aucun lint : ESLint n'est pas installé.
|
||||
|
||||
## Arborescence
|
||||
@@ -87,21 +95,21 @@ sequenceDiagram
|
||||
```
|
||||
|
||||
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
|
||||
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
|
||||
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
|
||||
`/predictions` est volontairement exclu de cette liste (contrairement à `stats`/`alerts`) : il
|
||||
suit toujours le chemin réel, comme `/auth/*` - en développement, ça veut dire qu'un jeton valide
|
||||
et un backend joignable sont nécessaires pour que la section prévisions du dashboard s'affiche.
|
||||
`environment.useMockFixtures` est vrai. Le drapeau vaut `false` dans les deux fichiers
|
||||
d'environnement : en pratique toutes les requêtes suivent le chemin réel et l'intercepteur n'est
|
||||
exercé que par son spec. `/predictions` et `/auth/*` ne sont de toute façon jamais mockés. En
|
||||
développement, un jeton valide et un backend joignable sont donc nécessaires pour que le tableau de
|
||||
bord s'affiche.
|
||||
|
||||
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
|
||||
`apiUrl` relatif, `/api/v1`.
|
||||
|
||||
En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif
|
||||
(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur
|
||||
`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...`
|
||||
sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le
|
||||
backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md).
|
||||
En production, `environment.ts` porte lui aussi un `apiUrl` relatif (`/api/v1`) plutôt qu'une URL
|
||||
absolue : la dette qui pointait en dur sur `http://localhost:8000/api/v1` a été corrigée. Un build
|
||||
de production sert donc l'appel `/api/v1/...` sur son propre origin, et c'est le **reverse proxy**
|
||||
qui route `/api` vers le backend : `location /api/` dans `infra/proxy/conf.d/enervision.conf`, voir
|
||||
[10-infra.md](10-infra.md) et l'[ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md).
|
||||
|
||||
## Exécution
|
||||
|
||||
@@ -118,13 +126,14 @@ le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.1
|
||||
tester ni construire le frontend.
|
||||
|
||||
Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`,
|
||||
englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en
|
||||
développement il tourne toujours directement via `npm`, depuis `apps/frontend`. Le port 4200
|
||||
n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend.
|
||||
englobées par `install` et `dev`). En développement il tourne directement via `npm`, depuis
|
||||
`apps/frontend` : le port 4200 n'apparaît dans le compose que comme valeur par défaut
|
||||
d'`APP_CORS_ORIGINS`, côté backend.
|
||||
|
||||
Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans
|
||||
`CMD` : il construit sans rien servir. Le `README.md` de l'application demande un multi-étage
|
||||
avec un service statique, il reste à écrire.
|
||||
Le service `frontend` du `docker-compose.yml` sert le build statique par le nginx de
|
||||
`apps/frontend/Dockerfile`, multi-étage, qui **écoute sur 3000**. En déploiement il n'est plus
|
||||
publié du tout : le reverse proxy est seul à sortir sur le réseau, et l'atteint par le réseau
|
||||
Compose.
|
||||
|
||||
## Sécurité
|
||||
|
||||
@@ -133,11 +142,41 @@ avec un service statique, il reste à écrire.
|
||||
`/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le
|
||||
rafraîchissement sur 401. Détail complet dans
|
||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||
- **La CSP posée par le reverse proxy contraint le build.** `script-src 'self'` interdit les
|
||||
gestionnaires d'événements en ligne ; l'inlining du CSS critique en produisait un
|
||||
(`<link media="print" onload="this.media='all'">`), ce qui aurait laissé l'application sans
|
||||
style derrière le proxy. D'où `optimization.styles.inlineCritical: false` dans la configuration
|
||||
de production d'`angular.json`. La contrepartie est un rendu non stylé très bref au premier
|
||||
affichage. `style-src` conserve `'unsafe-inline'` : Angular injecte les styles de composants à
|
||||
l'exécution, et s'en passer demanderait un `ngCspNonce` que le SPA statique ne peut pas produire.
|
||||
|
||||
## Tests
|
||||
|
||||
Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md).
|
||||
|
||||
## Recommandations
|
||||
|
||||
Statut : `Fait`. La vue `/recommendations` (`features/recommendations`, derrière `authGuard`, tous
|
||||
rôles) présente les recommandations du moteur de règles groupées par alerte, du plus récent au plus
|
||||
ancien, avec le contexte de l'alerte (sévérité, type, site, horodatage, message) puis chaque action,
|
||||
son explication et la règle qui l'a produite.
|
||||
|
||||
- **Jointure côté client.** Une recommandation ne porte que `alert_id`, jamais `site_id`, et
|
||||
`GET /recommendations` n'a aucun filtre. `app-recommendation-list` (`shared/components/`) charge
|
||||
donc en parallèle `GET /alerts` (filtré par `site_id` quand un site est fixé) et
|
||||
`GET /recommendations`, puis les joint par `alert_id` (`joinByAlert`, fonction pure testée à
|
||||
part). Les recommandations dont l'alerte n'est pas dans le jeu chargé sont ignorées : c'est ainsi
|
||||
que le filtre site s'applique. `/alerts` n'étant pas paginé, un seul appel suffit.
|
||||
- **Paramètres d'URL.** `?site=<site_id>` présélectionne le filtre site ; `?alert=<alert_id>`
|
||||
réduit la vue à une alerte et la met en évidence (entier strictement positif, sinon ignoré).
|
||||
- **Génération.** Le bouton « Générer les recommandations » n'apparaît que pour le rôle `admin`
|
||||
(`POST /recommendations/generate?site_id=`, réservé admin côté API) et affiche le bilan renvoyé
|
||||
(créées, déjà présentes, alertes examinées) avant de recharger la liste. La voie normale reste le
|
||||
DAG Airflow `alertes` ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
|
||||
- **Entrées.** Lien « Recommandations » dans l'en-tête du tableau de bord ; section
|
||||
« Recommandations » sur la vue détail d'un site (liste restreinte au site, lien vers la vue
|
||||
complète préfiltrée).
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
|
||||
|
||||
@@ -129,18 +129,18 @@ n'est pas envoyé et le rafraîchissement échoue toujours.
|
||||
En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est
|
||||
**même origine** et le cookie marche sans rien configurer.
|
||||
|
||||
En production, `src/environments/environment.ts` contient encore le gabarit
|
||||
`http://localhost:8000/api/v1`, en HTTP simple et sur une autre origine. **Dans cet état, aucun
|
||||
cookie `Secure` ne sera posé et l'authentification ne fonctionnera pas.**
|
||||
En déploiement, les deux conditions sont désormais remplies par le reverse proxy
|
||||
([ADR 0007](../adr/0007-terminaison-tls-et-reverse-proxy-nginx.md)) : `environment.ts` porte un
|
||||
`apiUrl` relatif, `/api/v1`, et le proxy sert le SPA sur `/` et l'API sur `/api/` **sous la même
|
||||
origine, en HTTPS**. C'est cela, et rien d'autre, qui rend le cookie `__Secure-ev_refresh`
|
||||
utilisable : servi en HTTP simple ou depuis une autre origine, il n'est jamais posé et
|
||||
l'authentification ne survit pas à un rechargement de page.
|
||||
|
||||
Deux corrections, à faire avant la démonstration :
|
||||
|
||||
1. passer `apiUrl` à `/api/v1` et servir le SPA et l'API sous la même origine, via un
|
||||
`location /api` dans le `nginx.conf` du conteneur frontend ou via l'ingress ;
|
||||
2. servir en HTTPS.
|
||||
Ce qui reste à surveiller : le certificat est auto-signé tant qu'aucun domaine public ne résout
|
||||
vers la machine. Un navigateur qui refuse l'exception refusera aussi le cookie.
|
||||
|
||||
Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin
|
||||
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que le proxy masque.
|
||||
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que la même origine masque.
|
||||
|
||||
## Origines autorisées
|
||||
|
||||
|
||||
@@ -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-text-inverse` | Texte sur fond coloré plein (boutons/badges) |
|
||||
| `--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) |
|
||||
| `--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) |
|
||||
|
||||
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
|
||||
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
|
||||
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
|
||||
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
|
||||
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
|
||||
`<ev-alert severity="danger">`, pas une classe dédiée.
|
||||
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`)
|
||||
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
|
||||
s'appliquent directement à des `<label>`/`<input>`/`<select>` natifs, liés par `formControlName` ou
|
||||
par un simple `(change)` : pas de composant `ControlValueAccessor` dédié, le gain n'en vaut pas la
|
||||
complexité pour des formulaires aussi simples que ceux de ce projet. `.form-select` habille un
|
||||
`<select>` natif avec la bordure et le focus de `.form-input`, plus un chevron. Les erreurs de
|
||||
formulaire, elles, s'affichent via `<ev-alert severity="danger">`, pas une classe dédiée.
|
||||
|
||||
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
|
||||
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
|
||||
@@ -83,6 +85,23 @@ dans le tableau `imports` du composant qui l'utilise.
|
||||
```html
|
||||
<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
|
||||
|
||||
|
||||
+357
-57
@@ -6,10 +6,18 @@ système qui en découle.
|
||||
|
||||
## Ce que couvre ce document
|
||||
|
||||
**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données
|
||||
d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles
|
||||
marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne
|
||||
d'ingestion, les agrégats continus, la compression et la rétention.
|
||||
**Douze tables applicatives existent** : six pour l'authentification et six pour les données
|
||||
d'énergie, dont l'hypertable `reading`.
|
||||
|
||||
Les sections marquées `Fait` relèvent du code déjà implémenté. Les sections marquées `Cible`
|
||||
décrivent les éléments prévus mais pas encore réalisés.
|
||||
|
||||
L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le dataset CSV/JSON et
|
||||
l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir
|
||||
l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). Les alertes `source='enervision'`,
|
||||
elles, sont produites par la détection interne, désormais ordonnancée par le DAG Airflow `alertes`
|
||||
(issue #116). L'orchestration de l'ingestion, les agrégats continus, la compression et la
|
||||
rétention restent des cibles.
|
||||
|
||||
## Trois emplacements, trois rôles
|
||||
|
||||
@@ -35,8 +43,16 @@ Statut : `Fait`.
|
||||
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
|
||||
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
|
||||
`apps/backend/tests/conftest.py`.
|
||||
- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
|
||||
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
|
||||
- Six révisions Alembic sont actuellement appliquées.
|
||||
- La première, `5353c0e4f094`, **ne crée aucune table** : elle établit `alembic_version`
|
||||
et refuse de s'appliquer si l'extension TimescaleDB manque.
|
||||
- Les révisions suivantes créent les tables liées à l'authentification :
|
||||
`app_user`, `login_attempt`, `audit_log` et `refresh_token`.
|
||||
- La révision `e6d2026091501` crée les six tables Data et déclare l'hypertable `reading`.
|
||||
- La révision `c0adab96238c` ajoute les tables `password_reset_attempt`
|
||||
et `password_reset_token`.
|
||||
|
||||
La garde de la première migration est :
|
||||
|
||||
```sql
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
|
||||
@@ -47,37 +63,58 @@ END IF;
|
||||
Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas
|
||||
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
|
||||
|
||||
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
|
||||
puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée
|
||||
les six tables de données décrites en fin de document et déclare l'hypertable `reading`.
|
||||
|
||||
## Cycle de vie d'une mesure
|
||||
|
||||
Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats
|
||||
continus, ni la compression, ni la rétention ne sont écrits.
|
||||
Statut : `Partiellement fait`.
|
||||
|
||||
Les mécanismes d'ingestion sont maintenant implémentés pour les deux sources de données du MVP :
|
||||
|
||||
- le dataset historique CSV/JSON avec `historical_import.py` ;
|
||||
- l'API Mock avec `mock_api_import.py`.
|
||||
|
||||
Les traitements sont actuellement exécutables directement depuis le backend.
|
||||
|
||||
L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus,
|
||||
la compression et les politiques de rétention.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
|
||||
ing -.-> hy[("Hypertable reading")]
|
||||
csv["CSV + JSON"] --> hist["historical_import.py"]
|
||||
mock["API Mock"] --> api["mock_api_import.py"]
|
||||
|
||||
hist --> hy[("Hypertable reading")]
|
||||
api --> hy
|
||||
|
||||
airflow["Airflow"] -.-> hist
|
||||
airflow -.-> api
|
||||
|
||||
hy -.-> agg[("Agrégat continu")]
|
||||
hy -.-> comp["Compression"]
|
||||
hy -.-> ret["Rétention"]
|
||||
agg -.-> api["API FastAPI"]
|
||||
|
||||
agg -.-> backend["API FastAPI"]
|
||||
agg -.-> graf["Grafana"]
|
||||
```
|
||||
|
||||
Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout
|
||||
Les flèches pleines représentent les traitements actuellement implémentés.
|
||||
|
||||
Les flèches pointillées représentent les éléments encore prévus comme cibles.
|
||||
|
||||
Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout
|
||||
l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront.
|
||||
|
||||
## Tables d'authentification
|
||||
|
||||
Statut : `Fait`. Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les
|
||||
hypertables ; elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API.
|
||||
Statut : `Fait`.
|
||||
|
||||
Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les hypertables ;
|
||||
elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
APP_USER ||--o{ REFRESH_TOKEN : ouvre
|
||||
APP_USER ||--o{ PASSWORD_RESET_TOKEN : recoit
|
||||
|
||||
APP_USER {
|
||||
uuid id PK
|
||||
string email UK
|
||||
@@ -88,6 +125,7 @@ erDiagram
|
||||
bool must_change_password
|
||||
timestamptz credentials_changed_at
|
||||
}
|
||||
|
||||
REFRESH_TOKEN {
|
||||
uuid id PK
|
||||
uuid family_id
|
||||
@@ -99,6 +137,7 @@ erDiagram
|
||||
text revoked_reason
|
||||
uuid replaced_by
|
||||
}
|
||||
|
||||
LOGIN_ATTEMPT {
|
||||
bigint id PK
|
||||
timestamptz occurred_at
|
||||
@@ -106,6 +145,7 @@ erDiagram
|
||||
inet client_ip
|
||||
text outcome
|
||||
}
|
||||
|
||||
AUDIT_LOG {
|
||||
bigint id PK
|
||||
timestamptz occurred_at
|
||||
@@ -114,9 +154,27 @@ erDiagram
|
||||
text action
|
||||
jsonb detail
|
||||
}
|
||||
|
||||
PASSWORD_RESET_ATTEMPT {
|
||||
bigint id PK
|
||||
timestamptz occurred_at
|
||||
string email_tried
|
||||
inet client_ip
|
||||
}
|
||||
|
||||
PASSWORD_RESET_TOKEN {
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
bytea token_hash UK
|
||||
timestamptz issued_at
|
||||
timestamptz expires_at
|
||||
timestamptz consumed_at
|
||||
inet client_ip
|
||||
text user_agent
|
||||
}
|
||||
```
|
||||
|
||||
Quatre choix de modélisation portent une intention et se défendent seuls :
|
||||
Six choix de modélisation portent une intention et se défendent seuls :
|
||||
|
||||
- **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de
|
||||
`CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition
|
||||
@@ -129,6 +187,10 @@ Quatre choix de modélisation portent une intention et se défendent seuls :
|
||||
- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont
|
||||
dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur
|
||||
d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md).
|
||||
- **`password_reset_token` ne stocke que l'empreinte du jeton**, jamais sa valeur. Une fuite de
|
||||
la table ne donne donc rien à rejouer.
|
||||
- **`password_reset_attempt` est séparée de `audit_log`** : son volume est piloté par le
|
||||
demandeur, comme celui de `login_attempt`, donc elle doit pouvoir se purger.
|
||||
|
||||
`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est
|
||||
donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils
|
||||
@@ -137,8 +199,9 @@ piloté par l'attaquant.
|
||||
|
||||
## Gabarit de révision créant une hypertable
|
||||
|
||||
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision
|
||||
`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
|
||||
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision.
|
||||
|
||||
La révision `e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
|
||||
|
||||
```python
|
||||
def upgrade() -> None:
|
||||
@@ -182,24 +245,29 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà.
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit
|
||||
porte sur son exploitation, plus sur sa forme.
|
||||
Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma et l'ingestion sont
|
||||
livrés : ce qui suit porte sur leur exploitation, plus sur leur forme.
|
||||
|
||||
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
|
||||
- **Quels agrégats continus**, et sur quelles fenêtres.
|
||||
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
|
||||
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
|
||||
- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure.
|
||||
- **Quels agrégats continus** créer et sur quelles fenêtres.
|
||||
- **Quelle profondeur de rétention** conserver en données brutes et à partir de quand compresser.
|
||||
- **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures.
|
||||
|
||||
## Modélisation détaillée des données
|
||||
|
||||
Cette modélisation prend en compte les fichiers CSV historiques,
|
||||
leurs métadonnées JSON et les données de l’API Mock.
|
||||
Elle comprend six tables, depuis le stockage des mesures
|
||||
jusqu’aux recommandations proposées à l’utilisateur.
|
||||
Cette modélisation prend en compte :
|
||||
|
||||
- les fichiers CSV historiques ;
|
||||
- leurs métadonnées JSON ;
|
||||
- les données de l'API Mock.
|
||||
|
||||
Elle comprend six tables Data, depuis le stockage des mesures jusqu'aux recommandations proposées
|
||||
à l'utilisateur.
|
||||
|
||||
### Schéma de données
|
||||
|
||||
Le diagramme ci-dessous présente les tables et leurs relations.
|
||||
|
||||
La révision `e6d2026091501` les crée.
|
||||
|
||||

|
||||
@@ -208,27 +276,26 @@ La révision `e6d2026091501` les crée.
|
||||
|
||||
### Description des tables
|
||||
|
||||
Chaque table remplit un rôle précis dans le traitement et l’exploitation
|
||||
des données.
|
||||
Chaque table remplit un rôle précis dans le traitement et l'exploitation des données.
|
||||
|
||||
| Table | Rôle | Origine des informations |
|
||||
|---|---|---|
|
||||
| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
|
||||
| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l'import |
|
||||
| `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
|
||||
| `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
|
||||
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
|
||||
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d'EnerVision |
|
||||
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
|
||||
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
|
||||
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d'EnerVision |
|
||||
|
||||
Les anomalies historiques décrites dans les JSON sont conservées
|
||||
dans `dataset.metadata`. Elles servent à l’analyse des données
|
||||
et ne sont pas considérées comme des alertes actuelles.
|
||||
Les anomalies historiques décrites dans les JSON sont conservées dans `dataset.metadata`.
|
||||
|
||||
Elles servent à l'analyse des données et ne sont pas considérées comme des alertes actuelles.
|
||||
|
||||
Les lignes de `recommendation` sont écrites par le moteur de règles du backend
|
||||
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`
|
||||
ou par `make recommendations`, à partir des alertes déjà en base. Le couple
|
||||
`(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes n'ajoute aucune
|
||||
ligne.
|
||||
(`app/services/recommendation_rules.py`), déclenché par `POST /api/v1/recommendations/generate`,
|
||||
par `make recommendations`, ou par la seconde tâche du DAG `alertes`, à partir des alertes déjà en
|
||||
base. Le couple `(alert_id, rule_reference)` est unique : rejouer le moteur sur les mêmes alertes
|
||||
n'ajoute aucune ligne.
|
||||
|
||||
### Relations entre les tables
|
||||
|
||||
@@ -240,13 +307,19 @@ ligne.
|
||||
|
||||
## Ingestion des données historiques
|
||||
|
||||
Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre du projet.
|
||||
Statut : `Fait`.
|
||||
|
||||
Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période du 1er janvier 2023 au 31 décembre 2024.
|
||||
Le MVP EnerVision initialise les données énergétiques à partir du dataset fourni dans le cadre
|
||||
du projet.
|
||||
|
||||
Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données. Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
|
||||
Le dataset de référence contient 122 647 mesures issues de 7 sites et couvre la période
|
||||
du 1er janvier 2023 au 31 décembre 2024.
|
||||
|
||||
### Architecture du flux
|
||||
Les fichiers sources CSV et JSON sont nécessaires uniquement pour l'initialisation des données.
|
||||
|
||||
Ils ne sont pas versionnés dans Git et sont placés localement dans `data/raw/`.
|
||||
|
||||
### Architecture du flux historique
|
||||
|
||||
```text
|
||||
Dataset CSV + métadonnées JSON
|
||||
@@ -277,17 +350,26 @@ Dataset CSV + métadonnées JSON
|
||||
|
||||
Le pipeline est développé en Python.
|
||||
|
||||
Pandas est utilisé pour l'extraction, la validation et la préparation des données. SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB.
|
||||
Pandas est utilisé pour l'extraction, la validation et la préparation des données.
|
||||
|
||||
SQLAlchemy Async assure le chargement transactionnel dans PostgreSQL/TimescaleDB.
|
||||
|
||||
Une empreinte SHA-256 permet d'identifier le dataset utilisé et d'assurer sa traçabilité.
|
||||
|
||||
Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources. Aucune imputation n'est réalisée à cette étape.
|
||||
Les valeurs manquantes sont conservées pendant l'ingestion afin de préserver les données sources.
|
||||
|
||||
Aucune imputation n'est réalisée à cette étape.
|
||||
|
||||
Le chargement des mesures est effectué par batches de 1 000 lignes.
|
||||
|
||||
Les données provenant du dataset CSV sont identifiées par `source = "csv"` et associées à leur `dataset_id`.
|
||||
Les données provenant du dataset CSV sont identifiées par :
|
||||
|
||||
### Résultats validés
|
||||
```text
|
||||
source = "csv"
|
||||
dataset_id = identifiant du dataset
|
||||
```
|
||||
|
||||
### Résultats validés pour l'historique
|
||||
|
||||
Le chargement de référence a permis d'obtenir :
|
||||
|
||||
@@ -296,14 +378,232 @@ Le chargement de référence a permis d'obtenir :
|
||||
- 122 647 mesures ;
|
||||
- 0 doublon détecté dans le dataset source.
|
||||
|
||||
L'idempotence a également été vérifiée par une deuxième exécution du pipeline : aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647.
|
||||
L'idempotence a également été vérifiée par une deuxième exécution du pipeline :
|
||||
aucune nouvelle mesure n'a été créée et le nombre de `reading` est resté à 122 647.
|
||||
|
||||
La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline est disponible dans `etl/README.md`.
|
||||
La procédure détaillée d'installation, d'exécution, de validation et de contrôle du pipeline
|
||||
est disponible dans `etl/README.md`.
|
||||
|
||||
### Évolution prévue
|
||||
## Ingestion depuis l'API Mock
|
||||
|
||||
L'étape suivante consiste à orchestrer les traitements Data avec Apache Airflow.
|
||||
Statut : `Fait`.
|
||||
|
||||
L'orchestration réutilisera la logique ETL existante afin de séparer la logique de traitement de la planification, du suivi des exécutions et de la gestion des erreurs.
|
||||
La deuxième source du pipeline Data est l'API Mock EnerVision.
|
||||
|
||||
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle de Machine Learning.
|
||||
Le traitement est implémenté dans :
|
||||
|
||||
```text
|
||||
apps/backend/app/etl/mock_api_import.py
|
||||
```
|
||||
|
||||
### Endpoints utilisés
|
||||
|
||||
Le pipeline récupère les informations des sites depuis :
|
||||
|
||||
```text
|
||||
GET /api/v1/sites
|
||||
```
|
||||
|
||||
puis les mesures historiques simulées depuis :
|
||||
|
||||
```text
|
||||
GET /api/v1/readings
|
||||
```
|
||||
|
||||
Pour `/api/v1/readings`, les informations suivantes sont envoyées :
|
||||
|
||||
```text
|
||||
site_id
|
||||
start_time
|
||||
end_time
|
||||
limit
|
||||
```
|
||||
|
||||
Les paramètres de ligne de commande disponibles pour l'import sont :
|
||||
|
||||
```text
|
||||
--start-time
|
||||
--end-time
|
||||
--limit
|
||||
--dry-run
|
||||
```
|
||||
|
||||
### Flux d'ingestion API Mock
|
||||
|
||||
```text
|
||||
API Mock
|
||||
|
|
||||
+-----+------+
|
||||
| |
|
||||
v v
|
||||
/sites /readings
|
||||
| |
|
||||
+-----+------+
|
||||
|
|
||||
v
|
||||
mock_api_import.py
|
||||
|
|
||||
v
|
||||
Transformation
|
||||
+ qualité data
|
||||
|
|
||||
v
|
||||
PostgreSQL / TimescaleDB
|
||||
| |
|
||||
v v
|
||||
site reading
|
||||
```
|
||||
|
||||
Les informations des sites sont insérées ou mises à jour dans `site`.
|
||||
|
||||
Les mesures sont enregistrées dans l'hypertable `reading` avec :
|
||||
|
||||
```text
|
||||
source = "api_history"
|
||||
dataset_id = NULL
|
||||
```
|
||||
|
||||
Les données provenant de l'API Mock ne sont donc pas associées à un enregistrement de la table
|
||||
`dataset`.
|
||||
|
||||
La réponse source reçue depuis l'API est conservée dans :
|
||||
|
||||
```text
|
||||
raw_data
|
||||
```
|
||||
|
||||
### Frontière de confiance avec l'API Mock
|
||||
|
||||
L'API Mock de l'école n'a aucune authentification et expose un endpoint mutatif à quiconque. Sa
|
||||
réponse est donc traitée comme une entrée hostile, conformément à API10 dans
|
||||
[la traçabilité OWASP](owasp-traceabilite.md). Le risque premier n'est pas la fausse alerte,
|
||||
c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction.
|
||||
|
||||
Quatre garde-fous, tous dans `mock_api_import.py` :
|
||||
|
||||
| Garde-fou | Mise en œuvre |
|
||||
|---|---|
|
||||
| Timeout | `APP_MOCK_API_TIMEOUT_SECONDS`, dix secondes par défaut |
|
||||
| Taille de tableau plafonnée | `MAX_SITES` sites, et au plus `--limit` mesures par site |
|
||||
| Bornes physiques | `PHYSICAL_BOUNDS`, une plage par grandeur |
|
||||
| Frontière d'anti-corruption | `build_site_row()` et `build_reading_row()`, qui ne recopient que les champs attendus |
|
||||
|
||||
Une valeur hors bornes, d'un type inattendu, `NaN` ou infinie devient `NULL`. Elle laisse sa
|
||||
trace dans `null_reasons` sous la forme `out_of_physical_bounds:<colonne>`, et `data_quality`
|
||||
descend à `degraded`. Une `data_quality` que `ck_reading_quality` refuserait devient `NULL`
|
||||
plutôt que de faire échouer le lot entier. Dans tous les cas `raw_data` conserve la réponse
|
||||
d'origine intacte : rien n'est perdu, seule son exploitation est bornée.
|
||||
|
||||
Le plafond de taille s'applique après désérialisation de la réponse. Borner le corps HTTP
|
||||
lui-même demanderait une lecture en flux, et reste à faire.
|
||||
|
||||
### Qualité des données de l'API Mock
|
||||
|
||||
Les valeurs `NULL` ne sont pas remplacées pendant l'ingestion.
|
||||
|
||||
Les informations suivantes fournies par l'API sont conservées :
|
||||
|
||||
```text
|
||||
data_quality
|
||||
null_reasons
|
||||
```
|
||||
|
||||
Cette conservation permet de distinguer une valeur manquante d'une valeur réelle égale à zéro
|
||||
et de garder les informations liées aux éventuelles défaillances de capteurs.
|
||||
|
||||
Aucune imputation n'est réalisée pendant cette phase :
|
||||
|
||||
```text
|
||||
imputed_values = NULL
|
||||
imputation_method = NULL
|
||||
```
|
||||
|
||||
### Validation de l'import API Mock
|
||||
|
||||
Un scénario de validation a été exécuté pour les 7 sites sur la période :
|
||||
|
||||
```text
|
||||
15/06/2024 12:00 UTC
|
||||
à
|
||||
15/06/2024 13:00 UTC
|
||||
```
|
||||
|
||||
avec :
|
||||
|
||||
```text
|
||||
limit = 60
|
||||
```
|
||||
|
||||
Résultat :
|
||||
|
||||
```text
|
||||
7 sites
|
||||
60 lectures par site
|
||||
420 lectures récupérées
|
||||
```
|
||||
|
||||
Les données ont été chargées dans PostgreSQL/TimescaleDB puis contrôlées directement en base.
|
||||
|
||||
Les contrôles ont confirmé :
|
||||
|
||||
- `source = "api_history"` ;
|
||||
- `dataset_id = NULL` ;
|
||||
- la conservation des valeurs `NULL` ;
|
||||
- la conservation de `data_quality` ;
|
||||
- la conservation de `null_reasons` ;
|
||||
- la conservation de `raw_data`.
|
||||
|
||||
L'idempotence a été vérifiée en rejouant le même import.
|
||||
|
||||
Une mesure déjà présente n'est pas ajoutée une seconde fois.
|
||||
|
||||
Les tests automatisés couvrent également :
|
||||
|
||||
- la récupération des sites ;
|
||||
- les paramètres envoyés à `/api/v1/readings` ;
|
||||
- les réponses HTTP en erreur ;
|
||||
- le format de la réponse ;
|
||||
- la transformation des mesures ;
|
||||
- les valeurs manquantes ;
|
||||
- la qualité des données ;
|
||||
- la conservation des données sources ;
|
||||
- l'idempotence en base.
|
||||
|
||||
## Évolution prévue
|
||||
|
||||
La prochaine étape consiste à orchestrer les deux mécanismes d'ingestion avec Apache Airflow.
|
||||
|
||||
```text
|
||||
CSV / JSON ----------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Airflow |
|
||||
+------------------+
|
||||
|
|
||||
+----------------+----------------+
|
||||
| |
|
||||
v v
|
||||
historical_import.py mock_api_import.py
|
||||
| |
|
||||
+----------------+----------------+
|
||||
|
|
||||
v
|
||||
PostgreSQL / TimescaleDB
|
||||
```
|
||||
|
||||
Airflow servira à :
|
||||
|
||||
- planifier les traitements ;
|
||||
- définir leur ordre d'exécution ;
|
||||
- suivre leur état ;
|
||||
- gérer et remonter les erreurs ;
|
||||
- faciliter les exécutions récurrentes.
|
||||
|
||||
Airflow ne remplacera pas la logique ETL déjà implémentée.
|
||||
|
||||
Les scripts Python resteront responsables de l'extraction, de la validation, de la transformation
|
||||
et du chargement des données.
|
||||
|
||||
Le pipeline servira ensuite de base à la préparation des données nécessaires au modèle
|
||||
de Machine Learning.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user