diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html
index f9a3fb2..3d52c11 100644
--- a/apps/frontend/src/app/features/dashboard/dashboard.html
+++ b/apps/frontend/src/app/features/dashboard/dashboard.html
@@ -27,9 +27,6 @@
@if (statsError(); as message) {
{{ message }}
}
- @if (alertsError(); as message) {
- {{ message }}
- }
@if (predictionsError(); as message) {
{{ message }}
}
@@ -68,19 +65,9 @@
}
- @if (alerts().length > 0) {
-
- Alertes actives
-
- @for (alert of alerts(); track alert.alert_id) {
- -
- {{ alert.severity }}
- {{ alert.message }}
-
- }
-
-
- }
+
@if (predictions().length > 0) {
@@ -93,9 +80,9 @@
@if (prediction.status === 'available') {
{{ prediction.predicted_value | number: '1.0-1' }} kWh
- {{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}
+ {{
+ prediction.target_at | date: "dd/MM 'à' HH:mm"
+ }}
} @else {
{{
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss
index 9c89f56..af0dd39 100644
--- a/apps/frontend/src/app/features/dashboard/dashboard.scss
+++ b/apps/frontend/src/app/features/dashboard/dashboard.scss
@@ -108,27 +108,8 @@ h2 {
margin-bottom: 2.5rem;
}
-.alerts-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-.alert-item {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- padding: 0.7rem 1rem;
- border-radius: var(--radius-md);
- background: var(--color-danger-bg);
- border: 1px solid var(--color-danger-border);
-}
-
-.alert-item__message {
- font-size: 0.9rem;
+.alerts-section {
+ margin-bottom: 2.5rem;
}
.predictions-list {
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts
index 910b0f6..a3c52d7 100644
--- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts
+++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts
@@ -1,12 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
-import { of, throwError } from 'rxjs';
+import { Observable, of, throwError } from 'rxjs';
+import { Router, provideRouter } from '@angular/router';
import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service';
+import { SitesService } from '../../core/services/sites.service';
import { PredictionsService } from '../../core/services/predictions.service';
-import {AuthService} from '../../core/services/auth.service';
-import {Router, provideRouter} from '@angular/router';
+import { AuthService } from '../../core/services/auth.service';
vi.mock('chart.js', () => {
class ChartMock {
@@ -18,66 +19,73 @@ vi.mock('chart.js', () => {
return { Chart: ChartMock, registerables: [] };
});
+const STATS = { total_sites: 7, sites: [] };
+
function predictionsMock(sites: unknown[] = []) {
- return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
+ return {
+ getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })),
+ };
+}
+
+function setup(
+ options: {
+ stats?: Observable;
+ predictions?: { getPredictions: ReturnType };
+ auth?: Record;
+ } = {},
+) {
+ const statsMock = { getSummary: vi.fn().mockReturnValue(options.stats ?? of(STATS)) };
+ const predictions = options.predictions ?? predictionsMock();
+ TestBed.configureTestingModule({
+ imports: [Dashboard],
+ providers: [
+ { provide: StatsService, useValue: statsMock },
+ { provide: AlertsService, useValue: { getAlerts: vi.fn().mockReturnValue(of([])) } },
+ { provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of([])) } },
+ { provide: PredictionsService, useValue: predictions },
+ ...(options.auth ? [{ provide: AuthService, useValue: options.auth }] : []),
+ provideRouter([]),
+ ],
+ });
+ return { fixture: TestBed.createComponent(Dashboard), statsMock, predictions };
}
describe('Dashboard', () => {
afterEach(() => vi.useRealTimers());
- it('charge les stats, les alertes et les prévisions 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' }])) };
- const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
-
- TestBed.configureTestingModule({
- imports: [Dashboard],
- providers: [
- { provide: StatsService, useValue: statsMock },
- { provide: AlertsService, useValue: alertsMock },
- { provide: PredictionsService, useValue: predictions },
- provideRouter([]),
- ],
+ it('charge les stats et les prévisions au démarrage', async () => {
+ const { fixture, statsMock, predictions } = setup({
+ predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]),
});
- const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
-
- // laisse le timer(0, ...) se déclencher avant de vérifier
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(statsMock.getSummary).toHaveBeenCalled();
- expect(alertsMock.getAlerts).toHaveBeenCalled();
expect(predictions.getPredictions).toHaveBeenCalled();
- expect(fixture.componentInstance.alerts().length).toBe(1);
expect(fixture.componentInstance.predictions().length).toBe(1);
expect(fixture.componentInstance.statsError()).toBeNull();
- expect(fixture.componentInstance.alertsError()).toBeNull();
expect(fixture.componentInstance.predictionsError()).toBeNull();
});
+ it('délègue les alertes au widget app-alert-feed', () => {
+ const { fixture } = setup();
+
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('app-alert-feed')).not.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 },
- { provide: PredictionsService, useValue: predictionsMock() },
- provideRouter([]),
- ],
+ const { fixture, statsMock } = setup({
+ stats: throwError(() => new Error('API injoignable')),
});
+ statsMock.getSummary
+ .mockReturnValueOnce(throwError(() => new Error('API injoignable')))
+ .mockReturnValue(of(STATS));
- const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
vi.advanceTimersByTime(1);
@@ -91,45 +99,11 @@ describe('Dashboard', () => {
expect(fixture.componentInstance.statsError()).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 },
- { provide: PredictionsService, useValue: predictionsMock() },
- provideRouter([]),
- ],
- });
-
- const fixture = TestBed.createComponent(Dashboard);
- fixture.detectChanges();
-
- expect(fixture.componentInstance.alerts().length).toBe(0);
- expect(fixture.componentInstance.alertsError()).not.toBeNull();
- });
-
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
- const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
- const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
- const predictions = {
- getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
- };
-
- TestBed.configureTestingModule({
- imports: [Dashboard],
- providers: [
- { provide: StatsService, useValue: statsMock },
- { provide: AlertsService, useValue: alertsMock },
- { provide: PredictionsService, useValue: predictions },
- provideRouter([]),
- ],
+ const { fixture } = setup({
+ predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
});
- const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.predictions().length).toBe(0);
@@ -138,25 +112,11 @@ describe('Dashboard', () => {
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
vi.useFakeTimers();
- const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
- const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
- const predictions = {
- getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
- };
-
- TestBed.configureTestingModule({
- imports: [Dashboard],
- providers: [
- { provide: StatsService, useValue: statsMock },
- { provide: AlertsService, useValue: alertsMock },
- { provide: PredictionsService, useValue: predictions },
- provideRouter([]),
- ],
+ const { fixture } = setup({
+ predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
});
- const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
-
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
@@ -168,109 +128,41 @@ describe('Dashboard', () => {
});
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
- const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
- const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
- const authMock = {
- logout: vi.fn().mockReturnValue(of(undefined)),
- clearSession: vi.fn(),
- principal: vi.fn().mockReturnValue({ role: 'admin' }),
- };
- TestBed.configureTestingModule({
- imports: [Dashboard],
- providers: [
- { provide: StatsService, useValue: statsMock },
- { provide: AlertsService, useValue: alertsMock },
- { provide: PredictionsService, useValue: predictionsMock() },
- { provide: AuthService, useValue: authMock },
- provideRouter([]),
- ],
- });
-
- const fixture = TestBed.createComponent(Dashboard);
- fixture.detectChanges();
-
- const router = TestBed.inject(Router);
- const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
-
- const button = fixture.nativeElement.querySelector('.logout-button');
- button.click();
-
- expect(authMock.logout).toHaveBeenCalled();
- expect(navigateSpy).toHaveBeenCalledWith(['/login']);
- });
- it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
- const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
- const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = {
- logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
- clearSession: vi.fn(),
- principal: vi.fn().mockReturnValue({ role: 'admin' }),
- };
- TestBed.configureTestingModule({
- imports: [Dashboard],
- providers: [
- { provide: StatsService, useValue: statsMock },
- { provide: AlertsService, useValue: alertsMock },
- { provide: PredictionsService, useValue: predictionsMock() },
- { provide: AuthService, useValue: authMock },
- provideRouter([]),
- ],
+ logout: vi.fn().mockReturnValue(of(undefined)),
+ clearSession: vi.fn(),
+ principal: vi.fn().mockReturnValue({ role: 'admin' }),
+ };
+ const { fixture } = setup({ auth: authMock });
+ fixture.detectChanges();
+ const router = TestBed.inject(Router);
+ const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+
+ fixture.nativeElement.querySelector('.logout-button').click();
+
+ expect(authMock.logout).toHaveBeenCalled();
+ expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
- const fixture = TestBed.createComponent(Dashboard);
- fixture.detectChanges();
+ it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
+ const authMock = {
+ logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
+ clearSession: vi.fn(),
+ principal: vi.fn().mockReturnValue({ role: 'admin' }),
+ };
+ const { fixture } = setup({ auth: authMock });
+ fixture.detectChanges();
+ const router = TestBed.inject(Router);
+ const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
- const router = TestBed.inject(Router);
- const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ fixture.nativeElement.querySelector('.logout-button').click();
- const button = fixture.nativeElement.querySelector('.logout-button');
- button.click();
-
- expect(authMock.clearSession).toHaveBeenCalled();
- expect(navigateSpy).toHaveBeenCalledWith(['/login']);
-});
-
- it('distingue le ton des sévérités high et critical', () => {
- const statsMock = { getSummary: vi.fn().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 },
- { provide: PredictionsService, useValue: predictionsMock() },
- provideRouter([]),
- ],
- });
-
- const fixture = TestBed.createComponent(Dashboard);
- const dashboard = fixture.componentInstance;
-
- expect(dashboard.badgeToneForSeverity('low')).toBe('success');
- expect(dashboard.badgeToneForSeverity('medium')).toBe('warning');
- expect(dashboard.badgeToneForSeverity('high')).toBe('danger');
- expect(dashboard.badgeToneForSeverity('critical')).toBe('critical');
- expect(dashboard.badgeToneForSeverity('high')).not.toBe(
- dashboard.badgeToneForSeverity('critical'),
- );
+ expect(authMock.clearSession).toHaveBeenCalled();
+ expect(navigateSpy).toHaveBeenCalledWith(['/login']);
});
it('distingue le ton des statuts de prévision', () => {
- const statsMock = { getSummary: vi.fn().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 },
- { provide: PredictionsService, useValue: predictionsMock() },
- provideRouter([]),
- ],
- });
-
- const fixture = TestBed.createComponent(Dashboard);
+ const { fixture } = setup();
const dashboard = fixture.componentInstance;
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts
index 2ba20c0..3d0b422 100644
--- a/apps/frontend/src/app/features/dashboard/dashboard.ts
+++ b/apps/frontend/src/app/features/dashboard/dashboard.ts
@@ -6,11 +6,10 @@ import { Router, RouterLink } from '@angular/router';
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 { AlertFeed } from '../../shared/components/alert-feed/alert-feed';
import { PredictionsService } from '../../core/services/predictions.service';
import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model';
-import { Alert, AlertSeverity } from '../../shared/models/alert.model';
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
import { Card } from '../../shared/components/ui/card/card';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
@@ -22,13 +21,6 @@ const REFRESH_INTERVAL_MS = 10000;
const UNAVAILABLE_MESSAGE =
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
-const TON_PAR_SEVERITE: Record = {
- low: 'success',
- medium: 'warning',
- high: 'danger',
- critical: 'critical',
-};
-
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
// tomber sur `undefined` si ce statut apparaît un jour.
@@ -47,6 +39,7 @@ const TON_PAR_STATUT_PREDICTION: Record = {
RouterLink,
ConsumptionGauge,
SiteLoadChart,
+ AlertFeed,
Card,
EvAlert,
Badge,
@@ -58,32 +51,21 @@ const TON_PAR_STATUT_PREDICTION: Record = {
})
export class Dashboard implements OnInit {
private statsService = inject(StatsService);
- private alertsService = inject(AlertsService);
public auth = inject(AuthService);
private predictionsService = inject(PredictionsService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
stats = signal(null);
- alerts = signal([]);
predictions = signal([]);
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
- // efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
- // sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
+ // efface silencieusement un message d'échec des prévisions après 10s au plus, sans retry ni
+ // indication pour l'utilisateur que la section correspondante est restée vide.
statsError = signal(null);
- alertsError = signal(null);
predictionsError = signal(null);
ngOnInit(): void {
- this.alertsService
- .getAlerts()
- .pipe(catchError(() => this.reportUnavailable(this.alertsError)))
- .subscribe((alerts) => {
- this.alertsError.set(null);
- this.alerts.set(alerts);
- });
-
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
this.predictionsService
@@ -99,7 +81,9 @@ export class Dashboard implements OnInit {
timer(0, REFRESH_INTERVAL_MS)
.pipe(
switchMap(() =>
- this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
+ this.statsService
+ .getSummary()
+ .pipe(catchError(() => this.reportUnavailable(this.statsError))),
),
takeUntilDestroyed(this.destroyRef),
)
@@ -109,10 +93,6 @@ export class Dashboard implements OnInit {
});
}
- badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
- return TON_PAR_SEVERITE[severity];
- }
-
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
return TON_PAR_STATUT_PREDICTION[status];
}