diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 40e814f..d2f1079 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,21 +1,36 @@ import { Routes } from '@angular/router'; -import {authGuard} from './core/guards/auth-guard'; +import { authGuard } from './core/guards/auth-guard'; export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, - { path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) }, - { path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) }, - { path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) }, - { path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) }, + { + path: 'login', + loadComponent: () => import('./features/auth/login/login').then((m) => m.Login), + }, + { + path: 'change-password', + loadComponent: () => + import('./features/auth/change-password/change-password').then((m) => m.ChangePassword), + }, + { + path: 'forgot-password', + loadComponent: () => + import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword), + }, + { + path: 'reset-password', + loadComponent: () => + import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword), + }, { path: 'dashboard', canActivate: [authGuard], - loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard), + loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard), }, { path: 'sites', canActivate: [authGuard], - loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList), + loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList), }, { path: 'sites/:siteId', @@ -23,6 +38,12 @@ export const routes: Routes = [ loadComponent: () => import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), }, + { + path: 'recommendations', + canActivate: [authGuard], + loadComponent: () => + import('./features/recommendations/recommendations').then((m) => m.RecommendationsView), + }, { path: 'monitoring/sensors', canActivate: [authGuard], diff --git a/apps/frontend/src/app/core/services/recommendations.service.spec.ts b/apps/frontend/src/app/core/services/recommendations.service.spec.ts new file mode 100644 index 0000000..4252757 --- /dev/null +++ b/apps/frontend/src/app/core/services/recommendations.service.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { RecommendationsService } from './recommendations.service'; +import { environment } from '../../../environments/environment'; +import { Recommendation } from '../../shared/models/recommendation.model'; + +const RECOMMANDATION_API: Recommendation = { + recommendation_id: 1, + alert_id: 1, + action: 'Vérifier la consommation', + explanation: 'Pic détecté', + rule_reference: 'spike-v1', + created_at: '2024-01-01T00:00:00Z', +}; + +describe('RecommendationsService', () => { + let service: RecommendationsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(RecommendationsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('liste les recommandations depuis le bon endpoint', () => { + let result: Recommendation[] = []; + service.getRecommendations().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/recommendations`); + expect(req.request.method).toBe('GET'); + req.flush([RECOMMANDATION_API]); + + expect(result.length).toBe(1); + expect(result[0].alert_id).toBe(1); + }); + + it('décrit une recommandation par son identifiant', () => { + service.getRecommendation(42).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/recommendations/42`); + expect(req.request.method).toBe('GET'); + req.flush({ ...RECOMMANDATION_API, recommendation_id: 42 }); + }); + + it('déclenche la génération en POST avec le site en paramètre de requête', () => { + let result: unknown; + service.generate('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST', + ); + expect(req.request.params.get('site_id')).toBe('SITE001'); + expect(req.request.body).toBeNull(); + req.flush({ alerts_examined: 2, recommendations_created: 3, already_present: 1 }); + + expect(result).toEqual({ alerts_examined: 2, recommendations_created: 3, already_present: 1 }); + }); + + it('génère pour tout le parc quand aucun site n’est donné', () => { + service.generate().subscribe(); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST', + ); + expect(req.request.params.has('site_id')).toBe(false); + req.flush({ alerts_examined: 0, recommendations_created: 0, already_present: 0 }); + }); +}); diff --git a/apps/frontend/src/app/core/services/recommendations.service.ts b/apps/frontend/src/app/core/services/recommendations.service.ts new file mode 100644 index 0000000..7a51844 --- /dev/null +++ b/apps/frontend/src/app/core/services/recommendations.service.ts @@ -0,0 +1,34 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { + Recommendation, + RecommendationGenerationReport, +} from '../../shared/models/recommendation.model'; + +@Service() +export class RecommendationsService { + private http = inject(HttpClient); + + getRecommendations() { + return this.http.get(`${environment.apiUrl}/recommendations`); + } + + getRecommendation(recommendationId: number) { + return this.http.get( + `${environment.apiUrl}/recommendations/${recommendationId}`, + ); + } + + generate(siteId?: string) { + let params = new HttpParams(); + if (siteId) { + params = params.set('site_id', siteId); + } + return this.http.post( + `${environment.apiUrl}/recommendations/generate`, + null, + { params }, + ); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 78e7fca..fd84e27 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -14,6 +14,7 @@ Supervision des capteurs } Voir les sites + Recommandations + + +
+ + +
+

Recommandations

+

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

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

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

+ } + + + diff --git a/apps/frontend/src/app/features/recommendations/recommendations.scss b/apps/frontend/src/app/features/recommendations/recommendations.scss new file mode 100644 index 0000000..ba95139 --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.scss @@ -0,0 +1,59 @@ +:host { + display: block; + color: var(--color-text); + padding: 2.5rem 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.recommendations__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.recommendations__logo { + font-size: 1.3rem; +} + +.recommendations__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +.recommendations__toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.recommendations__filter { + display: flex; + flex-direction: column; + min-width: 14rem; + + .form-label { + margin-top: 0; + } +} + +.recommendations__banner { + display: block; + margin-bottom: var(--space-3); +} + +.recommendations__focus { + margin: 0 0 var(--space-3); + font-size: 0.9rem; + color: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/features/recommendations/recommendations.spec.ts b/apps/frontend/src/app/features/recommendations/recommendations.spec.ts new file mode 100644 index 0000000..dcb8e62 --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.spec.ts @@ -0,0 +1,169 @@ +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { RecommendationsView, parseAlertId } from './recommendations'; +import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list'; +import { SitesService } from '../../core/services/sites.service'; +import { AlertsService } from '../../core/services/alerts.service'; +import { RecommendationsService } from '../../core/services/recommendations.service'; +import { AuthService } from '../../core/services/auth.service'; + +const SITES = [ + { + site_id: 'SITE001', + site_name: 'Usine Nantes', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }, +]; + +const BILAN = { alerts_examined: 2, recommendations_created: 3, already_present: 1 }; + +function setup(options: { query?: Record; role?: string } = {}) { + const query = options.query ?? {}; + const queryParamMap = new BehaviorSubject(convertToParamMap(query)); + const generate = vi.fn().mockReturnValue(of(BILAN)); + const getRecommendations = vi.fn().mockReturnValue(of([])); + const getAlerts = vi.fn().mockReturnValue(of([])); + TestBed.configureTestingModule({ + imports: [RecommendationsView], + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { queryParamMap, snapshot: { queryParamMap: convertToParamMap(query) } }, + }, + { provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of(SITES)) } }, + { provide: AlertsService, useValue: { getAlerts } }, + { provide: RecommendationsService, useValue: { getRecommendations, generate } }, + { + provide: AuthService, + useValue: { principal: vi.fn().mockReturnValue({ role: options.role ?? 'lecteur' }) }, + }, + ], + }); + const fixture = TestBed.createComponent(RecommendationsView); + fixture.detectChanges(); + fixture.detectChanges(); + return { fixture, queryParamMap, generate, getRecommendations, getAlerts }; +} + +function listeEnfant(fixture: ReturnType['fixture']): RecommendationList { + return fixture.debugElement.query(By.directive(RecommendationList)).componentInstance; +} + +describe('parseAlertId', () => { + it("n'accepte qu'un entier strictement positif", () => { + expect(parseAlertId('12')).toBe(12); + expect(parseAlertId('0')).toBeNull(); + expect(parseAlertId('-3')).toBeNull(); + expect(parseAlertId('abc')).toBeNull(); + expect(parseAlertId('12abc')).toBeNull(); + expect(parseAlertId(null)).toBeNull(); + }); +}); + +describe('RecommendationsView', () => { + it("cible l'alerte donnée par ?alert= et la transmet à la liste", () => { + const { fixture } = setup({ query: { alert: '12' } }); + + expect(fixture.componentInstance.alertId()).toBe(12); + expect(listeEnfant(fixture).alertId()).toBe(12); + expect(fixture.nativeElement.textContent).toContain('Alerte n° 12'); + expect(fixture.nativeElement.querySelector('a[href="/recommendations"]')).not.toBeNull(); + }); + + it('ignore un paramètre alert invalide', () => { + const { fixture } = setup({ query: { alert: 'abc' } }); + + expect(fixture.componentInstance.alertId()).toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('Alerte n°'); + }); + + it('applique le site donné par ?site= au filtre et à la liste', () => { + const { fixture, getAlerts } = setup({ query: { site: 'SITE001' } }); + + expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' }); + const option = fixture.nativeElement.querySelector( + 'option[value="SITE001"]', + ) as HTMLOptionElement; + expect(option.selected).toBe(true); + }); + + it('relance la liste sur le site choisi dans le filtre', () => { + const { fixture, getAlerts } = setup(); + const select = fixture.nativeElement.querySelector( + '[data-testid="site-filter"]', + ) as HTMLSelectElement; + + select.value = 'SITE001'; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + fixture.detectChanges(); + + expect(getAlerts).toHaveBeenLastCalledWith({ site_id: 'SITE001' }); + expect(listeEnfant(fixture).siteId()).toBe('SITE001'); + }); + + it('cache le bouton de génération aux lecteurs', () => { + const { fixture } = setup({ role: 'lecteur' }); + + expect(fixture.nativeElement.querySelector('[data-testid="generate"]')).toBeNull(); + }); + + it('permet à un admin de générer pour le site filtré, affiche le bilan et recharge la liste', () => { + const { fixture, generate, getRecommendations } = setup({ + role: 'admin', + query: { site: 'SITE001' }, + }); + + fixture.nativeElement.querySelector('[data-testid="generate"]').click(); + fixture.detectChanges(); + fixture.detectChanges(); + + expect(generate).toHaveBeenCalledWith('SITE001'); + expect(fixture.nativeElement.textContent).toContain( + '3 recommandations créées, 1 déjà présente, 2 alertes examinées.', + ); + expect(getRecommendations).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.generating()).toBe(false); + }); + + it('génère pour tout le parc quand aucun site n’est filtré', () => { + const { fixture, generate } = setup({ role: 'admin' }); + + fixture.componentInstance.onGenerate(); + + expect(generate).toHaveBeenCalledWith(undefined); + }); + + it("signale l'échec de la génération sans casser la page", () => { + const { fixture, generate } = setup({ role: 'admin' }); + generate.mockReturnValue(throwError(() => new Error('403'))); + + fixture.componentInstance.onGenerate(); + fixture.detectChanges(); + + expect(fixture.componentInstance.generationError()).not.toBeNull(); + expect(fixture.nativeElement.textContent).toContain( + 'La génération des recommandations a échoué', + ); + expect(fixture.componentInstance.generating()).toBe(false); + }); + + it('accorde le bilan au singulier', () => { + const { fixture } = setup(); + + expect( + fixture.componentInstance.bilan({ + alerts_examined: 1, + recommendations_created: 1, + already_present: 0, + }), + ).toBe('1 recommandation créée, 0 déjà présente, 1 alerte examinée'); + }); +}); diff --git a/apps/frontend/src/app/features/recommendations/recommendations.ts b/apps/frontend/src/app/features/recommendations/recommendations.ts new file mode 100644 index 0000000..985c5af --- /dev/null +++ b/apps/frontend/src/app/features/recommendations/recommendations.ts @@ -0,0 +1,85 @@ +import { Component, computed, inject, signal, viewChild } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { catchError, map, of } from 'rxjs'; +import { SitesService } from '../../core/services/sites.service'; +import { RecommendationsService } from '../../core/services/recommendations.service'; +import { AuthService } from '../../core/services/auth.service'; +import { Site } from '../../shared/models/site.model'; +import { RecommendationGenerationReport } from '../../shared/models/recommendation.model'; +import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list'; +import { Alert as EvAlert } from '../../shared/components/ui/alert/alert'; +import { Brand } from '../../shared/components/ui/brand/brand'; +import { Button } from '../../shared/components/ui/button/button'; + +const GENERATION_FAILED_MESSAGE = + 'La génération des recommandations a échoué, réessayez plus tard.'; + +export function parseAlertId(raw: string | null): number | null { + return raw !== null && /^[1-9]\d*$/.test(raw) ? Number(raw) : null; +} + +function pluriel(nombre: number, singulier: string, plurielForme: string): string { + return `${nombre} ${nombre > 1 ? plurielForme : singulier}`; +} + +@Component({ + selector: 'app-recommendations', + standalone: true, + imports: [RouterLink, RecommendationList, EvAlert, Brand, Button], + templateUrl: './recommendations.html', + styleUrl: './recommendations.scss', +}) +export class RecommendationsView { + private route = inject(ActivatedRoute); + private sitesService = inject(SitesService); + private recommendationsService = inject(RecommendationsService); + private auth = inject(AuthService); + + alertId = toSignal( + this.route.queryParamMap.pipe(map((params) => parseAlertId(params.get('alert')))), + { initialValue: null }, + ); + siteFilter = signal(this.route.snapshot.queryParamMap.get('site')); + sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), { + initialValue: [] as Site[], + }); + + list = viewChild.required(RecommendationList); + + isAdmin = computed(() => this.auth.principal()?.role === 'admin'); + generating = signal(false); + generationReport = signal(null); + generationError = signal(null); + + onSiteChange(event: Event): void { + this.siteFilter.set((event.target as HTMLSelectElement).value || null); + } + + onGenerate(): void { + if (this.generating()) { + return; + } + this.generating.set(true); + this.generationError.set(null); + this.recommendationsService.generate(this.siteFilter() ?? undefined).subscribe({ + next: (report) => { + this.generating.set(false); + this.generationReport.set(report); + this.list().reload(); + }, + error: () => { + this.generating.set(false); + this.generationError.set(GENERATION_FAILED_MESSAGE); + }, + }); + } + + bilan(report: RecommendationGenerationReport): string { + return [ + pluriel(report.recommendations_created, 'recommandation créée', 'recommandations créées'), + pluriel(report.already_present, 'déjà présente', 'déjà présentes'), + pluriel(report.alerts_examined, 'alerte examinée', 'alertes examinées'), + ].join(', '); + } +} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.html b/apps/frontend/src/app/features/sites/site-detail/site-detail.html index 9c4a4bc..e10d1db 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.html +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.html @@ -81,5 +81,16 @@ } } +
+

Recommandations

+ + Voir dans la vue recommandations +
+ Retour aux sites diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss index a0032be..5cfcad0 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -117,3 +117,12 @@ h2 { .chart-section { margin-bottom: 2rem; } + +.recommendations-section { + margin: 2.5rem 0 1.5rem; +} + +.recommendations-section__link { + display: inline-block; + margin-top: 1rem; +} diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts index 5e3cf46..0e7c35a 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -5,6 +5,8 @@ import { BehaviorSubject, of, throwError } from 'rxjs'; import { SiteDetail } from './site-detail'; import { SitesService } from '../../../core/services/sites.service'; import { ReadingsService } from '../../../core/services/readings.service'; +import { AlertsService } from '../../../core/services/alerts.service'; +import { RecommendationsService } from '../../../core/services/recommendations.service'; const SITE = { site_id: 'SITE001', @@ -69,6 +71,7 @@ function setup( readingsMock: Partial, ) { const paramMap = new BehaviorSubject(convertToParamMap({ siteId })); + const getAlerts = vi.fn().mockReturnValue(of([])); TestBed.configureTestingModule({ imports: [SiteDetail], providers: [ @@ -76,9 +79,14 @@ function setup( { provide: ActivatedRoute, useValue: { paramMap } }, { provide: SitesService, useValue: sitesMock }, { provide: ReadingsService, useValue: readingsMock }, + { provide: AlertsService, useValue: { getAlerts } }, + { + provide: RecommendationsService, + useValue: { getRecommendations: vi.fn().mockReturnValue(of([])) }, + }, ], }); - return { fixture: TestBed.createComponent(SiteDetail), paramMap }; + return { fixture: TestBed.createComponent(SiteDetail), paramMap, getAlerts }; } describe('SiteDetail', () => { @@ -261,6 +269,27 @@ describe('SiteDetail', () => { ); }); + it('demande les recommandations du site consulté à travers ses alertes', () => { + const { fixture, getAlerts } = setup( + 'SITE001', + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), + }, + { getHistory: vi.fn().mockReturnValue(of([])) }, + ); + + fixture.detectChanges(); + fixture.detectChanges(); + + expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' }); + expect(fixture.nativeElement.querySelector('app-recommendation-list')).not.toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Recommandations'); + expect( + fixture.nativeElement.querySelector('a[href="/recommendations?site=SITE001"]'), + ).not.toBeNull(); + }); + it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => { const getHistory = vi.fn().mockReturnValue(of([])); const { fixture } = setup( diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 21e716d..4fa7896 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -13,6 +13,7 @@ import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge'; import { Brand } from '../../../shared/components/ui/brand/brand'; import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge'; import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart'; +import { RecommendationList } from '../../../shared/components/recommendation-list/recommendation-list'; const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.'; const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.'; @@ -96,7 +97,16 @@ export interface MetricView { @Component({ selector: 'app-site-detail', standalone: true, - imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart], + imports: [ + RouterLink, + Card, + Alert, + Badge, + Brand, + ConsumptionGauge, + ReadingHistoryChart, + RecommendationList, + ], templateUrl: './site-detail.html', styleUrl: './site-detail.scss', }) @@ -117,6 +127,11 @@ export class SiteDetail { hasMeasurement = computed(() => this.current()?.timestamp != null); + siteAsList = computed(() => { + const site = this.site(); + return site ? [site] : []; + }); + consumptionKw = computed(() => this.current()?.consumption_kw ?? null); consumptionLabel = computed(() => { diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html new file mode 100644 index 0000000..d844bdc --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.html @@ -0,0 +1,48 @@ +@if (error(); as message) { + {{ message }} +} @else if (loading() && !hasData()) { +

Chargement des recommandations…

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

{{ group.alert.message }}

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

    {{ reco.explanation }}

    +
  2. + } +
+
+ } +
diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss new file mode 100644 index 0000000..9456376 --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.scss @@ -0,0 +1,94 @@ +:host { + display: block; +} + +.reco-list__banner { + display: block; + margin-bottom: var(--space-3); +} + +.reco-list__state { + margin: 0 0 var(--space-3); + font-size: 0.9rem; + color: var(--color-text-muted); +} + +.reco-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.reco-group { + padding: var(--space-4); + gap: var(--space-3); +} + +.reco-group--focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); +} + +.reco-group__alert { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--color-border-light); +} + +.reco-group__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.reco-group__type { + font-weight: 600; + color: var(--color-text); +} + +.reco-group__message { + margin: 0; + font-size: 0.9rem; +} + +.reco-group__items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.reco { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: var(--color-bg); + border-left: 3px solid var(--color-primary); +} + +.reco__head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.reco__action { + font-size: 0.95rem; +} + +.reco__explanation { + margin: 0; + font-size: 0.85rem; + color: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts new file mode 100644 index 0000000..1f3ae01 --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.spec.ts @@ -0,0 +1,244 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import { NEVER, of, throwError } from 'rxjs'; +import { RecommendationList, joinByAlert } from './recommendation-list'; +import { AlertsService } from '../../../core/services/alerts.service'; +import { RecommendationsService } from '../../../core/services/recommendations.service'; +import { Alert } from '../../models/alert.model'; +import { Recommendation } from '../../models/recommendation.model'; +import { Site } from '../../models/site.model'; + +const SITES: Site[] = [ + { + site_id: 'SITE001', + site_name: 'Usine Nantes', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }, + { + site_id: 'SITE002', + site_name: 'Bureau Lille', + site_type: 'bureau', + location: 'Lille', + capacity_kw: 80, + status: 'actif', + }, +]; + +function alerte(surcharges: Partial): Alert { + return { + alert_id: 1, + site_id: 'SITE001', + timestamp: '2026-09-15T09:00:00Z', + type: 'threshold', + severity: 'high', + message: 'Puissance appelée au-dessus de la capacité du site', + value: 812.5, + threshold: 720, + metric: 'consumption_kw', + prediction_id: null, + ...surcharges, + }; +} + +function reco(surcharges: Partial): Recommendation { + return { + recommendation_id: 1, + alert_id: 1, + action: 'Ramener la puissance appelée sous le seuil contractuel', + explanation: 'Seuil de consommation dépassé sur le site SITE001.', + rule_reference: 'threshold-reduction-v1', + created_at: '2026-09-15T09:05:00Z', + ...surcharges, + }; +} + +const ALERTES: Alert[] = [ + alerte({ alert_id: 1, site_id: 'SITE001', timestamp: '2026-09-15T09:00:00Z' }), + alerte({ + alert_id: 2, + site_id: 'SITE002', + timestamp: '2026-09-15T11:00:00Z', + severity: 'critical', + type: 'spike', + message: 'Variation brutale entre deux lectures consécutives', + }), + alerte({ alert_id: 3, site_id: 'SITE001', timestamp: '2026-09-15T10:00:00Z', severity: 'low' }), +]; + +const RECOMMANDATIONS: Recommendation[] = [ + reco({ + recommendation_id: 3, + alert_id: 2, + action: "Escalader à l'astreinte sous une heure", + rule_reference: 'escalade-astreinte-v1', + }), + reco({ recommendation_id: 1, alert_id: 1 }), + reco({ + recommendation_id: 2, + alert_id: 2, + action: 'Délester les équipements non prioritaires sur le créneau du pic', + rule_reference: 'spike-delestage-v1', + }), + reco({ recommendation_id: 4, alert_id: 99, rule_reference: 'orpheline-v1' }), +]; + +function setup( + alertsMock: { getAlerts: ReturnType }, + recosMock: { getRecommendations: ReturnType }, + inputs: Record = {}, +) { + TestBed.configureTestingModule({ + imports: [RecommendationList], + providers: [ + provideRouter([]), + { provide: AlertsService, useValue: alertsMock }, + { provide: RecommendationsService, useValue: recosMock }, + ], + }); + const fixture = TestBed.createComponent(RecommendationList); + for (const [nom, valeur] of Object.entries(inputs)) { + fixture.componentRef.setInput(nom, valeur); + } + return fixture; +} + +function rendre(fixture: ComponentFixture) { + fixture.detectChanges(); + fixture.detectChanges(); +} + +function texte(fixture: ComponentFixture): string { + return (fixture.nativeElement as HTMLElement).textContent ?? ''; +} + +const recosOk = () => ({ getRecommendations: vi.fn().mockReturnValue(of(RECOMMANDATIONS)) }); + +describe('joinByAlert', () => { + it('groupe par alerte, du plus récent au plus ancien, recommandations par identifiant', () => { + const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map([['SITE001', 'Usine Nantes']])); + + expect(groupes.map((g) => g.alert.alert_id)).toEqual([2, 1]); + expect(groupes[0].recommendations.map((r) => r.recommendation_id)).toEqual([2, 3]); + expect(groupes[1].siteName).toBe('Usine Nantes'); + expect(groupes[0].siteName).toBe('SITE002'); + }); + + it('ignore les alertes sans recommandation et les recommandations orphelines', () => { + const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map()); + + expect(groupes.some((g) => g.alert.alert_id === 3)).toBe(false); + expect(groupes.flatMap((g) => g.recommendations).some((r) => r.alert_id === 99)).toBe(false); + }); +}); + +describe('RecommendationList', () => { + it('charge alertes et recommandations puis affiche les groupes avec leur contexte', () => { + const getAlerts = vi.fn().mockReturnValue(of(ALERTES)); + const fixture = setup({ getAlerts }, recosOk(), { sites: SITES }); + + rendre(fixture); + + expect(getAlerts).toHaveBeenCalledWith({}); + expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(2); + const contenu = texte(fixture); + expect(contenu).toContain('Usine Nantes'); + expect(contenu).toContain('Bureau Lille'); + expect(contenu).toContain('Critique'); + expect(contenu).toContain('Pic de consommation'); + expect(contenu).toContain('Escalade astreinte'); + expect(contenu).toContain('Délester les équipements'); + expect(contenu).toContain('15/09/2026'); + expect(fixture.nativeElement.querySelector('a[href="/sites/SITE002"]')).not.toBeNull(); + expect(fixture.componentInstance.total()).toBe(3); + expect(fixture.componentInstance.error()).toBeNull(); + }); + + it('filtre les alertes du site côté API et masque le lien vers le site', () => { + const getAlerts = vi.fn().mockReturnValue(of(ALERTES.filter((a) => a.site_id === 'SITE001'))); + const fixture = setup({ getAlerts }, recosOk(), { siteId: 'SITE001', sites: SITES }); + + rendre(fixture); + + expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' }); + expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(1); + expect(fixture.nativeElement.querySelector('a[href^="/sites/"]')).toBeNull(); + }); + + it("ne garde que le groupe de l'alerte ciblée et le met en évidence", () => { + const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), { + alertId: 2, + }); + + rendre(fixture); + + const groupes = fixture.nativeElement.querySelectorAll('.reco-group'); + expect(groupes.length).toBe(1); + expect(groupes[0].classList.contains('reco-group--focus')).toBe(true); + expect(groupes[0].id).toBe('alerte-2'); + }); + + it("annonce l'absence de recommandation pour une alerte inconnue", () => { + const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), { + alertId: 123, + }); + + rendre(fixture); + + expect(texte(fixture)).toContain('Aucune recommandation pour cette alerte.'); + }); + + it("annonce l'absence de recommandation pour le site consulté", () => { + const fixture = setup( + { getAlerts: vi.fn().mockReturnValue(of([])) }, + { getRecommendations: vi.fn().mockReturnValue(of([])) }, + { siteId: 'SITE001' }, + ); + + rendre(fixture); + + expect(texte(fixture)).toContain('Aucune recommandation pour ce site.'); + }); + + it("signale l'indisponibilité et n'affiche aucun groupe si un des deux appels échoue", () => { + const fixture = setup( + { getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, + { getRecommendations: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, + ); + + rendre(fixture); + + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.groups()).toEqual([]); + expect(texte(fixture)).toContain('Recommandations indisponibles'); + expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(0); + }); + + it('annonce le chargement tant que la réponse ne vient pas', () => { + const fixture = setup( + { getAlerts: vi.fn().mockReturnValue(NEVER) }, + { getRecommendations: vi.fn().mockReturnValue(NEVER) }, + ); + + rendre(fixture); + + expect(fixture.componentInstance.loading()).toBe(true); + expect(texte(fixture)).toContain('Chargement des recommandations'); + }); + + it('recharge les deux flux à la demande', () => { + const getAlerts = vi.fn().mockReturnValue(of(ALERTES)); + const recos = recosOk(); + const fixture = setup({ getAlerts }, recos); + rendre(fixture); + + fixture.componentInstance.reload(); + rendre(fixture); + + expect(getAlerts).toHaveBeenCalledTimes(2); + expect(recos.getRecommendations).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts new file mode 100644 index 0000000..05b3829 --- /dev/null +++ b/apps/frontend/src/app/shared/components/recommendation-list/recommendation-list.ts @@ -0,0 +1,163 @@ +import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { DatePipe } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { catchError, EMPTY, forkJoin, Observable, switchMap, tap } from 'rxjs'; +import { AlertsService } from '../../../core/services/alerts.service'; +import { RecommendationsService } from '../../../core/services/recommendations.service'; +import { Alert, AlertSeverity, AlertType } from '../../models/alert.model'; +import { Recommendation } from '../../models/recommendation.model'; +import { Site } from '../../models/site.model'; +import { + LIBELLE_PAR_SEVERITE, + LIBELLE_PAR_TYPE, + TON_PAR_SEVERITE, +} from '../../models/alert-presentation'; +import { libelleRegle, tonRegle } from '../../models/recommendation-presentation'; +import { Card } from '../ui/card/card'; +import { Badge, BadgeTone } from '../ui/badge/badge'; +import { Alert as EvAlert } from '../ui/alert/alert'; + +const UNAVAILABLE_MESSAGE = 'Recommandations indisponibles, réessayez plus tard.'; + +export interface RecommendedAlertView { + alert: Alert; + siteName: string; + recommendations: Recommendation[]; +} + +interface Chargement { + alerts: Alert[]; + recommendations: Recommendation[]; +} + +// Pourquoi : une recommandation ne porte que alert_id, jamais site_id, et /recommendations n'a +// aucun filtre ; la jointure se fait ici, en O(alertes), acceptable à la taille du jeu de données. +export function joinByAlert( + alerts: Alert[], + recommendations: Recommendation[], + siteNames: Map, +): RecommendedAlertView[] { + const parAlerte = new Map(); + for (const recommandation of recommendations) { + const liste = parAlerte.get(recommandation.alert_id) ?? []; + liste.push(recommandation); + parAlerte.set(recommandation.alert_id, liste); + } + return alerts + .filter((alert) => parAlerte.has(alert.alert_id)) + .map((alert) => ({ + alert, + siteName: siteNames.get(alert.site_id) ?? alert.site_id, + recommendations: [...(parAlerte.get(alert.alert_id) ?? [])].sort( + (a, b) => a.recommendation_id - b.recommendation_id, + ), + })) + .sort((a, b) => Date.parse(b.alert.timestamp) - Date.parse(a.alert.timestamp)); +} + +@Component({ + selector: 'app-recommendation-list', + standalone: true, + imports: [DatePipe, RouterLink, Card, Badge, EvAlert], + templateUrl: './recommendation-list.html', + styleUrl: './recommendation-list.scss', +}) +export class RecommendationList { + private alertsService = inject(AlertsService); + private recommendationsService = inject(RecommendationsService); + private destroyRef = inject(DestroyRef); + + siteId = input(null); + alertId = input(null); + sites = input([]); + + private data = signal(null); + private reloadTick = signal(0); + loading = signal(true); + error = signal(null); + + private trigger = computed(() => ({ siteId: this.siteId(), tick: this.reloadTick() })); + + private siteNameById = computed( + () => new Map(this.sites().map((site) => [site.site_id, site.site_name])), + ); + + hasData = computed(() => this.data() !== null); + + groups = computed(() => { + const data = this.data(); + return data ? joinByAlert(data.alerts, data.recommendations, this.siteNameById()) : []; + }); + + visibleGroups = computed(() => { + const alertId = this.alertId(); + const groups = this.groups(); + return alertId === null ? groups : groups.filter((group) => group.alert.alert_id === alertId); + }); + + total = computed(() => + this.visibleGroups().reduce((somme, group) => somme + group.recommendations.length, 0), + ); + + emptyMessage = computed(() => { + if (this.alertId() !== null) { + return 'Aucune recommandation pour cette alerte.'; + } + return this.siteId() + ? 'Aucune recommandation pour ce site.' + : 'Aucune recommandation pour le moment.'; + }); + + constructor() { + toObservable(this.trigger) + .pipe( + tap(() => this.loading.set(true)), + switchMap(({ siteId }) => + forkJoin({ + alerts: this.alertsService.getAlerts(siteId ? { site_id: siteId } : {}), + recommendations: this.recommendationsService.getRecommendations(), + }).pipe(catchError(() => this.reportUnavailable())), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((data) => { + this.loading.set(false); + this.error.set(null); + this.data.set(data); + }); + } + + reload(): void { + this.reloadTick.update((tick) => tick + 1); + } + + toneFor(severity: AlertSeverity): BadgeTone { + return TON_PAR_SEVERITE[severity]; + } + + severityLabel(severity: AlertSeverity): string { + return LIBELLE_PAR_SEVERITE[severity]; + } + + typeLabel(type: AlertType): string { + return LIBELLE_PAR_TYPE[type]; + } + + ruleLabel(reference: string): string { + return libelleRegle(reference); + } + + ruleTone(reference: string): BadgeTone { + return tonRegle(reference); + } + + // Piège : vider les données avec l'erreur ; une demi-jointure (alertes sans recommandations, + // ou l'inverse) afficherait des groupes faux plutôt que rien. + private reportUnavailable(): Observable { + this.loading.set(false); + this.error.set(UNAVAILABLE_MESSAGE); + this.data.set(null); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts b/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts new file mode 100644 index 0000000..63b8556 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts @@ -0,0 +1,23 @@ +import { libelleRegle, tonRegle } from './recommendation-presentation'; + +describe('recommendation-presentation', () => { + it('traduit les sept règles connues du moteur', () => { + expect(libelleRegle('spike-delestage-v1')).toBe('Délestage'); + expect(libelleRegle('threshold-reduction-v1')).toBe('Réduction de puissance'); + expect(libelleRegle('outage-secours-v1')).toBe('Alimentation de secours'); + expect(libelleRegle('sensor-maintenance-v1')).toBe('Maintenance capteur'); + expect(libelleRegle('anomaly-verification-v1')).toBe('Vérification'); + expect(libelleRegle('escalade-astreinte-v1')).toBe('Escalade astreinte'); + expect(libelleRegle('contrat-puissance-v1')).toBe('Contrat de puissance'); + }); + + it('affiche telle quelle une référence de règle inconnue', () => { + expect(libelleRegle('spike-delestage-v2')).toBe('spike-delestage-v2'); + }); + + it("réserve le ton critique à l'escalade vers l'astreinte", () => { + expect(tonRegle('escalade-astreinte-v1')).toBe('critical'); + expect(tonRegle('spike-delestage-v1')).toBe('neutral'); + expect(tonRegle('inconnue-v9')).toBe('neutral'); + }); +}); diff --git a/apps/frontend/src/app/shared/models/recommendation-presentation.ts b/apps/frontend/src/app/shared/models/recommendation-presentation.ts new file mode 100644 index 0000000..f52d294 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation-presentation.ts @@ -0,0 +1,23 @@ +import { BadgeTone } from '../components/ui/badge/badge'; + +// Contrainte : une règle dont le sens change reçoit un suffixe -v2 côté backend (ADR 0006) ; +// une référence inconnue s'affiche donc telle quelle plutôt que de casser la vue. +const LIBELLE_PAR_REGLE: Record = { + 'spike-delestage-v1': 'Délestage', + 'threshold-reduction-v1': 'Réduction de puissance', + 'outage-secours-v1': 'Alimentation de secours', + 'sensor-maintenance-v1': 'Maintenance capteur', + 'anomaly-verification-v1': 'Vérification', + 'escalade-astreinte-v1': 'Escalade astreinte', + 'contrat-puissance-v1': 'Contrat de puissance', +}; + +const REGLE_ESCALADE = 'escalade-astreinte-v1'; + +export function libelleRegle(reference: string): string { + return LIBELLE_PAR_REGLE[reference] ?? reference; +} + +export function tonRegle(reference: string): BadgeTone { + return reference === REGLE_ESCALADE ? 'critical' : 'neutral'; +} diff --git a/apps/frontend/src/app/shared/models/recommendation.model.ts b/apps/frontend/src/app/shared/models/recommendation.model.ts new file mode 100644 index 0000000..c017b72 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation.model.ts @@ -0,0 +1,14 @@ +export interface Recommendation { + recommendation_id: number; + alert_id: number; + action: string; + explanation: string; + rule_reference: string; + created_at: string; +} + +export interface RecommendationGenerationReport { + alerts_examined: number; + recommendations_created: number; + already_present: number; +} diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index 5109a61..4969b0b 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -154,6 +154,29 @@ Compose. Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md). +## Recommandations + +Statut : `Fait`. La vue `/recommendations` (`features/recommendations`, derrière `authGuard`, tous +rôles) présente les recommandations du moteur de règles groupées par alerte, du plus récent au plus +ancien, avec le contexte de l'alerte (sévérité, type, site, horodatage, message) puis chaque action, +son explication et la règle qui l'a produite. + +- **Jointure côté client.** Une recommandation ne porte que `alert_id`, jamais `site_id`, et + `GET /recommendations` n'a aucun filtre. `app-recommendation-list` (`shared/components/`) charge + donc en parallèle `GET /alerts` (filtré par `site_id` quand un site est fixé) et + `GET /recommendations`, puis les joint par `alert_id` (`joinByAlert`, fonction pure testée à + part). Les recommandations dont l'alerte n'est pas dans le jeu chargé sont ignorées : c'est ainsi + que le filtre site s'applique. `/alerts` n'étant pas paginé, un seul appel suffit. +- **Paramètres d'URL.** `?site=` présélectionne le filtre site ; `?alert=` + réduit la vue à une alerte et la met en évidence (entier strictement positif, sinon ignoré). +- **Génération.** Le bouton « Générer les recommandations » n'apparaît que pour le rôle `admin` + (`POST /recommendations/generate?site_id=`, réservé admin côté API) et affiche le bilan renvoyé + (créées, déjà présentes, alertes examinées) avant de recharger la liste. La voie normale reste le + DAG Airflow `alertes` ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)). +- **Entrées.** Lien « Recommandations » dans l'en-tête du tableau de bord ; section + « Recommandations » sur la vue détail d'un site (liste restreinte au site, lien vers la vue + complète préfiltrée). + ## Questions ouvertes - **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs