diff --git a/.github/workflows/airflow.yml b/.github/workflows/airflow.yml index d5a722a..8ae13d1 100644 --- a/.github/workflows/airflow.yml +++ b/.github/workflows/airflow.yml @@ -91,11 +91,12 @@ jobs: bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help" # `--help` sort par argparse avant `get_settings()` : ni base ni secret requis, et - # l'import du module prouve que l'environnement /opt/backend est complet. Les deux - # commandes du DAG `alertes` sont couvertes, `app.cli` tirant tout FastAPI derrière lui. - - name: Vérifie que les deux commandes du DAG alertes s'importent sans réseau + # 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.cli generate-recommendations --help + && env -u VIRTUAL_ENV uv run --no-sync python -m app.etl.historical_import --help" \ No newline at end of file diff --git a/Makefile b/Makefile index 7543607..3499f54 100644 --- a/Makefile +++ b/Makefile @@ -13,11 +13,28 @@ 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 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 \ + 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 @@ -39,12 +56,19 @@ install-ml: ## Installe les dépendances du pipeline ML install-airflow: ## Installe les dépendances de lint/test des DAGs Airflow cd $(AIRFLOW) && uv sync --all-groups -dev: ## Lance toute la stack (backend + frontend) en rechargement à chaud +dev: services-up migrate demo-data ## Lance toute la stack : base, Mailpit, Airflow, puis backend et frontend + @echo "airflow -> http://localhost:$(AIRFLOW_PORT) mailpit -> http://localhost:$(MAILPIT_UI_PORT)" @trap 'kill 0' EXIT INT TERM; \ $(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-webserver airflow-scheduler + 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 @@ -91,8 +115,8 @@ ml-check: ml-lint ml-typecheck ml-test ## Chaîne de vérification complète du ml-train: ## Entraine le modele LightGBM. CSV=chemin optionnel, sinon lit ML_DATABASE_URL 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),) @@ -108,7 +132,7 @@ airflow-test: ## Verifie que les DAGs s'importent sans erreur et ont la structur airflow-check: airflow-lint airflow-test ## Chaîne de vérification complète des DAGs Airflow -airflow-up: ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant. +airflow-up: db-ensure-airflow ## Démarre Airflow (webserver + scheduler, LocalExecutor). db-up requis avant. docker compose up -d airflow-init airflow-webserver airflow-scheduler @echo "airflow -> http://localhost:$${AIRFLOW_PORT:-8080}" @@ -168,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 diff --git a/README.md b/README.md index 6a2b21b..5aedb98 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | | Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord | | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | -| ETL | Apache Airflow | `etl/airflow` | Trois DAGs | +| ETL | Apache Airflow | `etl/airflow` | Quatre DAGs | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | Reverse proxy | Nginx, TLS | `infra/proxy` | En place | | CI/CD | GitHub Actions | `.github/workflows` | Backend en place | @@ -48,7 +48,7 @@ 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'orchestration (pipeline ML, alertes) +│ ├── 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 @@ -75,25 +75,52 @@ Prerequis : uv, Docker, Node 24 LTS (npm fourni). Le poste doit disposer de Pyth cp .env.example .env # variables de docker-compose cp 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 | (documentation sur `/docs`) | +| Frontend | | +| Airflow | (`AIRFLOW_ADMIN_USERNAME` / `AIRFLOW_ADMIN_PASSWORD` du `.env`) | +| Mailpit | | + +Le `.env` doit porter les cles Airflow avant le premier `make dev` : `AIRFLOW_FERNET_KEY`, +`AIRFLOW_WEBSERVER_SECRET_KEY`, `AIRFLOW_APP_SECRET_KEY` et `AIRFLOW_ADMIN_PASSWORD`. Sans elles +`airflow-init` refuse de demarrer, et `airflow-webserver` comme `airflow-scheduler` avec lui. + +Les cibles d'origine restent disponibles pour ne demarrer qu'une partie : `make db-up`, +`make airflow-up`, `make dev-backend`, `make dev-frontend`. + `make help` liste les cibles disponibles. 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 : diff --git a/apps/backend/app/repositories/alert.py b/apps/backend/app/repositories/alert.py index f495a3b..b7d6eb2 100644 --- a/apps/backend/app/repositories/alert.py +++ b/apps/backend/app/repositories/alert.py @@ -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 diff --git a/apps/backend/tests/repositories/test_alert.py b/apps/backend/tests/repositories/test_alert.py index 16c5a9a..ba9ff08 100644 --- a/apps/backend/tests/repositories/test_alert.py +++ b/apps/backend/tests/repositories/test_alert.py @@ -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 diff --git a/apps/frontend/TESTING.md b/apps/frontend/TESTING.md index d4e92bf..e87dbaf 100644 --- a/apps/frontend/TESTING.md +++ b/apps/frontend/TESTING.md @@ -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) diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 40e814f..d2f1079 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,21 +1,36 @@ import { Routes } from '@angular/router'; -import {authGuard} from './core/guards/auth-guard'; +import { authGuard } from './core/guards/auth-guard'; export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, - { path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) }, - { path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) }, - { path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) }, - { path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) }, + { + path: 'login', + loadComponent: () => import('./features/auth/login/login').then((m) => m.Login), + }, + { + path: 'change-password', + loadComponent: () => + import('./features/auth/change-password/change-password').then((m) => m.ChangePassword), + }, + { + path: 'forgot-password', + loadComponent: () => + import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword), + }, + { + path: 'reset-password', + loadComponent: () => + import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword), + }, { path: 'dashboard', canActivate: [authGuard], - loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard), + loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard), }, { path: 'sites', canActivate: [authGuard], - loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList), + loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList), }, { path: 'sites/:siteId', @@ -23,6 +38,12 @@ export const routes: Routes = [ loadComponent: () => import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), }, + { + path: 'recommendations', + canActivate: [authGuard], + loadComponent: () => + import('./features/recommendations/recommendations').then((m) => m.RecommendationsView), + }, { path: 'monitoring/sensors', canActivate: [authGuard], diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts index 4483313..58eaae6 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -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(); }); diff --git a/apps/frontend/src/app/core/mocks/alerts.fixture.ts b/apps/frontend/src/app/core/mocks/alerts.fixture.ts index c1f7a9a..84c8c66 100644 --- a/apps/frontend/src/app/core/mocks/alerts.fixture.ts +++ b/apps/frontend/src/app/core/mocks/alerts.fixture.ts @@ -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, }, ]; diff --git a/apps/frontend/src/app/core/services/alerts.service.spec.ts b/apps/frontend/src/app/core/services/alerts.service.spec.ts index 68b5740..c17bc4b 100644 --- a/apps/frontend/src/app/core/services/alerts.service.spec.ts +++ b/apps/frontend/src/app/core/services/alerts.service.spec.ts @@ -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([]); }); }); diff --git a/apps/frontend/src/app/core/services/alerts.service.ts b/apps/frontend/src/app/core/services/alerts.service.ts index ebd00e2..8d9a8bf 100644 --- a/apps/frontend/src/app/core/services/alerts.service.ts +++ b/apps/frontend/src/app/core/services/alerts.service.ts @@ -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(`${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(`${environment.apiUrl}/alerts`, { params }); } } diff --git a/apps/frontend/src/app/core/services/recommendations.service.spec.ts b/apps/frontend/src/app/core/services/recommendations.service.spec.ts new file mode 100644 index 0000000..4252757 --- /dev/null +++ b/apps/frontend/src/app/core/services/recommendations.service.spec.ts @@ -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 }); + }); +}); diff --git a/apps/frontend/src/app/core/services/recommendations.service.ts b/apps/frontend/src/app/core/services/recommendations.service.ts new file mode 100644 index 0000000..7a51844 --- /dev/null +++ b/apps/frontend/src/app/core/services/recommendations.service.ts @@ -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(`${environment.apiUrl}/recommendations`); + } + + getRecommendation(recommendationId: number) { + return this.http.get( + `${environment.apiUrl}/recommendations/${recommendationId}`, + ); + } + + generate(siteId?: string) { + let params = new HttpParams(); + if (siteId) { + params = params.set('site_id', siteId); + } + return this.http.post( + `${environment.apiUrl}/recommendations/generate`, + null, + { params }, + ); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index f9a3fb2..fd84e27 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -14,6 +14,7 @@ Supervision des capteurs } Voir les sites + Recommandations {{ message }} } - @if (alertsError(); as message) { - - } @if (predictionsError(); as message) { } @if (stats(); as s) { -
- - Consommation vs capacité +

