Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e504bee72 | ||
|
|
32155cbfc5 | ||
|
|
d16de84559 | ||
|
|
90639b5618 | ||
|
|
e147ea69bc | ||
|
|
83caa9006e | ||
|
|
91752a2b0b |
@@ -47,10 +47,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: etl/airflow/uv.lock
|
cache-dependency-glob: etl/airflow/uv.lock
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
|
- name: Construit l'image (contexte à la racine, elle COPY ml/ et apps/backend/)
|
||||||
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
|
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -90,10 +90,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -126,10 +126,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -150,12 +150,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||||
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
|
# Pourquoi : le périmètre est `app`, le code livré. Les tests emploient légitimement des
|
||||||
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
|
# secrets factices et des `assert` que Bandit signalerait sans qu'aucun n'atteigne la prod.
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 26
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
|
|
||||||
@@ -33,10 +33,10 @@ jobs:
|
|||||||
name: Audit des dépendances
|
name: Audit des dépendances
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 26
|
node-version: 24
|
||||||
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
|
# Seuil high : une vulnérabilité moderate de devDependency ne doit pas bloquer une livraison.
|
||||||
- run: npm audit --audit-level=high --package-lock-only
|
- run: npm audit --audit-level=high --package-lock-only
|
||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
@@ -45,10 +45,10 @@ jobs:
|
|||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 26
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
- name : Installation des dépendances (Front)
|
- name : Installation des dépendances (Front)
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
path: apps/frontend/coverage/frontend/lcov.info
|
path: apps/frontend/coverage/frontend/lcov.info
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: ml/uv.lock
|
cache-dependency-glob: ml/uv.lock
|
||||||
@@ -67,12 +67,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Récupère le dépôt
|
- name: Récupère le dépôt
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
# Pourquoi : pas de cache ici. uvx n'installe pas le projet, le verrou n'alimente donc
|
||||||
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
# aucune clé de cache ; la seule roue téléchargée est celle de Bandit.
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
- name: Analyse le code livré (bloquant à partir de MEDIUM)
|
||||||
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
|
run: uvx bandit==1.9.4 --recursive enervision_ml --severity-level medium --confidence-level medium
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ jobs:
|
|||||||
build-front:
|
build-front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 26
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
|
|
||||||
@@ -40,10 +40,10 @@ jobs:
|
|||||||
needs: build-front
|
needs: build-front
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 26
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
path: apps/frontend/coverage/frontend/lcov.info
|
path: apps/frontend/coverage/frontend/lcov.info
|
||||||
@@ -64,9 +64,9 @@ jobs:
|
|||||||
build-back:
|
build-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -95,9 +95,9 @@ jobs:
|
|||||||
needs: build-back
|
needs: build-back
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
working-directory: apps/backend
|
working-directory: apps/backend
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
path: apps/backend/coverage.xml
|
path: apps/backend/coverage.xml
|
||||||
@@ -115,9 +115,9 @@ jobs:
|
|||||||
test-ml:
|
test-ml:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Installe uv
|
- name: Installe uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
cache-dependency-glob: ml/uv.lock
|
cache-dependency-glob: ml/uv.lock
|
||||||
@@ -135,7 +135,7 @@ jobs:
|
|||||||
working-directory: ml
|
working-directory: ml
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ml-coverage
|
name: ml-coverage
|
||||||
path: ml/coverage.xml
|
path: ml/coverage.xml
|
||||||
@@ -143,26 +143,23 @@ jobs:
|
|||||||
sonarqube:
|
sonarqube:
|
||||||
needs: [build-front, build-back, test-front, test-back, test-ml]
|
needs: [build-front, build-back, test-front, test-back, test-ml]
|
||||||
name: SonarQube
|
name: SonarQube
|
||||||
# Pourquoi : GitHub ne fournit pas les secrets aux workflows lancés par dependabot[bot].
|
|
||||||
# Sans SONAR_TOKEN le scan échoue sans rien analyser ; build et tests restent joués.
|
|
||||||
if: github.actor != 'dependabot[bot]'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Téléchargement du rapport de couverture (Front)
|
- name: Téléchargement du rapport de couverture (Front)
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
path: apps/frontend/coverage/frontend
|
path: apps/frontend/coverage/frontend
|
||||||
- name: Téléchargement du rapport de couverture (Back)
|
- name: Téléchargement du rapport de couverture (Back)
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
path: apps/backend
|
path: apps/backend
|
||||||
- name: Téléchargement du rapport de couverture (ML)
|
- name: Téléchargement du rapport de couverture (ML)
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ml-coverage
|
name: ml-coverage
|
||||||
path: ml
|
path: ml
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ series temporelles energetiques, deployee sur une machine on-premise.
|
|||||||
|-------|----------------------------------------------------------|
|
|-------|----------------------------------------------------------|
|
||||||
| J1 | Valider la préparation de l'environnement et du repo |
|
| J1 | Valider la préparation de l'environnement et du repo |
|
||||||
| J2 | Valider le périmètre retenu et les choix technologiques |
|
| J2 | Valider le périmètre retenu et les choix technologiques |
|
||||||
| J3 | Ingestion & backend |
|
| J3 | Valider l'architecture et la gestion de la sécurité |
|
||||||
| J4 | Architecture, sécurité & frontend |
|
| J4 | Valider la robustesse et assurer les livrables |
|
||||||
| J5 | Valider la robustesse et assurer les livrables |
|
|
||||||
| J6 | Amélioration possible |
|
|
||||||
|
|
||||||
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
|
Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.md](docs/architecture/00-vue-ensemble.md).
|
||||||
|
|
||||||
@@ -20,15 +18,15 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
|||||||
|
|
||||||
| Domaine | Technologie | Emplacement | Etat |
|
| Domaine | Technologie | Emplacement | Etat |
|
||||||
|------------|-------------------------------------|---------------------|---------------|
|
|------------|-------------------------------------|---------------------|---------------|
|
||||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | En place |
|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
||||||
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | En place |
|
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
|
||||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | En place |
|
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||||
| ETL | Apache Airflow | `etl/airflow` | Trois DAGs |
|
| ETL | Apache Airflow | `etl/airflow` | Trois DAGs |
|
||||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||||
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
| Reverse proxy | Nginx, TLS | `infra/proxy` | En place |
|
||||||
| CI/CD | GitHub Actions | `.github/workflows` | En place |
|
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
||||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||||
| ML | LightGBM, MLflow | `ml` | En place |
|
| ML | LightGBM, MLflow | `ml` | Entrainement initialise |
|
||||||
|
|
||||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
||||||
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
|
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# ==================
|
# ==================
|
||||||
|
|
||||||
# Image pour frontend
|
# Image pour frontend
|
||||||
FROM node:26-alpine3.22 AS builder
|
FROM node:24-alpine3.22 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Conventions de tests unitaires — Frontend
|
# Conventions de tests unitaires : Frontend
|
||||||
|
|
||||||
## Outil
|
## Outil
|
||||||
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
|
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
|
||||||
@@ -83,3 +83,6 @@ describe('MonComposant', () => {
|
|||||||
## Lancer les tests
|
## Lancer les tests
|
||||||
- Développement (mode watch) : `npm test`
|
- Développement (mode watch) : `npm test`
|
||||||
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
|
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
|
||||||
|
- Un fichier ou un dossier seulement :
|
||||||
|
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
|
||||||
|
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
|
||||||
|
|||||||
@@ -1,21 +1,36 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import {authGuard} from './core/guards/auth-guard';
|
import { authGuard } from './core/guards/auth-guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
|
{
|
||||||
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
|
path: 'login',
|
||||||
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
|
loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
|
||||||
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
|
},
|
||||||
|
{
|
||||||
|
path: 'change-password',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/change-password/change-password').then((m) => m.ChangePassword),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgot-password',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reset-password',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
|
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'sites',
|
path: 'sites',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
|
loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'sites/:siteId',
|
path: 'sites/:siteId',
|
||||||
@@ -23,6 +38,12 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'recommendations',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/recommendations/recommendations').then((m) => m.RecommendationsView),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'monitoring/sensors',
|
path: 'monitoring/sensors',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
|
|||||||
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
|
|||||||
|
|
||||||
export const ALERTS_FIXTURE: Alert[] = [
|
export const ALERTS_FIXTURE: Alert[] = [
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE002-1718458320',
|
alert_id: 5,
|
||||||
timestamp: '2026-09-15T11:12:00',
|
|
||||||
site_id: 'SITE002',
|
site_id: 'SITE002',
|
||||||
|
timestamp: '2026-09-15T11:12:00Z',
|
||||||
|
type: 'threshold',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
type: 'outage',
|
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
|
||||||
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
|
|
||||||
value: 812.5,
|
value: 812.5,
|
||||||
threshold: 720.0,
|
threshold: 720.0,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE003-1718458321',
|
alert_id: 4,
|
||||||
timestamp: '2026-09-15T11:05:00',
|
|
||||||
site_id: 'SITE003',
|
site_id: 'SITE003',
|
||||||
|
timestamp: '2026-09-15T11:05:00Z',
|
||||||
|
type: 'outage',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
type: 'sensor',
|
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
|
||||||
message: 'Perte réseau totale sur Data Center Marseille',
|
value: null,
|
||||||
value: 0,
|
threshold: null,
|
||||||
threshold: 0,
|
metric: null,
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE005-1718458322',
|
alert_id: 3,
|
||||||
timestamp: '2026-09-15T10:47:00',
|
|
||||||
site_id: 'SITE005',
|
site_id: 'SITE005',
|
||||||
|
timestamp: '2026-09-15T10:47:00Z',
|
||||||
|
type: 'spike',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
type: 'threshold',
|
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
|
||||||
message: 'Usine Toulouse approche de son seuil de capacité',
|
|
||||||
value: 410.0,
|
value: 410.0,
|
||||||
threshold: 480.0,
|
threshold: 260.0,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE006-1718458323',
|
alert_id: 2,
|
||||||
timestamp: '2026-09-15T10:30:00',
|
|
||||||
site_id: 'SITE006',
|
site_id: 'SITE006',
|
||||||
severity: 'medium',
|
timestamp: '2026-09-15T10:30:00Z',
|
||||||
type: 'sensor',
|
type: 'sensor',
|
||||||
message: 'Capteur de température défaillant sur Bureau Lille',
|
severity: 'medium',
|
||||||
value: 0,
|
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
|
||||||
threshold: 0,
|
value: null,
|
||||||
|
threshold: null,
|
||||||
|
metric: null,
|
||||||
|
prediction_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alert_id: 'ALR-SITE004-1718458324',
|
alert_id: 1,
|
||||||
timestamp: '2026-09-15T09:58:00',
|
|
||||||
site_id: 'SITE004',
|
site_id: 'SITE004',
|
||||||
severity: 'low',
|
timestamp: '2026-09-15T09:58:00Z',
|
||||||
type: 'anomaly',
|
type: 'anomaly',
|
||||||
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
|
severity: 'low',
|
||||||
|
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
|
||||||
value: 62.0,
|
value: 62.0,
|
||||||
threshold: 55.0,
|
threshold: 55.0,
|
||||||
|
metric: 'consumption_kwh',
|
||||||
|
prediction_id: 42,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http';
|
|||||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
import { AlertsService } from './alerts.service';
|
import { AlertsService } from './alerts.service';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Alert } from '../../shared/models/alert.model';
|
||||||
|
|
||||||
|
const ALERT_API: Alert = {
|
||||||
|
alert_id: 1,
|
||||||
|
site_id: 'site-1',
|
||||||
|
timestamp: '2026-09-16T00:00:00Z',
|
||||||
|
type: 'threshold',
|
||||||
|
severity: 'high',
|
||||||
|
message: 'Dépassement du seuil configuré',
|
||||||
|
value: 812.5,
|
||||||
|
threshold: 720.0,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
describe('AlertsService', () => {
|
describe('AlertsService', () => {
|
||||||
let service: AlertsService;
|
let service: AlertsService;
|
||||||
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
|
|||||||
|
|
||||||
afterEach(() => httpMock.verify());
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
|
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
|
||||||
let result: unknown;
|
let result: Alert[] = [];
|
||||||
service.getAlerts().subscribe((r) => (result = r));
|
service.getAlerts().subscribe((r) => (result = r));
|
||||||
|
|
||||||
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
|
const req = httpMock.expectOne(
|
||||||
expect(req.request.method).toBe('GET');
|
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
|
||||||
|
);
|
||||||
|
expect(req.request.params.keys()).toEqual([]);
|
||||||
|
req.flush([ALERT_API]);
|
||||||
|
|
||||||
req.flush([
|
expect(result.length).toBe(1);
|
||||||
{
|
expect(result[0].alert_id).toBe(1);
|
||||||
alert_id: 'ALR-TEST-1',
|
expect(result[0].prediction_id).toBeNull();
|
||||||
timestamp: '2026-09-15T12:00:00',
|
});
|
||||||
site_id: 'SITE001',
|
|
||||||
severity: 'high',
|
|
||||||
type: 'threshold',
|
|
||||||
message: 'Test',
|
|
||||||
value: 100,
|
|
||||||
threshold: 90,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect((result as unknown[]).length).toBe(1);
|
it('transmet les filtres site_id et severity en paramètres de requête', () => {
|
||||||
|
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||||
|
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||||
|
expect(req.request.params.get('severity')).toBe('high');
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne pose pas de paramètre pour un filtre omis', () => {
|
||||||
|
service.getAlerts({ site_id: 'SITE001' }).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
|
||||||
|
expect(req.request.params.has('severity')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { Service, inject } from '@angular/core';
|
import { Service, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { Alert } from '../../shared/models/alert.model';
|
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
||||||
|
|
||||||
|
export interface AlertFilters {
|
||||||
|
site_id?: string;
|
||||||
|
severity?: AlertSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class AlertsService {
|
export class AlertsService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
getAlerts() {
|
getAlerts(filters: AlertFilters = {}) {
|
||||||
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
|
let params = new HttpParams();
|
||||||
|
if (filters.site_id) {
|
||||||
|
params = params.set('site_id', filters.site_id);
|
||||||
|
}
|
||||||
|
if (filters.severity) {
|
||||||
|
params = params.set('severity', filters.severity);
|
||||||
|
}
|
||||||
|
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { RecommendationsService } from './recommendations.service';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Recommendation } from '../../shared/models/recommendation.model';
|
||||||
|
|
||||||
|
const RECOMMANDATION_API: Recommendation = {
|
||||||
|
recommendation_id: 1,
|
||||||
|
alert_id: 1,
|
||||||
|
action: 'Vérifier la consommation',
|
||||||
|
explanation: 'Pic détecté',
|
||||||
|
rule_reference: 'spike-v1',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RecommendationsService', () => {
|
||||||
|
let service: RecommendationsService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(RecommendationsService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('liste les recommandations depuis le bon endpoint', () => {
|
||||||
|
let result: Recommendation[] = [];
|
||||||
|
service.getRecommendations().subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush([RECOMMANDATION_API]);
|
||||||
|
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0].alert_id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('décrit une recommandation par son identifiant', () => {
|
||||||
|
service.getRecommendation(42).subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations/42`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush({ ...RECOMMANDATION_API, recommendation_id: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('déclenche la génération en POST avec le site en paramètre de requête', () => {
|
||||||
|
let result: unknown;
|
||||||
|
service.generate('SITE001').subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(
|
||||||
|
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
|
||||||
|
);
|
||||||
|
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||||
|
expect(req.request.body).toBeNull();
|
||||||
|
req.flush({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
|
||||||
|
|
||||||
|
expect(result).toEqual({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('génère pour tout le parc quand aucun site n’est donné', () => {
|
||||||
|
service.generate().subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(
|
||||||
|
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
|
||||||
|
);
|
||||||
|
expect(req.request.params.has('site_id')).toBe(false);
|
||||||
|
req.flush({ alerts_examined: 0, recommendations_created: 0, already_present: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
Recommendation,
|
||||||
|
RecommendationGenerationReport,
|
||||||
|
} from '../../shared/models/recommendation.model';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class RecommendationsService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
getRecommendations() {
|
||||||
|
return this.http.get<Recommendation[]>(`${environment.apiUrl}/recommendations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecommendation(recommendationId: number) {
|
||||||
|
return this.http.get<Recommendation>(
|
||||||
|
`${environment.apiUrl}/recommendations/${recommendationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(siteId?: string) {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (siteId) {
|
||||||
|
params = params.set('site_id', siteId);
|
||||||
|
}
|
||||||
|
return this.http.post<RecommendationGenerationReport>(
|
||||||
|
`${environment.apiUrl}/recommendations/generate`,
|
||||||
|
null,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
|
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
|
||||||
}
|
}
|
||||||
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
||||||
|
<a routerLink="/recommendations" class="ev-link">Recommandations</a>
|
||||||
<ev-button
|
<ev-button
|
||||||
class="logout-button"
|
class="logout-button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<div class="recommendations">
|
||||||
|
<nav class="ev-breadcrumb">
|
||||||
|
<a routerLink="/dashboard">Tableau de bord</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="recommendations__header">
|
||||||
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="recommendations__logo" />
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1>Recommandations</h1>
|
||||||
|
<p class="recommendations__subtitle">
|
||||||
|
Actions proposées par le moteur de règles à partir des alertes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="recommendations__toolbar">
|
||||||
|
<label class="recommendations__filter">
|
||||||
|
<span class="form-label">Site</span>
|
||||||
|
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
|
||||||
|
<option value="" [selected]="!siteFilter()">Tous les sites</option>
|
||||||
|
@for (site of sites(); track site.site_id) {
|
||||||
|
<option [value]="site.site_id" [selected]="site.site_id === siteFilter()">
|
||||||
|
{{ site.site_name }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
@if (isAdmin()) {
|
||||||
|
<ev-button
|
||||||
|
variant="secondary"
|
||||||
|
[fullWidth]="false"
|
||||||
|
[disabled]="generating()"
|
||||||
|
data-testid="generate"
|
||||||
|
(click)="onGenerate()"
|
||||||
|
>
|
||||||
|
{{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }}
|
||||||
|
</ev-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (generationReport(); as report) {
|
||||||
|
<ev-alert severity="success" class="recommendations__banner">{{ bilan(report) }}.</ev-alert>
|
||||||
|
}
|
||||||
|
@if (generationError(); as message) {
|
||||||
|
<ev-alert severity="danger" class="recommendations__banner">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (alertId(); as id) {
|
||||||
|
<p class="recommendations__focus">
|
||||||
|
Alerte n° {{ id }} ·
|
||||||
|
<a routerLink="/recommendations" class="ev-link">Toutes les recommandations</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-recommendation-list [siteId]="siteFilter()" [alertId]="alertId()" [sites]="sites()" />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__logo {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 14rem;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations__focus {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||||
|
import { RecommendationsView, parseAlertId } from './recommendations';
|
||||||
|
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
|
||||||
|
import { SitesService } from '../../core/services/sites.service';
|
||||||
|
import { AlertsService } from '../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../core/services/recommendations.service';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
|
||||||
|
const SITES = [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BILAN = { alerts_examined: 2, recommendations_created: 3, already_present: 1 };
|
||||||
|
|
||||||
|
function setup(options: { query?: Record<string, string>; role?: string } = {}) {
|
||||||
|
const query = options.query ?? {};
|
||||||
|
const queryParamMap = new BehaviorSubject(convertToParamMap(query));
|
||||||
|
const generate = vi.fn().mockReturnValue(of(BILAN));
|
||||||
|
const getRecommendations = vi.fn().mockReturnValue(of([]));
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [RecommendationsView],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: { queryParamMap, snapshot: { queryParamMap: convertToParamMap(query) } },
|
||||||
|
},
|
||||||
|
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of(SITES)) } },
|
||||||
|
{ provide: AlertsService, useValue: { getAlerts } },
|
||||||
|
{ provide: RecommendationsService, useValue: { getRecommendations, generate } },
|
||||||
|
{
|
||||||
|
provide: AuthService,
|
||||||
|
useValue: { principal: vi.fn().mockReturnValue({ role: options.role ?? 'lecteur' }) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(RecommendationsView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
return { fixture, queryParamMap, generate, getRecommendations, getAlerts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listeEnfant(fixture: ReturnType<typeof setup>['fixture']): RecommendationList {
|
||||||
|
return fixture.debugElement.query(By.directive(RecommendationList)).componentInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseAlertId', () => {
|
||||||
|
it("n'accepte qu'un entier strictement positif", () => {
|
||||||
|
expect(parseAlertId('12')).toBe(12);
|
||||||
|
expect(parseAlertId('0')).toBeNull();
|
||||||
|
expect(parseAlertId('-3')).toBeNull();
|
||||||
|
expect(parseAlertId('abc')).toBeNull();
|
||||||
|
expect(parseAlertId('12abc')).toBeNull();
|
||||||
|
expect(parseAlertId(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecommendationsView', () => {
|
||||||
|
it("cible l'alerte donnée par ?alert= et la transmet à la liste", () => {
|
||||||
|
const { fixture } = setup({ query: { alert: '12' } });
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.alertId()).toBe(12);
|
||||||
|
expect(listeEnfant(fixture).alertId()).toBe(12);
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Alerte n° 12');
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href="/recommendations"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore un paramètre alert invalide', () => {
|
||||||
|
const { fixture } = setup({ query: { alert: 'abc' } });
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.alertId()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Alerte n°');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applique le site donné par ?site= au filtre et à la liste', () => {
|
||||||
|
const { fixture, getAlerts } = setup({ query: { site: 'SITE001' } });
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||||
|
const option = fixture.nativeElement.querySelector(
|
||||||
|
'option[value="SITE001"]',
|
||||||
|
) as HTMLOptionElement;
|
||||||
|
expect(option.selected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relance la liste sur le site choisi dans le filtre', () => {
|
||||||
|
const { fixture, getAlerts } = setup();
|
||||||
|
const select = fixture.nativeElement.querySelector(
|
||||||
|
'[data-testid="site-filter"]',
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
|
||||||
|
select.value = 'SITE001';
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenLastCalledWith({ site_id: 'SITE001' });
|
||||||
|
expect(listeEnfant(fixture).siteId()).toBe('SITE001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cache le bouton de génération aux lecteurs', () => {
|
||||||
|
const { fixture } = setup({ role: 'lecteur' });
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('[data-testid="generate"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permet à un admin de générer pour le site filtré, affiche le bilan et recharge la liste', () => {
|
||||||
|
const { fixture, generate, getRecommendations } = setup({
|
||||||
|
role: 'admin',
|
||||||
|
query: { site: 'SITE001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('[data-testid="generate"]').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(generate).toHaveBeenCalledWith('SITE001');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'3 recommandations créées, 1 déjà présente, 2 alertes examinées.',
|
||||||
|
);
|
||||||
|
expect(getRecommendations).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.componentInstance.generating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('génère pour tout le parc quand aucun site n’est filtré', () => {
|
||||||
|
const { fixture, generate } = setup({ role: 'admin' });
|
||||||
|
|
||||||
|
fixture.componentInstance.onGenerate();
|
||||||
|
|
||||||
|
expect(generate).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'échec de la génération sans casser la page", () => {
|
||||||
|
const { fixture, generate } = setup({ role: 'admin' });
|
||||||
|
generate.mockReturnValue(throwError(() => new Error('403')));
|
||||||
|
|
||||||
|
fixture.componentInstance.onGenerate();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.generationError()).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'La génération des recommandations a échoué',
|
||||||
|
);
|
||||||
|
expect(fixture.componentInstance.generating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accorde le bilan au singulier', () => {
|
||||||
|
const { fixture } = setup();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.componentInstance.bilan({
|
||||||
|
alerts_examined: 1,
|
||||||
|
recommendations_created: 1,
|
||||||
|
already_present: 0,
|
||||||
|
}),
|
||||||
|
).toBe('1 recommandation créée, 0 déjà présente, 1 alerte examinée');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Component, computed, inject, signal, viewChild } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { catchError, map, of } from 'rxjs';
|
||||||
|
import { SitesService } from '../../core/services/sites.service';
|
||||||
|
import { RecommendationsService } from '../../core/services/recommendations.service';
|
||||||
|
import { AuthService } from '../../core/services/auth.service';
|
||||||
|
import { Site } from '../../shared/models/site.model';
|
||||||
|
import { RecommendationGenerationReport } from '../../shared/models/recommendation.model';
|
||||||
|
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
|
||||||
|
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||||
|
import { Brand } from '../../shared/components/ui/brand/brand';
|
||||||
|
import { Button } from '../../shared/components/ui/button/button';
|
||||||
|
|
||||||
|
const GENERATION_FAILED_MESSAGE =
|
||||||
|
'La génération des recommandations a échoué, réessayez plus tard.';
|
||||||
|
|
||||||
|
export function parseAlertId(raw: string | null): number | null {
|
||||||
|
return raw !== null && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluriel(nombre: number, singulier: string, plurielForme: string): string {
|
||||||
|
return `${nombre} ${nombre > 1 ? plurielForme : singulier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-recommendations',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, RecommendationList, EvAlert, Brand, Button],
|
||||||
|
templateUrl: './recommendations.html',
|
||||||
|
styleUrl: './recommendations.scss',
|
||||||
|
})
|
||||||
|
export class RecommendationsView {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private sitesService = inject(SitesService);
|
||||||
|
private recommendationsService = inject(RecommendationsService);
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
|
||||||
|
alertId = toSignal(
|
||||||
|
this.route.queryParamMap.pipe(map((params) => parseAlertId(params.get('alert')))),
|
||||||
|
{ initialValue: null },
|
||||||
|
);
|
||||||
|
siteFilter = signal<string | null>(this.route.snapshot.queryParamMap.get('site'));
|
||||||
|
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
|
||||||
|
initialValue: [] as Site[],
|
||||||
|
});
|
||||||
|
|
||||||
|
list = viewChild.required(RecommendationList);
|
||||||
|
|
||||||
|
isAdmin = computed(() => this.auth.principal()?.role === 'admin');
|
||||||
|
generating = signal(false);
|
||||||
|
generationReport = signal<RecommendationGenerationReport | null>(null);
|
||||||
|
generationError = signal<string | null>(null);
|
||||||
|
|
||||||
|
onSiteChange(event: Event): void {
|
||||||
|
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onGenerate(): void {
|
||||||
|
if (this.generating()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.generating.set(true);
|
||||||
|
this.generationError.set(null);
|
||||||
|
this.recommendationsService.generate(this.siteFilter() ?? undefined).subscribe({
|
||||||
|
next: (report) => {
|
||||||
|
this.generating.set(false);
|
||||||
|
this.generationReport.set(report);
|
||||||
|
this.list().reload();
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.generating.set(false);
|
||||||
|
this.generationError.set(GENERATION_FAILED_MESSAGE);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bilan(report: RecommendationGenerationReport): string {
|
||||||
|
return [
|
||||||
|
pluriel(report.recommendations_created, 'recommandation créée', 'recommandations créées'),
|
||||||
|
pluriel(report.already_present, 'déjà présente', 'déjà présentes'),
|
||||||
|
pluriel(report.alerts_examined, 'alerte examinée', 'alertes examinées'),
|
||||||
|
].join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,5 +81,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<section class="recommendations-section">
|
||||||
|
<h2>Recommandations</h2>
|
||||||
|
<app-recommendation-list [siteId]="siteId() ?? null" [sites]="siteAsList()" />
|
||||||
|
<a
|
||||||
|
routerLink="/recommendations"
|
||||||
|
[queryParams]="{ site: siteId() }"
|
||||||
|
class="ev-link recommendations-section__link"
|
||||||
|
>Voir dans la vue recommandations</a
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,3 +117,12 @@ h2 {
|
|||||||
.chart-section {
|
.chart-section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recommendations-section {
|
||||||
|
margin: 2.5rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations-section__link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { BehaviorSubject, of, throwError } from 'rxjs';
|
|||||||
import { SiteDetail } from './site-detail';
|
import { SiteDetail } from './site-detail';
|
||||||
import { SitesService } from '../../../core/services/sites.service';
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
import { ReadingsService } from '../../../core/services/readings.service';
|
import { ReadingsService } from '../../../core/services/readings.service';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||||
|
|
||||||
const SITE = {
|
const SITE = {
|
||||||
site_id: 'SITE001',
|
site_id: 'SITE001',
|
||||||
@@ -69,6 +71,7 @@ function setup(
|
|||||||
readingsMock: Partial<ReadingsService>,
|
readingsMock: Partial<ReadingsService>,
|
||||||
) {
|
) {
|
||||||
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
|
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [SiteDetail],
|
imports: [SiteDetail],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -76,9 +79,14 @@ function setup(
|
|||||||
{ provide: ActivatedRoute, useValue: { paramMap } },
|
{ provide: ActivatedRoute, useValue: { paramMap } },
|
||||||
{ provide: SitesService, useValue: sitesMock },
|
{ provide: SitesService, useValue: sitesMock },
|
||||||
{ provide: ReadingsService, useValue: readingsMock },
|
{ provide: ReadingsService, useValue: readingsMock },
|
||||||
|
{ provide: AlertsService, useValue: { getAlerts } },
|
||||||
|
{
|
||||||
|
provide: RecommendationsService,
|
||||||
|
useValue: { getRecommendations: vi.fn().mockReturnValue(of([])) },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
|
return { fixture: TestBed.createComponent(SiteDetail), paramMap, getAlerts };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('SiteDetail', () => {
|
describe('SiteDetail', () => {
|
||||||
@@ -261,6 +269,27 @@ describe('SiteDetail', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('demande les recommandations du site consulté à travers ses alertes', () => {
|
||||||
|
const { fixture, getAlerts } = setup(
|
||||||
|
'SITE001',
|
||||||
|
{
|
||||||
|
getSite: vi.fn().mockReturnValue(of(SITE)),
|
||||||
|
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
|
||||||
|
},
|
||||||
|
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||||
|
expect(fixture.nativeElement.querySelector('app-recommendation-list')).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Recommandations');
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('a[href="/recommendations?site=SITE001"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
|
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
|
||||||
const getHistory = vi.fn().mockReturnValue(of([]));
|
const getHistory = vi.fn().mockReturnValue(of([]));
|
||||||
const { fixture } = setup(
|
const { fixture } = setup(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
|
|||||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||||
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
|
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
|
||||||
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
||||||
|
import { RecommendationList } from '../../../shared/components/recommendation-list/recommendation-list';
|
||||||
|
|
||||||
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
||||||
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
||||||
@@ -96,7 +97,16 @@ export interface MetricView {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-site-detail',
|
selector: 'app-site-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
Card,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Brand,
|
||||||
|
ConsumptionGauge,
|
||||||
|
ReadingHistoryChart,
|
||||||
|
RecommendationList,
|
||||||
|
],
|
||||||
templateUrl: './site-detail.html',
|
templateUrl: './site-detail.html',
|
||||||
styleUrl: './site-detail.scss',
|
styleUrl: './site-detail.scss',
|
||||||
})
|
})
|
||||||
@@ -117,6 +127,11 @@ export class SiteDetail {
|
|||||||
|
|
||||||
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
||||||
|
|
||||||
|
siteAsList = computed<Site[]>(() => {
|
||||||
|
const site = this.site();
|
||||||
|
return site ? [site] : [];
|
||||||
|
});
|
||||||
|
|
||||||
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
|
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
|
||||||
|
|
||||||
consumptionLabel = computed(() => {
|
consumptionLabel = computed(() => {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="reco-list__banner">{{ message }}</ev-alert>
|
||||||
|
} @else if (loading() && !hasData()) {
|
||||||
|
<p class="reco-list__state" aria-live="polite">Chargement des recommandations…</p>
|
||||||
|
} @else if (visibleGroups().length === 0) {
|
||||||
|
<ev-alert severity="success" class="reco-list__banner">{{ emptyMessage() }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="reco-list" [attr.aria-busy]="loading()">
|
||||||
|
@for (group of visibleGroups(); track group.alert.alert_id) {
|
||||||
|
<ev-card
|
||||||
|
class="reco-group"
|
||||||
|
[class.reco-group--focus]="group.alert.alert_id === alertId()"
|
||||||
|
[id]="'alerte-' + group.alert.alert_id"
|
||||||
|
>
|
||||||
|
<header class="reco-group__alert">
|
||||||
|
<div class="reco-group__meta">
|
||||||
|
<ev-badge [tone]="toneFor(group.alert.severity)">{{
|
||||||
|
severityLabel(group.alert.severity)
|
||||||
|
}}</ev-badge>
|
||||||
|
<span class="reco-group__type">{{ typeLabel(group.alert.type) }}</span>
|
||||||
|
@if (!siteId()) {
|
||||||
|
<a [routerLink]="['/sites', group.alert.site_id]" class="ev-link">{{
|
||||||
|
group.siteName
|
||||||
|
}}</a>
|
||||||
|
}
|
||||||
|
<time [attr.datetime]="group.alert.timestamp">{{
|
||||||
|
group.alert.timestamp | date: 'dd/MM/yyyy HH:mm'
|
||||||
|
}}</time>
|
||||||
|
</div>
|
||||||
|
<p class="reco-group__message">{{ group.alert.message }}</p>
|
||||||
|
</header>
|
||||||
|
<ol class="reco-group__items">
|
||||||
|
@for (reco of group.recommendations; track reco.recommendation_id) {
|
||||||
|
<li class="reco">
|
||||||
|
<div class="reco__head">
|
||||||
|
<strong class="reco__action">{{ reco.action }}</strong>
|
||||||
|
<ev-badge [tone]="ruleTone(reco.rule_reference)">{{
|
||||||
|
ruleLabel(reco.rule_reference)
|
||||||
|
}}</ev-badge>
|
||||||
|
</div>
|
||||||
|
<p class="reco__explanation">{{ reco.explanation }}</p>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</ev-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-list__banner {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-list__state {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group {
|
||||||
|
padding: var(--space-4);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group--focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__alert {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco-group__items {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco__action {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco__explanation {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { NEVER, of, throwError } from 'rxjs';
|
||||||
|
import { RecommendationList, joinByAlert } from './recommendation-list';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||||
|
import { Alert } from '../../models/alert.model';
|
||||||
|
import { Recommendation } from '../../models/recommendation.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
|
||||||
|
const SITES: Site[] = [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Usine Nantes',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
site_id: 'SITE002',
|
||||||
|
site_name: 'Bureau Lille',
|
||||||
|
site_type: 'bureau',
|
||||||
|
location: 'Lille',
|
||||||
|
capacity_kw: 80,
|
||||||
|
status: 'actif',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function alerte(surcharges: Partial<Alert>): Alert {
|
||||||
|
return {
|
||||||
|
alert_id: 1,
|
||||||
|
site_id: 'SITE001',
|
||||||
|
timestamp: '2026-09-15T09:00:00Z',
|
||||||
|
type: 'threshold',
|
||||||
|
severity: 'high',
|
||||||
|
message: 'Puissance appelée au-dessus de la capacité du site',
|
||||||
|
value: 812.5,
|
||||||
|
threshold: 720,
|
||||||
|
metric: 'consumption_kw',
|
||||||
|
prediction_id: null,
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reco(surcharges: Partial<Recommendation>): Recommendation {
|
||||||
|
return {
|
||||||
|
recommendation_id: 1,
|
||||||
|
alert_id: 1,
|
||||||
|
action: 'Ramener la puissance appelée sous le seuil contractuel',
|
||||||
|
explanation: 'Seuil de consommation dépassé sur le site SITE001.',
|
||||||
|
rule_reference: 'threshold-reduction-v1',
|
||||||
|
created_at: '2026-09-15T09:05:00Z',
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALERTES: Alert[] = [
|
||||||
|
alerte({ alert_id: 1, site_id: 'SITE001', timestamp: '2026-09-15T09:00:00Z' }),
|
||||||
|
alerte({
|
||||||
|
alert_id: 2,
|
||||||
|
site_id: 'SITE002',
|
||||||
|
timestamp: '2026-09-15T11:00:00Z',
|
||||||
|
severity: 'critical',
|
||||||
|
type: 'spike',
|
||||||
|
message: 'Variation brutale entre deux lectures consécutives',
|
||||||
|
}),
|
||||||
|
alerte({ alert_id: 3, site_id: 'SITE001', timestamp: '2026-09-15T10:00:00Z', severity: 'low' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const RECOMMANDATIONS: Recommendation[] = [
|
||||||
|
reco({
|
||||||
|
recommendation_id: 3,
|
||||||
|
alert_id: 2,
|
||||||
|
action: "Escalader à l'astreinte sous une heure",
|
||||||
|
rule_reference: 'escalade-astreinte-v1',
|
||||||
|
}),
|
||||||
|
reco({ recommendation_id: 1, alert_id: 1 }),
|
||||||
|
reco({
|
||||||
|
recommendation_id: 2,
|
||||||
|
alert_id: 2,
|
||||||
|
action: 'Délester les équipements non prioritaires sur le créneau du pic',
|
||||||
|
rule_reference: 'spike-delestage-v1',
|
||||||
|
}),
|
||||||
|
reco({ recommendation_id: 4, alert_id: 99, rule_reference: 'orpheline-v1' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
|
||||||
|
recosMock: { getRecommendations: ReturnType<typeof vi.fn> },
|
||||||
|
inputs: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [RecommendationList],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: AlertsService, useValue: alertsMock },
|
||||||
|
{ provide: RecommendationsService, useValue: recosMock },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fixture = TestBed.createComponent(RecommendationList);
|
||||||
|
for (const [nom, valeur] of Object.entries(inputs)) {
|
||||||
|
fixture.componentRef.setInput(nom, valeur);
|
||||||
|
}
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendre(fixture: ComponentFixture<RecommendationList>) {
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function texte(fixture: ComponentFixture<RecommendationList>): string {
|
||||||
|
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const recosOk = () => ({ getRecommendations: vi.fn().mockReturnValue(of(RECOMMANDATIONS)) });
|
||||||
|
|
||||||
|
describe('joinByAlert', () => {
|
||||||
|
it('groupe par alerte, du plus récent au plus ancien, recommandations par identifiant', () => {
|
||||||
|
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map([['SITE001', 'Usine Nantes']]));
|
||||||
|
|
||||||
|
expect(groupes.map((g) => g.alert.alert_id)).toEqual([2, 1]);
|
||||||
|
expect(groupes[0].recommendations.map((r) => r.recommendation_id)).toEqual([2, 3]);
|
||||||
|
expect(groupes[1].siteName).toBe('Usine Nantes');
|
||||||
|
expect(groupes[0].siteName).toBe('SITE002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore les alertes sans recommandation et les recommandations orphelines', () => {
|
||||||
|
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map());
|
||||||
|
|
||||||
|
expect(groupes.some((g) => g.alert.alert_id === 3)).toBe(false);
|
||||||
|
expect(groupes.flatMap((g) => g.recommendations).some((r) => r.alert_id === 99)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecommendationList', () => {
|
||||||
|
it('charge alertes et recommandations puis affiche les groupes avec leur contexte', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
|
||||||
|
const fixture = setup({ getAlerts }, recosOk(), { sites: SITES });
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({});
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(2);
|
||||||
|
const contenu = texte(fixture);
|
||||||
|
expect(contenu).toContain('Usine Nantes');
|
||||||
|
expect(contenu).toContain('Bureau Lille');
|
||||||
|
expect(contenu).toContain('Critique');
|
||||||
|
expect(contenu).toContain('Pic de consommation');
|
||||||
|
expect(contenu).toContain('Escalade astreinte');
|
||||||
|
expect(contenu).toContain('Délester les équipements');
|
||||||
|
expect(contenu).toContain('15/09/2026');
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE002"]')).not.toBeNull();
|
||||||
|
expect(fixture.componentInstance.total()).toBe(3);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filtre les alertes du site côté API et masque le lien vers le site', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of(ALERTES.filter((a) => a.site_id === 'SITE001')));
|
||||||
|
const fixture = setup({ getAlerts }, recosOk(), { siteId: 'SITE001', sites: SITES });
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(1);
|
||||||
|
expect(fixture.nativeElement.querySelector('a[href^="/sites/"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne garde que le groupe de l'alerte ciblée et le met en évidence", () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
|
||||||
|
alertId: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
const groupes = fixture.nativeElement.querySelectorAll('.reco-group');
|
||||||
|
expect(groupes.length).toBe(1);
|
||||||
|
expect(groupes[0].classList.contains('reco-group--focus')).toBe(true);
|
||||||
|
expect(groupes[0].id).toBe('alerte-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence de recommandation pour une alerte inconnue", () => {
|
||||||
|
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
|
||||||
|
alertId: 123,
|
||||||
|
});
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('Aucune recommandation pour cette alerte.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annonce l'absence de recommandation pour le site consulté", () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(of([])) },
|
||||||
|
{ getRecommendations: vi.fn().mockReturnValue(of([])) },
|
||||||
|
{ siteId: 'SITE001' },
|
||||||
|
);
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(texte(fixture)).toContain('Aucune recommandation pour ce site.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'indisponibilité et n'affiche aucun groupe si un des deux appels échoue", () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) },
|
||||||
|
{ getRecommendations: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||||
|
);
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||||
|
expect(fixture.componentInstance.groups()).toEqual([]);
|
||||||
|
expect(texte(fixture)).toContain('Recommandations indisponibles');
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annonce le chargement tant que la réponse ne vient pas', () => {
|
||||||
|
const fixture = setup(
|
||||||
|
{ getAlerts: vi.fn().mockReturnValue(NEVER) },
|
||||||
|
{ getRecommendations: vi.fn().mockReturnValue(NEVER) },
|
||||||
|
);
|
||||||
|
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.loading()).toBe(true);
|
||||||
|
expect(texte(fixture)).toContain('Chargement des recommandations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recharge les deux flux à la demande', () => {
|
||||||
|
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
|
||||||
|
const recos = recosOk();
|
||||||
|
const fixture = setup({ getAlerts }, recos);
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
fixture.componentInstance.reload();
|
||||||
|
rendre(fixture);
|
||||||
|
|
||||||
|
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(recos.getRecommendations).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { catchError, EMPTY, forkJoin, Observable, switchMap, tap } from 'rxjs';
|
||||||
|
import { AlertsService } from '../../../core/services/alerts.service';
|
||||||
|
import { RecommendationsService } from '../../../core/services/recommendations.service';
|
||||||
|
import { Alert, AlertSeverity, AlertType } from '../../models/alert.model';
|
||||||
|
import { Recommendation } from '../../models/recommendation.model';
|
||||||
|
import { Site } from '../../models/site.model';
|
||||||
|
import {
|
||||||
|
LIBELLE_PAR_SEVERITE,
|
||||||
|
LIBELLE_PAR_TYPE,
|
||||||
|
TON_PAR_SEVERITE,
|
||||||
|
} from '../../models/alert-presentation';
|
||||||
|
import { libelleRegle, tonRegle } from '../../models/recommendation-presentation';
|
||||||
|
import { Card } from '../ui/card/card';
|
||||||
|
import { Badge, BadgeTone } from '../ui/badge/badge';
|
||||||
|
import { Alert as EvAlert } from '../ui/alert/alert';
|
||||||
|
|
||||||
|
const UNAVAILABLE_MESSAGE = 'Recommandations indisponibles, réessayez plus tard.';
|
||||||
|
|
||||||
|
export interface RecommendedAlertView {
|
||||||
|
alert: Alert;
|
||||||
|
siteName: string;
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chargement {
|
||||||
|
alerts: Alert[];
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pourquoi : une recommandation ne porte que alert_id, jamais site_id, et /recommendations n'a
|
||||||
|
// aucun filtre ; la jointure se fait ici, en O(alertes), acceptable à la taille du jeu de données.
|
||||||
|
export function joinByAlert(
|
||||||
|
alerts: Alert[],
|
||||||
|
recommendations: Recommendation[],
|
||||||
|
siteNames: Map<string, string>,
|
||||||
|
): RecommendedAlertView[] {
|
||||||
|
const parAlerte = new Map<number, Recommendation[]>();
|
||||||
|
for (const recommandation of recommendations) {
|
||||||
|
const liste = parAlerte.get(recommandation.alert_id) ?? [];
|
||||||
|
liste.push(recommandation);
|
||||||
|
parAlerte.set(recommandation.alert_id, liste);
|
||||||
|
}
|
||||||
|
return alerts
|
||||||
|
.filter((alert) => parAlerte.has(alert.alert_id))
|
||||||
|
.map((alert) => ({
|
||||||
|
alert,
|
||||||
|
siteName: siteNames.get(alert.site_id) ?? alert.site_id,
|
||||||
|
recommendations: [...(parAlerte.get(alert.alert_id) ?? [])].sort(
|
||||||
|
(a, b) => a.recommendation_id - b.recommendation_id,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Date.parse(b.alert.timestamp) - Date.parse(a.alert.timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-recommendation-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DatePipe, RouterLink, Card, Badge, EvAlert],
|
||||||
|
templateUrl: './recommendation-list.html',
|
||||||
|
styleUrl: './recommendation-list.scss',
|
||||||
|
})
|
||||||
|
export class RecommendationList {
|
||||||
|
private alertsService = inject(AlertsService);
|
||||||
|
private recommendationsService = inject(RecommendationsService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
siteId = input<string | null>(null);
|
||||||
|
alertId = input<number | null>(null);
|
||||||
|
sites = input<Site[]>([]);
|
||||||
|
|
||||||
|
private data = signal<Chargement | null>(null);
|
||||||
|
private reloadTick = signal(0);
|
||||||
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
private trigger = computed(() => ({ siteId: this.siteId(), tick: this.reloadTick() }));
|
||||||
|
|
||||||
|
private siteNameById = computed(
|
||||||
|
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
|
||||||
|
);
|
||||||
|
|
||||||
|
hasData = computed(() => this.data() !== null);
|
||||||
|
|
||||||
|
groups = computed<RecommendedAlertView[]>(() => {
|
||||||
|
const data = this.data();
|
||||||
|
return data ? joinByAlert(data.alerts, data.recommendations, this.siteNameById()) : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
visibleGroups = computed(() => {
|
||||||
|
const alertId = this.alertId();
|
||||||
|
const groups = this.groups();
|
||||||
|
return alertId === null ? groups : groups.filter((group) => group.alert.alert_id === alertId);
|
||||||
|
});
|
||||||
|
|
||||||
|
total = computed(() =>
|
||||||
|
this.visibleGroups().reduce((somme, group) => somme + group.recommendations.length, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
emptyMessage = computed(() => {
|
||||||
|
if (this.alertId() !== null) {
|
||||||
|
return 'Aucune recommandation pour cette alerte.';
|
||||||
|
}
|
||||||
|
return this.siteId()
|
||||||
|
? 'Aucune recommandation pour ce site.'
|
||||||
|
: 'Aucune recommandation pour le moment.';
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
toObservable(this.trigger)
|
||||||
|
.pipe(
|
||||||
|
tap(() => this.loading.set(true)),
|
||||||
|
switchMap(({ siteId }) =>
|
||||||
|
forkJoin({
|
||||||
|
alerts: this.alertsService.getAlerts(siteId ? { site_id: siteId } : {}),
|
||||||
|
recommendations: this.recommendationsService.getRecommendations(),
|
||||||
|
}).pipe(catchError(() => this.reportUnavailable())),
|
||||||
|
),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((data) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(null);
|
||||||
|
this.data.set(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
this.reloadTick.update((tick) => tick + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
toneFor(severity: AlertSeverity): BadgeTone {
|
||||||
|
return TON_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
severityLabel(severity: AlertSeverity): string {
|
||||||
|
return LIBELLE_PAR_SEVERITE[severity];
|
||||||
|
}
|
||||||
|
|
||||||
|
typeLabel(type: AlertType): string {
|
||||||
|
return LIBELLE_PAR_TYPE[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleLabel(reference: string): string {
|
||||||
|
return libelleRegle(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleTone(reference: string): BadgeTone {
|
||||||
|
return tonRegle(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Piège : vider les données avec l'erreur ; une demi-jointure (alertes sans recommandations,
|
||||||
|
// ou l'inverse) afficherait des groupes faux plutôt que rien.
|
||||||
|
private reportUnavailable(): Observable<never> {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
this.data.set(null);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
LIBELLE_PAR_SEVERITE,
|
||||||
|
LIBELLE_PAR_TYPE,
|
||||||
|
SEVERITES,
|
||||||
|
TON_PAR_SEVERITE,
|
||||||
|
TYPES_ALERTE,
|
||||||
|
UNITE_PAR_METRIQUE,
|
||||||
|
} from './alert-presentation';
|
||||||
|
|
||||||
|
describe('alert-presentation', () => {
|
||||||
|
it('distingue le ton des sévérités high et critical', () => {
|
||||||
|
expect(TON_PAR_SEVERITE.high).toBe('danger');
|
||||||
|
expect(TON_PAR_SEVERITE.critical).toBe('critical');
|
||||||
|
expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'affiche pas une alerte faible avec le ton de succès", () => {
|
||||||
|
expect(TON_PAR_SEVERITE.low).toBe('neutral');
|
||||||
|
expect(TON_PAR_SEVERITE.medium).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('donne un libellé français à chaque sévérité et à chaque type', () => {
|
||||||
|
for (const severite of SEVERITES) {
|
||||||
|
expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy();
|
||||||
|
}
|
||||||
|
for (const type of TYPES_ALERTE) {
|
||||||
|
expect(LIBELLE_PAR_TYPE[type]).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(SEVERITES.length).toBe(4);
|
||||||
|
expect(TYPES_ALERTE.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associe une unité à chaque métrique du contrat', () => {
|
||||||
|
expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW');
|
||||||
|
expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { BadgeTone } from '../components/ui/badge/badge';
|
||||||
|
import { AlertMetric, AlertSeverity, AlertType } from './alert.model';
|
||||||
|
|
||||||
|
// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se
|
||||||
|
// lisait comme « tout va bien » à côté des rouges.
|
||||||
|
export const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
||||||
|
low: 'neutral',
|
||||||
|
medium: 'warning',
|
||||||
|
high: 'danger',
|
||||||
|
critical: 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLE_PAR_SEVERITE: Record<AlertSeverity, string> = {
|
||||||
|
low: 'Faible',
|
||||||
|
medium: 'Moyenne',
|
||||||
|
high: 'Élevée',
|
||||||
|
critical: 'Critique',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLE_PAR_TYPE: Record<AlertType, string> = {
|
||||||
|
spike: 'Pic de consommation',
|
||||||
|
threshold: 'Seuil dépassé',
|
||||||
|
anomaly: 'Anomalie',
|
||||||
|
outage: 'Coupure',
|
||||||
|
sensor: 'Capteur',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UNITE_PAR_METRIQUE: Record<AlertMetric, string> = {
|
||||||
|
consumption_kw: 'kW',
|
||||||
|
consumption_kwh: 'kWh',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical'];
|
||||||
|
|
||||||
|
export const TYPES_ALERTE: readonly AlertType[] = [
|
||||||
|
'spike',
|
||||||
|
'threshold',
|
||||||
|
'anomaly',
|
||||||
|
'outage',
|
||||||
|
'sensor',
|
||||||
|
];
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||||
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
||||||
|
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
|
||||||
|
|
||||||
export interface Alert {
|
export interface Alert {
|
||||||
alert_id: string;
|
alert_id: number;
|
||||||
timestamp: string;
|
|
||||||
site_id: string;
|
site_id: string;
|
||||||
severity: AlertSeverity;
|
timestamp: string;
|
||||||
type: AlertType;
|
type: AlertType;
|
||||||
|
severity: AlertSeverity;
|
||||||
message: string;
|
message: string;
|
||||||
value: number;
|
value: number | null;
|
||||||
threshold: number;
|
threshold: number | null;
|
||||||
|
metric: AlertMetric | null;
|
||||||
|
prediction_id: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { libelleRegle, tonRegle } from './recommendation-presentation';
|
||||||
|
|
||||||
|
describe('recommendation-presentation', () => {
|
||||||
|
it('traduit les sept règles connues du moteur', () => {
|
||||||
|
expect(libelleRegle('spike-delestage-v1')).toBe('Délestage');
|
||||||
|
expect(libelleRegle('threshold-reduction-v1')).toBe('Réduction de puissance');
|
||||||
|
expect(libelleRegle('outage-secours-v1')).toBe('Alimentation de secours');
|
||||||
|
expect(libelleRegle('sensor-maintenance-v1')).toBe('Maintenance capteur');
|
||||||
|
expect(libelleRegle('anomaly-verification-v1')).toBe('Vérification');
|
||||||
|
expect(libelleRegle('escalade-astreinte-v1')).toBe('Escalade astreinte');
|
||||||
|
expect(libelleRegle('contrat-puissance-v1')).toBe('Contrat de puissance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche telle quelle une référence de règle inconnue', () => {
|
||||||
|
expect(libelleRegle('spike-delestage-v2')).toBe('spike-delestage-v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("réserve le ton critique à l'escalade vers l'astreinte", () => {
|
||||||
|
expect(tonRegle('escalade-astreinte-v1')).toBe('critical');
|
||||||
|
expect(tonRegle('spike-delestage-v1')).toBe('neutral');
|
||||||
|
expect(tonRegle('inconnue-v9')).toBe('neutral');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { BadgeTone } from '../components/ui/badge/badge';
|
||||||
|
|
||||||
|
// Contrainte : une règle dont le sens change reçoit un suffixe -v2 côté backend (ADR 0006) ;
|
||||||
|
// une référence inconnue s'affiche donc telle quelle plutôt que de casser la vue.
|
||||||
|
const LIBELLE_PAR_REGLE: Record<string, string> = {
|
||||||
|
'spike-delestage-v1': 'Délestage',
|
||||||
|
'threshold-reduction-v1': 'Réduction de puissance',
|
||||||
|
'outage-secours-v1': 'Alimentation de secours',
|
||||||
|
'sensor-maintenance-v1': 'Maintenance capteur',
|
||||||
|
'anomaly-verification-v1': 'Vérification',
|
||||||
|
'escalade-astreinte-v1': 'Escalade astreinte',
|
||||||
|
'contrat-puissance-v1': 'Contrat de puissance',
|
||||||
|
};
|
||||||
|
|
||||||
|
const REGLE_ESCALADE = 'escalade-astreinte-v1';
|
||||||
|
|
||||||
|
export function libelleRegle(reference: string): string {
|
||||||
|
return LIBELLE_PAR_REGLE[reference] ?? reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tonRegle(reference: string): BadgeTone {
|
||||||
|
return reference === REGLE_ESCALADE ? 'critical' : 'neutral';
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface Recommendation {
|
||||||
|
recommendation_id: number;
|
||||||
|
alert_id: number;
|
||||||
|
action: string;
|
||||||
|
explanation: string;
|
||||||
|
rule_reference: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecommendationGenerationReport {
|
||||||
|
alerts_examined: number;
|
||||||
|
recommendations_created: number;
|
||||||
|
already_present: number;
|
||||||
|
}
|
||||||
@@ -29,3 +29,18 @@
|
|||||||
color: var(--color-disabled);
|
color: var(--color-disabled);
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur
|
||||||
|
// reprend en dur la valeur de --color-text-muted.
|
||||||
|
.form-select {
|
||||||
|
@extend .form-input;
|
||||||
|
padding-right: 2.25rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.6rem center;
|
||||||
|
background-size: 1rem;
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
|
|||||||
@@ -146,6 +146,29 @@ Compose.
|
|||||||
|
|
||||||
Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md).
|
Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md).
|
||||||
|
|
||||||
|
## Recommandations
|
||||||
|
|
||||||
|
Statut : `Fait`. La vue `/recommendations` (`features/recommendations`, derrière `authGuard`, tous
|
||||||
|
rôles) présente les recommandations du moteur de règles groupées par alerte, du plus récent au plus
|
||||||
|
ancien, avec le contexte de l'alerte (sévérité, type, site, horodatage, message) puis chaque action,
|
||||||
|
son explication et la règle qui l'a produite.
|
||||||
|
|
||||||
|
- **Jointure côté client.** Une recommandation ne porte que `alert_id`, jamais `site_id`, et
|
||||||
|
`GET /recommendations` n'a aucun filtre. `app-recommendation-list` (`shared/components/`) charge
|
||||||
|
donc en parallèle `GET /alerts` (filtré par `site_id` quand un site est fixé) et
|
||||||
|
`GET /recommendations`, puis les joint par `alert_id` (`joinByAlert`, fonction pure testée à
|
||||||
|
part). Les recommandations dont l'alerte n'est pas dans le jeu chargé sont ignorées : c'est ainsi
|
||||||
|
que le filtre site s'applique. `/alerts` n'étant pas paginé, un seul appel suffit.
|
||||||
|
- **Paramètres d'URL.** `?site=<site_id>` présélectionne le filtre site ; `?alert=<alert_id>`
|
||||||
|
réduit la vue à une alerte et la met en évidence (entier strictement positif, sinon ignoré).
|
||||||
|
- **Génération.** Le bouton « Générer les recommandations » n'apparaît que pour le rôle `admin`
|
||||||
|
(`POST /recommendations/generate?site_id=`, réservé admin côté API) et affiche le bilan renvoyé
|
||||||
|
(créées, déjà présentes, alertes examinées) avant de recharger la liste. La voie normale reste le
|
||||||
|
DAG Airflow `alertes` ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
|
||||||
|
- **Entrées.** Lien « Recommandations » dans l'en-tête du tableau de bord ; section
|
||||||
|
« Recommandations » sur la vue détail d'un site (liste restreinte au site, lien vers la vue
|
||||||
|
complète préfiltrée).
|
||||||
|
|
||||||
## Questions ouvertes
|
## Questions ouvertes
|
||||||
|
|
||||||
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
|
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
|
|||||||
| `--shadow-card` | Ombre portée des cartes |
|
| `--shadow-card` | Ombre portée des cartes |
|
||||||
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
|
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
|
||||||
|
|
||||||
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
|
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`)
|
||||||
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
|
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
|
||||||
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
|
s'appliquent directement à des `<label>`/`<input>`/`<select>` natifs, liés par `formControlName` ou
|
||||||
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
|
par un simple `(change)` : pas de composant `ControlValueAccessor` dédié, le gain n'en vaut pas la
|
||||||
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
|
complexité pour des formulaires aussi simples que ceux de ce projet. `.form-select` habille un
|
||||||
`<ev-alert severity="danger">`, pas une classe dédiée.
|
`<select>` natif avec la bordure et le focus de `.form-input`, plus un chevron. Les erreurs de
|
||||||
|
formulaire, elles, s'affichent via `<ev-alert severity="danger">`, pas une classe dédiée.
|
||||||
|
|
||||||
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
|
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
|
||||||
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
|
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
|
||||||
|
|||||||
Reference in New Issue
Block a user