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