From 7b9406965e3bc00a14266d55bab30c086ddefbed Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:17 +0200 Subject: [PATCH] fix(frontend): maintient le rafraichissement du tableau de bord en cas d'erreur Sans catchError, la premiere reponse en erreur terminait le flux du timer : le rafraichissement ne repartait jamais et l'ecran restait fige sur des chiffres perimes, sans rien signaler. Le catchError porte sur l'observable interne du switchMap. Place sur le flux externe il terminerait le timer tout autant. Un signal error alimente un bandeau, efface des qu'une reponse valide revient. --- .../src/app/features/dashboard/dashboard.html | 4 ++ .../src/app/features/dashboard/dashboard.scss | 10 ++++ .../app/features/dashboard/dashboard.spec.ts | 56 ++++++++++++++++++- .../src/app/features/dashboard/dashboard.ts | 32 ++++++++--- 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 70b333c..d324c74 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -4,6 +4,10 @@

Consommation instantanée du parc

+ @if (error(); as message) { + + } + @if (stats(); as s) {
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index d0b1088..3cacb0f 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -37,6 +37,16 @@ h2 { margin: 0 0 1rem; } +.banner-error { + margin: 0 0 1.5rem; + padding: 0.75rem 1rem; + border: 1px solid var(--color-critical); + border-left-width: 4px; + border-radius: var(--radius); + background: #fdecea; + color: var(--color-critical); +} + .overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 7dcbe37..f55adac 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; @@ -8,6 +8,7 @@ import { AlertsService } from '../../core/services/alerts.service'; vi.mock('chart.js', () => { class ChartMock { update = vi.fn(); + destroy = vi.fn(); data = { datasets: [{}] }; static register = vi.fn(); } @@ -15,6 +16,8 @@ vi.mock('chart.js', () => { }); describe('Dashboard', () => { + afterEach(() => vi.useRealTimers()); + it('charge les stats et les alertes au démarrage', async () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) }; @@ -37,5 +40,56 @@ describe('Dashboard', () => { expect(statsMock.getSummary).toHaveBeenCalled(); expect(alertsMock.getAlerts).toHaveBeenCalled(); expect(fixture.componentInstance.alerts().length).toBe(1); + expect(fixture.componentInstance.error()).toBeNull(); + }); + + it('signale l\'indisponibilité puis repart au rafraîchissement suivant', () => { + vi.useFakeTimers(); + const statsMock = { + getSummary: vi + .fn() + .mockReturnValueOnce(throwError(() => new Error('API injoignable'))) + .mockReturnValue(of({ total_sites: 7, sites: [] })), + }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + vi.advanceTimersByTime(1); + expect(statsMock.getSummary).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.stats()).toBeNull(); + + vi.advanceTimersByTime(10000); + expect(statsMock.getSummary).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.stats()).not.toBeNull(); + expect(fixture.componentInstance.error()).toBeNull(); + }); + + it("n'interrompt pas la page quand le chargement des alertes échoue", () => { + const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; + const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + ], + }); + + const fixture = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.alerts().length).toBe(0); }); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index 2fe7768..b6a7627 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -1,15 +1,17 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { timer, switchMap } from 'rxjs'; +import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs'; import { DecimalPipe } from '@angular/common'; import { StatsService } from '../../core/services/stats.service'; import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge'; import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart'; -import {AlertsService} from '../../core/services/alerts.service'; -import {StatsSummary} from '../../shared/models/stats.model'; -import {Alert} from '../../shared/models/alert.model'; +import { AlertsService } from '../../core/services/alerts.service'; +import { StatsSummary } from '../../shared/models/stats.model'; +import { Alert } from '../../shared/models/alert.model'; const REFRESH_INTERVAL_MS = 10000; +const UNAVAILABLE_MESSAGE = + 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; @Component({ selector: 'app-dashboard', @@ -25,15 +27,31 @@ export class Dashboard implements OnInit { stats = signal(null); alerts = signal([]); + error = signal(null); ngOnInit(): void { - this.alertsService.getAlerts().subscribe((alerts) => this.alerts.set(alerts)); + this.alertsService + .getAlerts() + .pipe(catchError(() => this.reportUnavailable())) + .subscribe((alerts) => this.alerts.set(alerts)); + // Piège : le catchError porte sur l'observable interne. Sur le flux externe il + // terminerait le timer, et le rafraîchissement ne repartirait jamais. timer(0, REFRESH_INTERVAL_MS) .pipe( - switchMap(() => this.statsService.getSummary()), + switchMap(() => + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())) + ), takeUntilDestroyed(this.destroyRef) ) - .subscribe((stats) => this.stats.set(stats)); + .subscribe((stats) => { + this.error.set(null); + this.stats.set(stats); + }); + } + + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; } }