+ + Actualisé à {{ s.timestamp | date: 'HH:mm:ss' }} · {{ s.total_sites }} sites suivis +

+ +
+ + Consommation vs capacité - {{ s.total_consumption_kw | number: '1.0-1' }} / - {{ s.total_capacity_kw | number }} kW + {{ s.total_consumption_kw | number: '1.0-1' }} + / {{ s.total_capacity_kw | number }} kW + + + + + Charge moyenne du parc + {{ s.average_load_percent | number: '1.0-0' }} % - - - - Charge moyenne du parc - {{ s.average_load_percent }} % -
-
+
+
+ {{ loadHint(s.average_load_percent) }} - - Sites suivis - {{ s.total_sites }} + + Sites suivis + {{ s.total_sites }} + Voir la liste des sites
- -
-

Charge et alerte visuelle par site

- -
} - @if (alerts().length > 0) { -
-

Alertes actives

-
    - @for (alert of alerts(); track alert.alert_id) { -
  • - {{ alert.severity }} - {{ alert.message }} -
  • +
    +
    + @if (stats(); as s) { +
    +

    Charge par site

    + + + +
    + } + +
    +

    Prévisions de consommation

    + @if (predictions().length > 0) { + + + + + + + + + + + + @for (site of predictions(); track site.site_id) { + + + @if (site.prediction; as prediction) { + @if (prediction.status === 'available') { + + + } @else { + + + } + } @else { + + + } + + + } + +
    SitePrévisionÉchéance
    {{ site.site_name }} + {{ prediction.predicted_value | number: '1.0-1' }} kWh + {{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} + {{ + prediction.status === 'insufficient_data' + ? 'Historique insuffisant' + : 'Erreur' + }} + -Pas encore de prévision-Détail
    +
    + } @else if (!predictionsError()) { +

    Aucune prévision disponible pour le moment.

    } -
-
- } +
+ - @if (predictions().length > 0) { -
-

Prévisions de consommation

-
    - @for (site of predictions(); track site.site_id) { -
  • - {{ site.site_name }} - @if (site.prediction; as prediction) { - @if (prediction.status === 'available') { - - {{ prediction.predicted_value | number: '1.0-1' }} kWh - {{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} - - } @else { - {{ - prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur' - }} - } - } @else { - Pas encore de prévision - } -
  • - } -
-
- } + + diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 9c89f56..c3f60bb 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -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,116 +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--link { - cursor: pointer; - transition: border-color 0.15s ease; +.kpi__label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} - &:hover { - border-color: var(--color-primary); +.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); } } -.card__label { - font-size: 0.8rem; +.kpi__hint { + font-size: var(--font-size-xs); color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.02em; } -.card__value { - font-size: 1.6rem; - font-weight: 700; +.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; + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 910b0f6..e8a696e 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -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; + predictions?: { getPredictions: ReturnType }; + auth?: Record; + } = {}, +) { + 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,109 +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(), - principal: vi.fn().mockReturnValue({ role: 'admin' }), - }; - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictionsMock() }, - { provide: AuthService, useValue: authMock }, - provideRouter([]), - ], - }); - - const fixture = TestBed.createComponent(Dashboard); - fixture.detectChanges(); - - const router = TestBed.inject(Router); - const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); - - const button = fixture.nativeElement.querySelector('.logout-button'); - button.click(); - - expect(authMock.logout).toHaveBeenCalled(); - expect(navigateSpy).toHaveBeenCalledWith(['/login']); - }); - it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { - const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; - const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; const authMock = { - logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), - clearSession: vi.fn(), - principal: vi.fn().mockReturnValue({ role: 'admin' }), - }; - TestBed.configureTestingModule({ - imports: [Dashboard], - providers: [ - { provide: StatsService, useValue: statsMock }, - { provide: AlertsService, useValue: alertsMock }, - { provide: PredictionsService, useValue: predictionsMock() }, - { provide: AuthService, useValue: authMock }, - provideRouter([]), - ], + 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); + + fixture.nativeElement.querySelector('.logout-button').click(); + + expect(authMock.logout).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); }); - const fixture = TestBed.createComponent(Dashboard); - fixture.detectChanges(); + it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { + const authMock = { + logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), + clearSession: vi.fn(), + principal: vi.fn().mockReturnValue({ role: 'admin' }), + }; + const { fixture } = setup({ auth: authMock }); + fixture.detectChanges(); + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); - const router = TestBed.inject(Router); - const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + fixture.nativeElement.querySelector('.logout-button').click(); - const button = fixture.nativeElement.querySelector('.logout-button'); - button.click(); + expect(authMock.clearSession).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); + }); - 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'); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index 2ba20c0..2d9e1d6 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -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 = { - 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 = { 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 = { RouterLink, ConsumptionGauge, SiteLoadChart, + AlertFeed, Card, EvAlert, Badge, @@ -58,32 +55,20 @@ const TON_PAR_STATUT_PREDICTION: Record = { }) export class Dashboard implements OnInit { private statsService = inject(StatsService); - private alertsService = inject(AlertsService); public auth = inject(AuthService); private predictionsService = inject(PredictionsService); private router = inject(Router); private destroyRef = inject(DestroyRef); stats = signal(null); - alerts = signal([]); predictions = signal([]); - // 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(null); - alertsError = signal(null); predictionsError = signal(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']), diff --git a/apps/frontend/src/app/features/recommendations/recommendations.html b/apps/frontend/src/app/features/recommendations/recommendations.html new file mode 100644 index 0000000..8a7747e --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.html @@ -0,0 +1,58 @@ +
+ + +
+ + +
+

