fix(frontend): aligne le modèle Alert et AlertsService sur le contrat /alerts

Le modèle front portait un alert_id texte, des value/threshold non nullables et
ignorait metric et prediction_id, alors que l'API sérialise un entier, des
flottants nullables et ces deux champs. Toute comparaison avec l'alert_id d'une
recommandation échouait silencieusement.

- alert.model.ts : alert_id number, value/threshold/metric/prediction_id nullables
- alerts.service.ts : getAlerts(filters) pose site_id et severity en HttpParams,
  les deux filtres que l'API accepte
- alerts.fixture.ts : réaligné sur le contrat (ids entiers, horodatages UTC,
  champs nuls sur outage/sensor, un cas spike)
- alert-presentation.ts : tons, libellés et unités partagés ; low passe en
  neutre, le vert se lisait comme un état sain
This commit is contained in:
Johan LEROY
2026-09-21 14:25:30 +02:00
parent 3c378c177f
commit 91752a2b0b
6 changed files with 177 additions and 51 deletions
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
export const ALERTS_FIXTURE: Alert[] = [
{
alert_id: 'ALR-SITE002-1718458320',
timestamp: '2026-09-15T11:12:00',
alert_id: 5,
site_id: 'SITE002',
timestamp: '2026-09-15T11:12:00Z',
type: 'threshold',
severity: 'critical',
type: 'outage',
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 'ALR-SITE003-1718458321',
timestamp: '2026-09-15T11:05:00',
alert_id: 4,
site_id: 'SITE003',
timestamp: '2026-09-15T11:05:00Z',
type: 'outage',
severity: 'critical',
type: 'sensor',
message: 'Perte réseau totale sur Data Center Marseille',
value: 0,
threshold: 0,
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
value: null,
threshold: null,
metric: null,
prediction_id: null,
},
{
alert_id: 'ALR-SITE005-1718458322',
timestamp: '2026-09-15T10:47:00',
alert_id: 3,
site_id: 'SITE005',
timestamp: '2026-09-15T10:47:00Z',
type: 'spike',
severity: 'high',
type: 'threshold',
message: 'Usine Toulouse approche de son seuil de capacité',
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
value: 410.0,
threshold: 480.0,
threshold: 260.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 'ALR-SITE006-1718458323',
timestamp: '2026-09-15T10:30:00',
alert_id: 2,
site_id: 'SITE006',
severity: 'medium',
timestamp: '2026-09-15T10:30:00Z',
type: 'sensor',
message: 'Capteur de température défaillant sur Bureau Lille',
value: 0,
threshold: 0,
severity: 'medium',
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
value: null,
threshold: null,
metric: null,
prediction_id: null,
},
{
alert_id: 'ALR-SITE004-1718458324',
timestamp: '2026-09-15T09:58:00',
alert_id: 1,
site_id: 'SITE004',
severity: 'low',
timestamp: '2026-09-15T09:58:00Z',
type: 'anomaly',
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
severity: 'low',
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
value: 62.0,
threshold: 55.0,
metric: 'consumption_kwh',
prediction_id: 42,
},
];
@@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AlertsService } from './alerts.service';
import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model';
const ALERT_API: Alert = {
alert_id: 1,
site_id: 'site-1',
timestamp: '2026-09-16T00:00:00Z',
type: 'threshold',
severity: 'high',
message: 'Dépassement du seuil configuré',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
};
describe('AlertsService', () => {
let service: AlertsService;
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
afterEach(() => httpMock.verify());
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
let result: unknown;
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
let result: Alert[] = [];
service.getAlerts().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
expect(req.request.method).toBe('GET');
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
);
expect(req.request.params.keys()).toEqual([]);
req.flush([ALERT_API]);
req.flush([
{
alert_id: 'ALR-TEST-1',
timestamp: '2026-09-15T12:00:00',
site_id: 'SITE001',
severity: 'high',
type: 'threshold',
message: 'Test',
value: 100,
threshold: 90,
},
]);
expect(result.length).toBe(1);
expect(result[0].alert_id).toBe(1);
expect(result[0].prediction_id).toBeNull();
});
expect((result as unknown[]).length).toBe(1);
it('transmet les filtres site_id et severity en paramètres de requête', () => {
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.params.get('severity')).toBe('high');
req.flush([]);
});
it('ne pose pas de paramètre pour un filtre omis', () => {
service.getAlerts({ site_id: 'SITE001' }).subscribe();
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
expect(req.request.params.has('severity')).toBe(false);
req.flush([]);
});
});
@@ -1,13 +1,25 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
export interface AlertFilters {
site_id?: string;
severity?: AlertSeverity;
}
@Service()
export class AlertsService {
private http = inject(HttpClient);
getAlerts() {
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
getAlerts(filters: AlertFilters = {}) {
let params = new HttpParams();
if (filters.site_id) {
params = params.set('site_id', filters.site_id);
}
if (filters.severity) {
params = params.set('severity', filters.severity);
}
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
}
}
@@ -0,0 +1,37 @@
import {
LIBELLE_PAR_SEVERITE,
LIBELLE_PAR_TYPE,
SEVERITES,
TON_PAR_SEVERITE,
TYPES_ALERTE,
UNITE_PAR_METRIQUE,
} from './alert-presentation';
describe('alert-presentation', () => {
it('distingue le ton des sévérités high et critical', () => {
expect(TON_PAR_SEVERITE.high).toBe('danger');
expect(TON_PAR_SEVERITE.critical).toBe('critical');
expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical);
});
it("n'affiche pas une alerte faible avec le ton de succès", () => {
expect(TON_PAR_SEVERITE.low).toBe('neutral');
expect(TON_PAR_SEVERITE.medium).toBe('warning');
});
it('donne un libellé français à chaque sévérité et à chaque type', () => {
for (const severite of SEVERITES) {
expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy();
}
for (const type of TYPES_ALERTE) {
expect(LIBELLE_PAR_TYPE[type]).toBeTruthy();
}
expect(SEVERITES.length).toBe(4);
expect(TYPES_ALERTE.length).toBe(5);
});
it('associe une unité à chaque métrique du contrat', () => {
expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW');
expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh');
});
});
@@ -0,0 +1,41 @@
import { BadgeTone } from '../components/ui/badge/badge';
import { AlertMetric, AlertSeverity, AlertType } from './alert.model';
// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se
// lisait comme « tout va bien » à côté des rouges.
export const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
low: 'neutral',
medium: 'warning',
high: 'danger',
critical: 'critical',
};
export const LIBELLE_PAR_SEVERITE: Record<AlertSeverity, string> = {
low: 'Faible',
medium: 'Moyenne',
high: 'Élevée',
critical: 'Critique',
};
export const LIBELLE_PAR_TYPE: Record<AlertType, string> = {
spike: 'Pic de consommation',
threshold: 'Seuil dépassé',
anomaly: 'Anomalie',
outage: 'Coupure',
sensor: 'Capteur',
};
export const UNITE_PAR_METRIQUE: Record<AlertMetric, string> = {
consumption_kw: 'kW',
consumption_kwh: 'kWh',
};
export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical'];
export const TYPES_ALERTE: readonly AlertType[] = [
'spike',
'threshold',
'anomaly',
'outage',
'sensor',
];
@@ -1,13 +1,16 @@
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
export interface Alert {
alert_id: string;
timestamp: string;
alert_id: number;
site_id: string;
severity: AlertSeverity;
timestamp: string;
type: AlertType;
severity: AlertSeverity;
message: string;
value: number;
threshold: number;
value: number | null;
threshold: number | null;
metric: AlertMetric | null;
prediction_id: number | null;
}