fix(ml,backend,frontend): borne la peremption des predictions et isole les erreurs par flux
This commit is contained in:
@@ -1,99 +0,0 @@
|
||||
import { PredictionSummary } from '../../shared/models/prediction.model';
|
||||
|
||||
export const PREDICTIONS_FIXTURE: PredictionSummary = {
|
||||
timestamp: '2026-09-18T09:00:00Z',
|
||||
sites: [
|
||||
{
|
||||
site_id: 'SITE001',
|
||||
site_name: 'Bureau Paris La Défense',
|
||||
prediction: {
|
||||
target_at: '2026-09-18T10:00:00Z',
|
||||
target_metric: 'consumption_kwh',
|
||||
period_minutes: 60,
|
||||
predicted_value: 89.2,
|
||||
status: 'available',
|
||||
failure_reason: null,
|
||||
model_reference: 'lightgbm-16b431449a50',
|
||||
created_at: '2026-09-18T09:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
site_id: 'SITE002',
|
||||
site_name: 'Usine Lyon Vénissieux',
|
||||
prediction: {
|
||||
target_at: '2026-09-18T10:00:00Z',
|
||||
target_metric: 'consumption_kwh',
|
||||
period_minutes: 60,
|
||||
predicted_value: 561.4,
|
||||
status: 'available',
|
||||
failure_reason: null,
|
||||
model_reference: 'lightgbm-16b431449a50',
|
||||
created_at: '2026-09-18T09:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
site_id: 'SITE003',
|
||||
site_name: 'Data Center Marseille',
|
||||
prediction: {
|
||||
target_at: '2026-09-18T10:00:00Z',
|
||||
target_metric: 'consumption_kwh',
|
||||
period_minutes: 60,
|
||||
predicted_value: null,
|
||||
status: 'insufficient_data',
|
||||
failure_reason:
|
||||
"Historique insuffisant : moins de 168h de consumption_kwh disponibles pour ce site.",
|
||||
model_reference: 'lightgbm-16b431449a50',
|
||||
created_at: '2026-09-18T09:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
site_id: 'SITE004',
|
||||
site_name: 'Bureau Bordeaux',
|
||||
prediction: {
|
||||
target_at: '2026-09-18T10:00:00Z',
|
||||
target_metric: 'consumption_kwh',
|
||||
period_minutes: 60,
|
||||
predicted_value: 58.9,
|
||||
status: 'available',
|
||||
failure_reason: null,
|
||||
model_reference: 'lightgbm-16b431449a50',
|
||||
created_at: '2026-09-18T09:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
site_id: 'SITE005',
|
||||
site_name: 'Usine Toulouse',
|
||||
prediction: {
|
||||
target_at: '2026-09-18T10:00:00Z',
|
||||
target_metric: 'consumption_kwh',
|
||||
period_minutes: 60,
|
||||
predicted_value: 402.7,
|
||||
status: 'available',
|
||||
failure_reason: null,
|
||||
model_reference: 'lightgbm-16b431449a50',
|
||||
created_at: '2026-09-18T09:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
site_id: 'SITE006',
|
||||
site_name: 'Bureau Lille',
|
||||
prediction: {
|
||||
target_at: '2026-09-18T10:00:00Z',
|
||||
target_metric: 'consumption_kwh',
|
||||
period_minutes: 60,
|
||||
predicted_value: 91.3,
|
||||
status: 'available',
|
||||
failure_reason: null,
|
||||
model_reference: 'lightgbm-16b431449a50',
|
||||
created_at: '2026-09-18T09:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Illustre le cas d'un site jamais scoré : `prediction` reste `null`, pas un statut inventé
|
||||
// (même contrat que `PredictionService.summary()` côté backend).
|
||||
site_id: 'SITE007',
|
||||
site_name: 'Data Center Nantes',
|
||||
prediction: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -21,7 +21,13 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error(); as message) {
|
||||
@if (statsError(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
@if (alertsError(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
@if (predictionsError(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
@@ -85,7 +91,7 @@
|
||||
<span class="prediction-item__value">
|
||||
{{ prediction.predicted_value | number: '1.0-1' }} kWh
|
||||
<span class="prediction-item__target"
|
||||
>à {{ prediction.target_at | date: 'HH:mm' }}</span
|
||||
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
|
||||
>
|
||||
</span>
|
||||
} @else {
|
||||
|
||||
@@ -52,7 +52,9 @@ describe('Dashboard', () => {
|
||||
expect(predictions.getPredictions).toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||
expect(fixture.componentInstance.predictions().length).toBe(1);
|
||||
expect(fixture.componentInstance.error()).toBeNull();
|
||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||
expect(fixture.componentInstance.alertsError()).toBeNull();
|
||||
expect(fixture.componentInstance.predictionsError()).toBeNull();
|
||||
});
|
||||
|
||||
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
||||
@@ -80,13 +82,13 @@ describe('Dashboard', () => {
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(statsMock.getSummary).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||
expect(fixture.componentInstance.statsError()).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();
|
||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||
});
|
||||
|
||||
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
|
||||
@@ -107,6 +109,7 @@ describe('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", () => {
|
||||
@@ -130,7 +133,38 @@ describe('Dashboard', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.predictions().length).toBe(0);
|
||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
||||
});
|
||||
|
||||
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 = 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
|
||||
// toujours être visible : rien ne vient la rafraîchir tant que la section n'est pas rechargée.
|
||||
vi.advanceTimersByTime(30000);
|
||||
|
||||
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||
});
|
||||
|
||||
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, inject, signal, DestroyRef, WritableSignal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
|
||||
import { DecimalPipe, DatePipe } from '@angular/common';
|
||||
@@ -67,32 +67,44 @@ export class Dashboard implements OnInit {
|
||||
stats = signal<StatsSummary | null>(null);
|
||||
alerts = signal<Alert[]>([]);
|
||||
predictions = signal<SitePredictionSummary[]>([]);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
// 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.
|
||||
statsError = signal<string | null>(null);
|
||||
alertsError = signal<string | null>(null);
|
||||
predictionsError = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.alertsService
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => this.reportUnavailable()))
|
||||
.subscribe((alerts) => this.alerts.set(alerts));
|
||||
.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
|
||||
.getPredictions()
|
||||
.pipe(catchError(() => this.reportUnavailable()))
|
||||
.subscribe((summary) => this.predictions.set(summary.sites));
|
||||
.pipe(catchError(() => this.reportUnavailable(this.predictionsError)))
|
||||
.subscribe((summary) => {
|
||||
this.predictionsError.set(null);
|
||||
this.predictions.set(summary.sites);
|
||||
});
|
||||
|
||||
// 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().pipe(catchError(() => this.reportUnavailable())),
|
||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((stats) => {
|
||||
this.error.set(null);
|
||||
this.statsError.set(null);
|
||||
this.stats.set(stats);
|
||||
});
|
||||
}
|
||||
@@ -116,8 +128,8 @@ export class Dashboard implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private reportUnavailable(): Observable<never> {
|
||||
this.error.set(UNAVAILABLE_MESSAGE);
|
||||
private reportUnavailable(target: WritableSignal<string | null>): Observable<never> {
|
||||
target.set(UNAVAILABLE_MESSAGE);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user