Recommandations

+

+ Actions proposées par le moteur de règles à partir des alertes +

+
+
+ +
+ + @if (isAdmin()) { + + {{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }} + + } +
+ + @if (generationReport(); as report) { + {{ bilan(report) }}. + } + @if (generationError(); as message) { + {{ message }} + } + + @if (alertId(); as id) { +

+ Alerte n° {{ id }} · + Toutes les recommandations +

+ } + + +
diff --git a/apps/frontend/src/app/features/recommendations/recommendations.scss b/apps/frontend/src/app/features/recommendations/recommendations.scss new file mode 100644 index 0000000..ba95139 --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.scss @@ -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); +} diff --git a/apps/frontend/src/app/features/recommendations/recommendations.spec.ts b/apps/frontend/src/app/features/recommendations/recommendations.spec.ts new file mode 100644 index 0000000..dcb8e62 --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.spec.ts @@ -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; 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['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'); + }); +}); diff --git a/apps/frontend/src/app/features/recommendations/recommendations.ts b/apps/frontend/src/app/features/recommendations/recommendations.ts new file mode 100644 index 0000000..985c5af --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.ts @@ -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(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(null); + generationError = signal(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(', '); + } +} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html index 9c4a4bc..e10d1db 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.html +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -81,5 +81,16 @@ } } +
+

