Compare commits

..
Author SHA1 Message Date
Johan LEROY 65baf3fddb docs(infra): aligne le README du proxy sur nginx 1.31-alpine
Le tag de l'image y est cité deux fois (présentation, commande nginx -t) ;
il suit celui de docker-compose.prod.yml. Le runner du Dockerfile frontend
est une image durcie dhi.io, suivie séparément, et n'est pas concerné.
2026-09-22 08:38:03 +02:00
dependabot[bot]andGitHub 7f025f72ac chore(deps): bump nginx from 1.28-alpine to 1.31-alpine
Bumps nginx from 1.28-alpine to 1.31-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.31-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:17:47 +00:00
PhyriosandGitHub 1cd206adc8 Merge pull request #125 from ineszang/dev
Push dev to main
2026-09-21 14:17:01 +02:00
32 changed files with 78 additions and 1421 deletions
+1 -4
View File
@@ -1,4 +1,4 @@
# Conventions de tests unitaires : Frontend
# Conventions de tests unitaires — Frontend
## Outil
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
@@ -83,6 +83,3 @@ describe('MonComposant', () => {
## Lancer les tests
- Développement (mode watch) : `npm test`
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
- Un fichier ou un dossier seulement :
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
+7 -28
View File
@@ -1,36 +1,21 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth-guard';
import {authGuard} from './core/guards/auth-guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'login',
loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
},
{
path: 'change-password',
loadComponent: () =>
import('./features/auth/change-password/change-password').then((m) => m.ChangePassword),
},
{
path: 'forgot-password',
loadComponent: () =>
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
},
{
path: 'reset-password',
loadComponent: () =>
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
},
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
},
{
path: 'sites',
canActivate: [authGuard],
loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList),
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
},
{
path: 'sites/:siteId',
@@ -38,12 +23,6 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
},
{
path: 'recommendations',
canActivate: [authGuard],
loadComponent: () =>
import('./features/recommendations/recommendations').then((m) => m.RecommendationsView),
},
{
path: 'monitoring/sensors',
canActivate: [authGuard],
@@ -2,63 +2,53 @@ import { Alert } from '../../shared/models/alert.model';
export const ALERTS_FIXTURE: Alert[] = [
{
alert_id: 5,
alert_id: 'ALR-SITE002-1718458320',
timestamp: '2026-09-15T11:12:00',
site_id: 'SITE002',
timestamp: '2026-09-15T11:12:00Z',
type: 'threshold',
severity: 'critical',
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
type: 'outage',
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 4,
alert_id: 'ALR-SITE003-1718458321',
timestamp: '2026-09-15T11:05:00',
site_id: 'SITE003',
timestamp: '2026-09-15T11:05:00Z',
type: 'outage',
severity: 'critical',
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
value: null,
threshold: null,
metric: null,
prediction_id: null,
},
{
alert_id: 3,
site_id: 'SITE005',
timestamp: '2026-09-15T10:47:00Z',
type: 'spike',
severity: 'high',
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
value: 410.0,
threshold: 260.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 2,
site_id: 'SITE006',
timestamp: '2026-09-15T10:30:00Z',
type: 'sensor',
severity: 'medium',
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
value: null,
threshold: null,
metric: null,
prediction_id: null,
message: 'Perte réseau totale sur Data Center Marseille',
value: 0,
threshold: 0,
},
{
alert_id: 1,
alert_id: 'ALR-SITE005-1718458322',
timestamp: '2026-09-15T10:47:00',
site_id: 'SITE005',
severity: 'high',
type: 'threshold',
message: 'Usine Toulouse approche de son seuil de capacité',
value: 410.0,
threshold: 480.0,
},
{
alert_id: 'ALR-SITE006-1718458323',
timestamp: '2026-09-15T10:30:00',
site_id: 'SITE006',
severity: 'medium',
type: 'sensor',
message: 'Capteur de température défaillant sur Bureau Lille',
value: 0,
threshold: 0,
},
{
alert_id: 'ALR-SITE004-1718458324',
timestamp: '2026-09-15T09:58:00',
site_id: 'SITE004',
timestamp: '2026-09-15T09:58:00Z',
type: 'anomaly',
severity: 'low',
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
type: 'anomaly',
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
value: 62.0,
threshold: 55.0,
metric: 'consumption_kwh',
prediction_id: 42,
},
];
@@ -3,20 +3,6 @@ import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AlertsService } from './alerts.service';
import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model';
const ALERT_API: Alert = {
alert_id: 1,
site_id: 'site-1',
timestamp: '2026-09-16T00:00:00Z',
type: 'threshold',
severity: 'high',
message: 'Dépassement du seuil configuré',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
};
describe('AlertsService', () => {
let service: AlertsService;
@@ -32,35 +18,26 @@ describe('AlertsService', () => {
afterEach(() => httpMock.verify());
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
let result: Alert[] = [];
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
let result: unknown;
service.getAlerts().subscribe((r) => (result = r));
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
);
expect(req.request.params.keys()).toEqual([]);
req.flush([ALERT_API]);
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
expect(req.request.method).toBe('GET');
expect(result.length).toBe(1);
expect(result[0].alert_id).toBe(1);
expect(result[0].prediction_id).toBeNull();
});
req.flush([
{
alert_id: 'ALR-TEST-1',
timestamp: '2026-09-15T12:00:00',
site_id: 'SITE001',
severity: 'high',
type: 'threshold',
message: 'Test',
value: 100,
threshold: 90,
},
]);
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([]);
expect((result as unknown[]).length).toBe(1);
});
});
@@ -1,25 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
export interface AlertFilters {
site_id?: string;
severity?: AlertSeverity;
}
import { Alert } from '../../shared/models/alert.model';
@Service()
export class AlertsService {
private http = inject(HttpClient);
getAlerts(filters: AlertFilters = {}) {
let params = new HttpParams();
if (filters.site_id) {
params = params.set('site_id', filters.site_id);
}
if (filters.severity) {
params = params.set('severity', filters.severity);
}
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
getAlerts() {
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
}
}
@@ -1,74 +0,0 @@
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 });
});
});
@@ -1,34 +0,0 @@
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,7 +14,6 @@
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
}
<a routerLink="/sites" class="ev-link">Voir les sites</a>
<a routerLink="/recommendations" class="ev-link">Recommandations</a>
<ev-button
class="logout-button"
variant="secondary"
@@ -1,58 +0,0 @@
<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>
@@ -1,59 +0,0 @@
: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);
}
@@ -1,169 +0,0 @@
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');
});
});
@@ -1,85 +0,0 @@
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,16 +81,5 @@
}
}
<section class="recommendations-section">
<h2>Recommandations</h2>
<app-recommendation-list [siteId]="siteId() ?? null" [sites]="siteAsList()" />
<a
routerLink="/recommendations"
[queryParams]="{ site: siteId() }"
class="ev-link recommendations-section__link"
>Voir dans la vue recommandations</a
>
</section>
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</div>
@@ -117,12 +117,3 @@ h2 {
.chart-section {
margin-bottom: 2rem;
}
.recommendations-section {
margin: 2.5rem 0 1.5rem;
}
.recommendations-section__link {
display: inline-block;
margin-top: 1rem;
}
@@ -5,8 +5,6 @@ import { BehaviorSubject, of, throwError } from 'rxjs';
import { SiteDetail } from './site-detail';
import { SitesService } from '../../../core/services/sites.service';
import { ReadingsService } from '../../../core/services/readings.service';
import { AlertsService } from '../../../core/services/alerts.service';
import { RecommendationsService } from '../../../core/services/recommendations.service';
const SITE = {
site_id: 'SITE001',
@@ -71,7 +69,6 @@ function setup(
readingsMock: Partial<ReadingsService>,
) {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
const getAlerts = vi.fn().mockReturnValue(of([]));
TestBed.configureTestingModule({
imports: [SiteDetail],
providers: [
@@ -79,14 +76,9 @@ function setup(
{ provide: ActivatedRoute, useValue: { paramMap } },
{ provide: SitesService, useValue: sitesMock },
{ provide: ReadingsService, useValue: readingsMock },
{ provide: AlertsService, useValue: { getAlerts } },
{
provide: RecommendationsService,
useValue: { getRecommendations: vi.fn().mockReturnValue(of([])) },
},
],
});
return { fixture: TestBed.createComponent(SiteDetail), paramMap, getAlerts };
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
}
describe('SiteDetail', () => {
@@ -269,27 +261,6 @@ describe('SiteDetail', () => {
);
});
it('demande les recommandations du site consulté à travers ses alertes', () => {
const { fixture, getAlerts } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
fixture.detectChanges();
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
expect(fixture.nativeElement.querySelector('app-recommendation-list')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Recommandations');
expect(
fixture.nativeElement.querySelector('a[href="/recommendations?site=SITE001"]'),
).not.toBeNull();
});
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
const getHistory = vi.fn().mockReturnValue(of([]));
const { fixture } = setup(
@@ -13,7 +13,6 @@ import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
import { Brand } from '../../../shared/components/ui/brand/brand';
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
import { RecommendationList } from '../../../shared/components/recommendation-list/recommendation-list';
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
@@ -97,16 +96,7 @@ export interface MetricView {
@Component({
selector: 'app-site-detail',
standalone: true,
imports: [
RouterLink,
Card,
Alert,
Badge,
Brand,
ConsumptionGauge,
ReadingHistoryChart,
RecommendationList,
],
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
templateUrl: './site-detail.html',
styleUrl: './site-detail.scss',
})
@@ -127,11 +117,6 @@ export class SiteDetail {
hasMeasurement = computed(() => this.current()?.timestamp != null);
siteAsList = computed<Site[]>(() => {
const site = this.site();
return site ? [site] : [];
});
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
consumptionLabel = computed(() => {
@@ -1,48 +0,0 @@
@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>
@@ -1,94 +0,0 @@
: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);
}
@@ -1,244 +0,0 @@
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);
});
});
@@ -1,163 +0,0 @@
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;
}
}
@@ -1,37 +0,0 @@
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');
});
});
@@ -1,41 +0,0 @@
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,16 +1,13 @@
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
export interface Alert {
alert_id: number;
site_id: string;
alert_id: string;
timestamp: string;
type: AlertType;
site_id: string;
severity: AlertSeverity;
type: AlertType;
message: string;
value: number | null;
threshold: number | null;
metric: AlertMetric | null;
prediction_id: number | null;
value: number;
threshold: number;
}
@@ -1,23 +0,0 @@
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');
});
});
@@ -1,23 +0,0 @@
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';
}
@@ -1,14 +0,0 @@
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;
}
-15
View File
@@ -29,18 +29,3 @@
color: var(--color-disabled);
margin-top: 0.25rem;
}
// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur
// reprend en dur la valeur de --color-text-muted.
.form-select {
@extend .form-input;
padding-right: 2.25rem;
color: var(--color-text);
background-color: var(--color-surface);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
background-size: 1rem;
appearance: none;
cursor: pointer;
}
-1
View File
@@ -3,7 +3,6 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
+1 -1
View File
@@ -44,7 +44,7 @@ services:
ports: !reset null
proxy:
image: nginx:1.28-alpine
image: nginx:1.31-alpine
depends_on:
backend:
condition: service_healthy
-23
View File
@@ -146,29 +146,6 @@ Compose.
Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md).
## Recommandations
Statut : `Fait`. La vue `/recommendations` (`features/recommendations`, derrière `authGuard`, tous
rôles) présente les recommandations du moteur de règles groupées par alerte, du plus récent au plus
ancien, avec le contexte de l'alerte (sévérité, type, site, horodatage, message) puis chaque action,
son explication et la règle qui l'a produite.
- **Jointure côté client.** Une recommandation ne porte que `alert_id`, jamais `site_id`, et
`GET /recommendations` n'a aucun filtre. `app-recommendation-list` (`shared/components/`) charge
donc en parallèle `GET /alerts` (filtré par `site_id` quand un site est fixé) et
`GET /recommendations`, puis les joint par `alert_id` (`joinByAlert`, fonction pure testée à
part). Les recommandations dont l'alerte n'est pas dans le jeu chargé sont ignorées : c'est ainsi
que le filtre site s'applique. `/alerts` n'étant pas paginé, un seul appel suffit.
- **Paramètres d'URL.** `?site=<site_id>` présélectionne le filtre site ; `?alert=<alert_id>`
réduit la vue à une alerte et la met en évidence (entier strictement positif, sinon ignoré).
- **Génération.** Le bouton « Générer les recommandations » n'apparaît que pour le rôle `admin`
(`POST /recommendations/generate?site_id=`, réservé admin côté API) et affiche le bilan renvoyé
(créées, déjà présentes, alertes examinées) avant de recharger la liste. La voie normale reste le
DAG Airflow `alertes` ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
- **Entrées.** Lien « Recommandations » dans l'en-tête du tableau de bord ; section
« Recommandations » sur la vue détail d'un site (liste restreinte au site, lien vers la vue
complète préfiltrée).
## Questions ouvertes
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
@@ -24,13 +24,12 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
| `--shadow-card` | Ombre portée des cartes |
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`)
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>`/`<select>` natifs, liés par `formControlName` ou
par un simple `(change)` : pas de composant `ControlValueAccessor` dédié, le gain n'en vaut pas la
complexité pour des formulaires aussi simples que ceux de ce projet. `.form-select` habille un
`<select>` natif avec la bordure et le focus de `.form-input`, plus un chevron. Les erreurs de
formulaire, elles, s'affichent via `<ev-alert severity="danger">`, pas une classe dédiée.
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
`<ev-alert severity="danger">`, pas une classe dédiée.
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`,
+2 -2
View File
@@ -9,7 +9,7 @@ Terminaison TLS et routage de la stack déployée. Seul composant publié sur le
- `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git.
- `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`.
Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la
Pas de `Dockerfile` : l'image officielle `nginx:1.31-alpine` est utilisée telle quelle et la
configuration est montée en volume par `docker-compose.prod.yml`.
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
@@ -82,7 +82,7 @@ docker run --rm \
-v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \
-v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \
nginx:1.28-alpine nginx -t
nginx:1.31-alpine nginx -t
```
Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image.