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; } }