Recommandations

+ + Voir dans la vue recommandations +
+ Retour aux sites diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss index a0032be..5cfcad0 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -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; +} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts index 5e3cf46..0e7c35a 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -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, ) { 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( diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 21e716d..4fa7896 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -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(() => { + const site = this.site(); + return site ? [site] : []; + }); + consumptionKw = computed(() => this.current()?.consumption_kw ?? null); consumptionLabel = computed(() => { diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.html b/apps/frontend/src/app/features/sites/site-list/site-list.html index 9998066..9e8576c 100644 --- a/apps/frontend/src/app/features/sites/site-list/site-list.html +++ b/apps/frontend/src/app/features/sites/site-list/site-list.html @@ -17,8 +17,8 @@ } - - + +
@@ -36,7 +36,9 @@ - + } diff --git a/apps/frontend/src/app/features/sites/site-list/site-list.scss b/apps/frontend/src/app/features/sites/site-list/site-list.scss index 9fa25a2..77c1923 100644 --- a/apps/frontend/src/app/features/sites/site-list/site-list.scss +++ b/apps/frontend/src/app/features/sites/site-list/site-list.scss @@ -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; - } -} diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html new file mode 100644 index 0000000..12cb095 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html @@ -0,0 +1,95 @@ +
+
+
+

Alertes actives

+ @if (!loading() || alerts().length > 0) { +

+ {{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }} +

+ } +
+
+ @if (!siteId()) { + + } + +
+
+ + @if (error(); as message) { + {{ message }} + } + + @if (loading() && alerts().length === 0 && !error()) { +

Chargement des alertes…

+ } @else if (alerts().length === 0 && !error()) { + Aucune alerte pour ces critères. + } + +
    + @for (alert of visibleAlerts(); track alert.alert_id) { +
  • + + + +
    +
    + {{ + severityLabel(alert.severity) + }} + {{ typeLabel(alert.type) }} + {{ siteName(alert.site_id) }} + +
    +

    {{ alert.message }}

    + @if (alert.value !== null) { +

    + {{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }} + @if (alert.threshold !== null) { + seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }} + } +

    + } +
    +
  • + } +
+ + @if (hiddenCount() > 0) { + + Afficher plus ({{ hiddenCount() }} restantes) + + } +
diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss new file mode 100644 index 0000000..f5aa194 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss @@ -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); +} diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts new file mode 100644 index 0000000..e2068ec --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts @@ -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 { + 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 }, + sitesMock: { getSites: ReturnType } = { + 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) { + fixture.detectChanges(); + vi.advanceTimersByTime(1); + fixture.detectChanges(); +} + +function texte(fixture: ComponentFixture): string { + return (fixture.nativeElement as HTMLElement).textContent ?? ''; +} + +function choisir(fixture: ComponentFixture, 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'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts new file mode 100644 index 0000000..91f5d42 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts @@ -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(null); + + readonly severites = SEVERITES; + severity = signal(null); + siteFilter = signal(null); + + alerts = signal([]); + loading = signal(true); + error = signal(null); + visibleCount = signal(PAGE_SIZE); + + sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), { + initialValue: [] as Site[], + }); + + private filters = computed(() => ({ + 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 { + this.loading.set(false); + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html new file mode 100644 index 0000000..d844bdc --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html @@ -0,0 +1,48 @@ +@if (error(); as message) { + {{ message }} +} @else if (loading() && !hasData()) { +

Chargement des recommandations…

+} @else if (visibleGroups().length === 0) { + {{ emptyMessage() }} +} + +
+ @for (group of visibleGroups(); track group.alert.alert_id) { + +
+
+ {{ + severityLabel(group.alert.severity) + }} + {{ typeLabel(group.alert.type) }} + @if (!siteId()) { + {{ + group.siteName + }} + } + +
+

{{ group.alert.message }}

+
+
    + @for (reco of group.recommendations; track reco.recommendation_id) { +
  1. +
    + {{ reco.action }} + {{ + ruleLabel(reco.rule_reference) + }} +
    +

    {{ reco.explanation }}

    +
  2. + } +
+
+ } +
diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss new file mode 100644 index 0000000..9456376 --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss @@ -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); +} diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts new file mode 100644 index 0000000..1f3ae01 --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts @@ -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 { + 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 { + 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 }, + recosMock: { getRecommendations: ReturnType }, + inputs: Record = {}, +) { + 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) { + fixture.detectChanges(); + fixture.detectChanges(); +} + +function texte(fixture: ComponentFixture): 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); + }); +}); diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts new file mode 100644 index 0000000..05b3829 --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts @@ -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, +): RecommendedAlertView[] { + const parAlerte = new Map(); + 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(null); + alertId = input(null); + sites = input([]); + + private data = signal(null); + private reloadTick = signal(0); + loading = signal(true); + error = signal(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(() => { + 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 { + this.loading.set(false); + this.error.set(UNAVAILABLE_MESSAGE); + this.data.set(null); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.html b/apps/frontend/src/app/shared/components/ui/icon/icon.html new file mode 100644 index 0000000..869ccd1 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.html @@ -0,0 +1,37 @@ + + @switch (name()) { + @case ('spike') { + + + } + @case ('threshold') { + + + } + @case ('anomaly') { + + } + @case ('outage') { + + + } + @case ('sensor') { + + + + + + } + } + diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.scss b/apps/frontend/src/app/shared/components/ui/icon/icon.scss new file mode 100644 index 0000000..c90a9ce --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.scss @@ -0,0 +1,12 @@ +:host { + display: inline-flex; + flex-shrink: 0; + width: 1em; + height: 1em; + vertical-align: -0.125em; +} + +svg { + width: 100%; + height: 100%; +} diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.spec.ts b/apps/frontend/src/app/shared/components/ui/icon/icon.spec.ts new file mode 100644 index 0000000..ce3dedc --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.spec.ts @@ -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'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/ui/icon/icon.ts b/apps/frontend/src/app/shared/components/ui/icon/icon.ts new file mode 100644 index 0000000..dc302e9 --- /dev/null +++ b/apps/frontend/src/app/shared/components/ui/icon/icon.ts @@ -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(); + label = input(null); +} diff --git a/apps/frontend/src/app/shared/models/alert-presentation.spec.ts b/apps/frontend/src/app/shared/models/alert-presentation.spec.ts new file mode 100644 index 0000000..b31fdb8 --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert-presentation.spec.ts @@ -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'); + }); +}); diff --git a/apps/frontend/src/app/shared/models/alert-presentation.ts b/apps/frontend/src/app/shared/models/alert-presentation.ts new file mode 100644 index 0000000..cffa772 --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert-presentation.ts @@ -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 = { + low: 'neutral', + medium: 'warning', + high: 'danger', + critical: 'critical', +}; + +export const LIBELLE_PAR_SEVERITE: Record = { + low: 'Faible', + medium: 'Moyenne', + high: 'Élevée', + critical: 'Critique', +}; + +export const LIBELLE_PAR_TYPE: Record = { + spike: 'Pic de consommation', + threshold: 'Seuil dépassé', + anomaly: 'Anomalie', + outage: 'Coupure', + sensor: 'Capteur', +}; + +export const UNITE_PAR_METRIQUE: Record = { + 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', +]; diff --git a/apps/frontend/src/app/shared/models/alert.model.ts b/apps/frontend/src/app/shared/models/alert.model.ts index 028f35a..8a51c93 100644 --- a/apps/frontend/src/app/shared/models/alert.model.ts +++ b/apps/frontend/src/app/shared/models/alert.model.ts @@ -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; } diff --git a/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts b/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts new file mode 100644 index 0000000..63b8556 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts @@ -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'); + }); +}); diff --git a/apps/frontend/src/app/shared/models/recommendation-presentation.ts b/apps/frontend/src/app/shared/models/recommendation-presentation.ts new file mode 100644 index 0000000..f52d294 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation-presentation.ts @@ -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 = { + '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'; +} diff --git a/apps/frontend/src/app/shared/models/recommendation.model.ts b/apps/frontend/src/app/shared/models/recommendation.model.ts new file mode 100644 index 0000000..c017b72 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation.model.ts @@ -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; +} diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index 5599780..179d4ec 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -2,6 +2,7 @@ @use 'styles/forms'; @use 'styles/auth-page'; @use 'styles/links'; +@use 'styles/tables'; body { margin: 0; diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss index 9bbfb0c..7cde5d1 100644 --- a/apps/frontend/src/styles/_forms.scss +++ b/apps/frontend/src/styles/_forms.scss @@ -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; +} diff --git a/apps/frontend/src/styles/_tables.scss b/apps/frontend/src/styles/_tables.scss new file mode 100644 index 0000000..36b0026 --- /dev/null +++ b/apps/frontend/src/styles/_tables.scss @@ -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); +} diff --git a/apps/frontend/src/styles/_tokens.scss b/apps/frontend/src/styles/_tokens.scss index 2e7663b..dd44c0e 100644 --- a/apps/frontend/src/styles/_tokens.scss +++ b/apps/frontend/src/styles/_tokens.scss @@ -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; diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index d2fbb9c..888336f 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -3,6 +3,7 @@ { "compileOnSave": false, "compilerOptions": { + "strict": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, diff --git a/docker-compose.yml b/docker-compose.yml index da19f43..6f2c1c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ x-airflow-common: &airflow-common 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 diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 619f671..0539cd0 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -70,10 +70,11 @@ Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, intercepteur répond à sa place tant que les endpoints n'existent pas. Voir [30-frontend.md](30-frontend.md). -Le lien `airflow --> db` est maintenant en trait plein : trois DAGs tournent, deux pour +Le lien `airflow --> db` est maintenant en trait plein : quatre DAGs tournent, deux pour l'entraînement et le scoring du modèle ML (issue #115), un pour la détection d'alertes et la -génération des recommandations (issue #116), cf. plus bas et [20-backend.md](20-backend.md). Le -reste du périmètre Airflow envisagé (ingestion, issues #15/#16) reste en pointillé, non construit. +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. @@ -88,7 +89,7 @@ collecteur ne vient le lire. | 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` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Trois DAGs en sous-processus `uv run` : `ml_train` manuel et `ml_score` `@hourly` pour le pipeline ML (issue #115), `alertes` à `15 * * * *` pour la détection et les recommandations (issue #116, [ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). L'ingestion (issues #15/#16) n'a pas encore de DAG | +| ETL | Apache Airflow | `etl/airflow` | `En cours` | Webserver + scheduler (LocalExecutor) tournent via docker-compose, base de métadonnées Postgres dédiée. Quatre DAGs en sous-processus `uv run` : `ml_train`, `ml_score`, `alertes` et `historical_import`. Le DAG historique orchestre `app.etl.historical_import` et charge `dataset`, `site` et `reading`. L'orchestration API Mock reste à compléter dans #15 | | CI/CD | GitHub Actions | `.github/workflows` | `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 diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index 545af7a..1da2a9e 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -52,7 +52,7 @@ Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devin - `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver : c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow). -### Airflow (issues #115 et #116) +### Airflow (issues #115, #116 et #119) Trois services, `docker compose profiles` non utilisés (démarrage explicite via `make airflow-up`, pas dans `make dev`) : @@ -76,6 +76,12 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md). | `ml_train` | manuelle | `enervision_ml.train`, dans `/opt/ml/.venv` | | `ml_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 diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index 14a7012..4969b0b 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -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,11 +95,11 @@ 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 @@ -146,6 +154,29 @@ Compose. Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md). +## Recommandations + +Statut : `Fait`. La vue `/recommendations` (`features/recommendations`, derrière `authGuard`, tous +rôles) présente les recommandations du moteur de règles groupées par alerte, du plus récent au plus +ancien, avec le contexte de l'alerte (sévérité, type, site, horodatage, message) puis chaque action, +son explication et la règle qui l'a produite. + +- **Jointure côté client.** Une recommandation ne porte que `alert_id`, jamais `site_id`, et + `GET /recommendations` n'a aucun filtre. `app-recommendation-list` (`shared/components/`) charge + donc en parallèle `GET /alerts` (filtré par `site_id` quand un site est fixé) et + `GET /recommendations`, puis les joint par `alert_id` (`joinByAlert`, fonction pure testée à + part). Les recommandations dont l'alerte n'est pas dans le jeu chargé sont ignorées : c'est ainsi + que le filtre site s'applique. `/alerts` n'étant pas paginé, un seul appel suffit. +- **Paramètres d'URL.** `?site=` présélectionne le filtre site ; `?alert=` + 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 diff --git a/docs/architecture/32-design-systeme-frontend.md b/docs/architecture/32-design-systeme-frontend.md index a7894d8..790c60f 100644 --- a/docs/architecture/32-design-systeme-frontend.md +++ b/docs/architecture/32-design-systeme-frontend.md @@ -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 `
Nom{{ site.site_type }} {{ site.location || '-' }} {{ site.capacity_kw ?? '-' }}{{ site.status ?? '-' }} + {{ site.status ?? '-' }} + Détail
`, `.ev-table__number` pour une cellule numérique en chiffres +tabulaires, `.ev-table__muted` pour une cellule sans valeur. ## Logo diff --git a/etl/README.md b/etl/README.md index 698ffca..d789d6b 100644 --- a/etl/README.md +++ b/etl/README.md @@ -663,8 +663,16 @@ mock_api_import.py La logique d'extraction, de transformation et de chargement est donc disponible pour les deux sources de données du MVP. -Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline ML (`ml_train`/`ml_score`, issue #115) ainsi que la détection d'alertes et la génération des recommandations (`alertes`, issue #116). Il n'orchestre pas encore ces deux imports : `historical_import.py` et `mock_api_import.py` (normalisation et chargement micro-batch, issues #15/#16) restent à faire. +Airflow tourne désormais réellement (`etl/airflow/`, `make airflow-up`) et orchestre le pipeline +ML (`ml_train`/`ml_score`, issue #115), la détection d'alertes et la génération des +recommandations (`alertes`, issue #116), ainsi que l'import historique +(`historical_import`, issue #119). -Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui). +Le DAG `historical_import` est déclenché manuellement. Il exécute +`app.etl.historical_import` avec les fichiers montés en lecture seule depuis `data/raw` vers +`/opt/data/raw`. L'orchestration de l'import API Mock et la réconciliation globale des deux +sources restent couvertes par l'issue #15. + +Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py` et `alertes.py` et `historical_import.py` montrent le patron retenu (des `BashOperator` qui invoquent le script tel quel, dans l'environnement `uv` que l'image embarque pour lui). Le pipeline Data servira ensuite à préparer les données nécessaires au modèle de Machine Learning. diff --git a/etl/airflow/dags/historical_import.py b/etl/airflow/dags/historical_import.py new file mode 100644 index 0000000..a668578 --- /dev/null +++ b/etl/airflow/dags/historical_import.py @@ -0,0 +1,45 @@ +"""DAG d'import du dataset historique EnerVision (issue #119). + +Orchestre le pipeline existant `app.etl.historical_import` sans dupliquer sa logique ETL. +Le dataset historique sert à initialiser l'environnement : le DAG reste donc manuel. + +Le backend est exécuté dans l'environnement `/opt/backend` embarqué dans l'image Airflow, +sur le même patron que le DAG `alertes` (ADR 0008). +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from airflow.models.dag import DAG +from airflow.operators.bash import BashOperator + +COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m" + +CSV_PATH = "/opt/data/raw/all_sites_combined.csv" +METADATA_PATH = "/opt/data/raw/dataset_metadata.json" +SOURCE_TIMEZONE = "UTC" +BATCH_SIZE = 1000 + +with DAG( + dag_id="historical_import", + description="Importe le dataset historique CSV/JSON dans dataset, site et reading.", + schedule=None, + start_date=datetime(2026, 1, 1), + catchup=False, + max_active_runs=1, + tags=["etl", "historical"], +) as dag: + BashOperator( + task_id="import_historical", + bash_command=( + f"{COMMANDE_BACKEND} app.etl.historical_import " + f"--csv {CSV_PATH} " + f"--metadata {METADATA_PATH} " + "--source-timezone UTC " + "--batch-size 1000" + ), + retries=1, + retry_delay=timedelta(minutes=2), + execution_timeout=timedelta(minutes=30), + ) diff --git a/etl/airflow/tests/test_dags.py b/etl/airflow/tests/test_dags.py index 555849d..96bf030 100644 --- a/etl/airflow/tests/test_dags.py +++ b/etl/airflow/tests/test_dags.py @@ -10,12 +10,13 @@ from airflow.models.dagbag import DagBag DAGS_FOLDER = Path(__file__).resolve().parent.parent / "dags" -DAG_IDS = ["ml_train", "ml_score", "alertes"] +DAG_IDS = ["ml_train", "ml_score", "alertes", "historical_import"] TACHES = [ ("ml_train", "train"), ("ml_score", "score"), ("alertes", "detection"), ("alertes", "recommandations"), + ("historical_import", "import_historical"), ] @@ -47,6 +48,10 @@ def test_alertes_runs_after_the_hourly_scoring(dagbag: DagBag) -> None: assert dagbag.dags["alertes"].timetable.summary == "15 * * * *" +def test_historical_import_has_no_schedule(dagbag: DagBag) -> None: + assert dagbag.dags["historical_import"].timetable.summary == "None" + + def test_ml_train_task_calls_the_training_module(dagbag: DagBag) -> None: tache = dagbag.dags["ml_train"].get_task("train") assert "enervision_ml.train" in tache.bash_command @@ -67,12 +72,29 @@ def test_alertes_recommendation_task_calls_the_backend_cli(dagbag: DagBag) -> No assert "app.cli generate-recommendations" in tache.bash_command +def test_historical_import_calls_the_existing_backend_module(dagbag: DagBag) -> None: + tache = dagbag.dags["historical_import"].get_task("import_historical") + assert "app.etl.historical_import" in tache.bash_command + + +def test_historical_import_uses_the_expected_source_files(dagbag: DagBag) -> None: + commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command + + assert "--csv /opt/data/raw/all_sites_combined.csv" in commande + assert "--metadata /opt/data/raw/dataset_metadata.json" in commande + + @pytest.mark.parametrize("task_id", ["detection", "recommandations"]) def test_alertes_tasks_run_in_the_backend_environment(dagbag: DagBag, task_id: str) -> None: # Le backend a son propre venv dans l'image, distinct de celui de ml/ (ADR 0008). assert "/opt/backend" in dagbag.dags["alertes"].get_task(task_id).bash_command +def test_historical_import_runs_in_the_backend_environment(dagbag: DagBag) -> None: + commande = dagbag.dags["historical_import"].get_task("import_historical").bash_command + assert "/opt/backend" in commande + + def test_alertes_generates_recommendations_after_detecting(dagbag: DagBag) -> None: # `recommendation.alert_id` est une cle etrangere `NOT NULL` : la generation n'a rien a lire # tant que la detection n'a pas ecrit. @@ -133,6 +155,10 @@ def test_alertes_retries_after_a_transient_failure(dagbag: DagBag, task_id: str) assert dagbag.dags["alertes"].get_task(task_id).retries >= 1 +def test_historical_import_retries_after_a_transient_failure(dagbag: DagBag) -> None: + assert dagbag.dags["historical_import"].get_task("import_historical").retries >= 1 + + @pytest.mark.parametrize(("dag_id", "task_id"), TACHES) def test_tasks_never_resync_the_baked_environment( dagbag: DagBag, dag_id: str, task_id: str