From d9229e5a936c65d192c81398819d0ddbe03e4d0f Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 18 Sep 2026 11:51:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20affiche=20les=20pr=C3=A9visio?= =?UTF-8?q?ns=20de=20consommation=20sur=20le=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interceptors/mock-api-interceptor.spec.ts | 9 ++ .../core/interceptors/mock-api-interceptor.ts | 2 + .../src/app/core/mocks/predictions.fixture.ts | 99 +++++++++++++++++++ .../core/services/predictions.service.spec.ts | 35 +++++++ .../app/core/services/predictions.service.ts | 13 +++ .../src/app/features/dashboard/dashboard.html | 29 ++++++ .../src/app/features/dashboard/dashboard.scss | 37 +++++++ .../app/features/dashboard/dashboard.spec.ts | 62 +++++++++++- .../src/app/features/dashboard/dashboard.ts | 27 ++++- .../src/app/shared/models/prediction.model.ts | 24 +++++ docs/architecture/30-frontend.md | 47 ++++----- 11 files changed, 360 insertions(+), 24 deletions(-) create mode 100644 apps/frontend/src/app/core/mocks/predictions.fixture.ts create mode 100644 apps/frontend/src/app/core/services/predictions.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/predictions.service.ts create mode 100644 apps/frontend/src/app/shared/models/prediction.model.ts diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts index 5d6e343..4483313 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -64,4 +64,13 @@ describe('mockApiInterceptor', () => { httpMock.expectNone(`${environment.apiUrl}/alerts`); expect((result as unknown[]).length).toBeGreaterThan(0); }); + + it('laisse toujours passer /predictions vers le réseau, même avec useMockFixtures activé', () => { + environment.useMockFixtures = true; + + http.get(`${environment.apiUrl}/predictions`).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/predictions`); + req.flush({ timestamp: '2026-09-18T09:00:00Z', sites: [] }); + }); }); diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts index 58287ff..7c48936 100644 --- a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts @@ -26,5 +26,7 @@ export const mockApiInterceptor: HttpInterceptorFn = (req, next) => { if (req.url.endsWith(`${environment.apiUrl}/alerts`)) { return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE })); } + // Volontairement jamais mocké, contrairement à `stats`/`alerts` : les prévisions sont servies + // par l'API réelle dès maintenant (au même titre que `/auth/*`, déjà toujours réel). return next(req); }; diff --git a/apps/frontend/src/app/core/mocks/predictions.fixture.ts b/apps/frontend/src/app/core/mocks/predictions.fixture.ts new file mode 100644 index 0000000..5397e27 --- /dev/null +++ b/apps/frontend/src/app/core/mocks/predictions.fixture.ts @@ -0,0 +1,99 @@ +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, + }, + ], +}; diff --git a/apps/frontend/src/app/core/services/predictions.service.spec.ts b/apps/frontend/src/app/core/services/predictions.service.spec.ts new file mode 100644 index 0000000..b3aaf7e --- /dev/null +++ b/apps/frontend/src/app/core/services/predictions.service.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { PredictionsService } from './predictions.service'; +import { environment } from '../../../environments/environment'; + +describe('PredictionsService', () => { + let service: PredictionsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(PredictionsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('appelle le bon endpoint et retourne un résumé de prévisions', () => { + let result: unknown; + service.getPredictions().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/predictions`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-18T09:00:00Z', + sites: [{ site_id: 'SITE001', site_name: 'Test', prediction: null }], + }); + + expect((result as { sites: unknown[] }).sites.length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/core/services/predictions.service.ts b/apps/frontend/src/app/core/services/predictions.service.ts new file mode 100644 index 0000000..4245f7e --- /dev/null +++ b/apps/frontend/src/app/core/services/predictions.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { PredictionSummary } from '../../shared/models/prediction.model'; + +@Service() +export class PredictionsService { + private http = inject(HttpClient); + + getPredictions() { + return this.http.get(`${environment.apiUrl}/predictions`); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 08b015e..d3c5a87 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -72,4 +72,33 @@ } + + @if (predictions().length > 0) { +
+

Prévisions de consommation

+
    + @for (site of predictions(); track site.site_id) { +
  • + {{ site.site_name }} + @if (site.prediction; as prediction) { + @if (prediction.status === 'available') { + + {{ prediction.predicted_value | number: '1.0-1' }} kWh + à {{ prediction.target_at | date: 'HH:mm' }} + + } @else { + {{ + prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur' + }} + } + } @else { + Pas encore de prévision + } +
  • + } +
+
+ } diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 06adf01..8112b51 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -121,3 +121,40 @@ h2 { .alert-item__message { font-size: 0.9rem; } + +.predictions-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.prediction-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem 1rem; + border-radius: var(--radius-md); + background: var(--color-surface); + border: 1px solid var(--color-border-light); +} + +.prediction-item__site { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__value { + font-size: 0.9rem; + font-weight: 600; +} + +.prediction-item__target { + margin-left: 0.35rem; + font-size: 0.8rem; + font-weight: 400; + color: var(--color-text-muted); +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 1f285b3..120ff25 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs'; import { Dashboard } from './dashboard'; import { StatsService } from '../../core/services/stats.service'; import { AlertsService } from '../../core/services/alerts.service'; +import { PredictionsService } from '../../core/services/predictions.service'; import {AuthService} from '../../core/services/auth.service'; import {Router, provideRouter} from '@angular/router'; @@ -17,18 +18,24 @@ vi.mock('chart.js', () => { return { Chart: ChartMock, registerables: [] }; }); +function predictionsMock(sites: unknown[] = []) { + return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) }; +} + describe('Dashboard', () => { afterEach(() => vi.useRealTimers()); - it('charge les stats et les alertes au démarrage', async () => { + 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([]), ], }); @@ -42,7 +49,9 @@ describe('Dashboard', () => { 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.error()).toBeNull(); }); @@ -61,6 +70,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -88,6 +98,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -98,6 +109,30 @@ describe('Dashboard', () => { expect(fixture.componentInstance.alerts().length).toBe(0); }); + 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 = TestBed.createComponent(Dashboard); + fixture.detectChanges(); + + expect(fixture.componentInstance.predictions().length).toBe(0); + expect(fixture.componentInstance.error()).not.toBeNull(); + }); + 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([])) }; @@ -108,6 +143,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, { provide: AuthService, useValue: authMock }, provideRouter([]), ], @@ -137,6 +173,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, { provide: AuthService, useValue: authMock }, provideRouter([]), ], @@ -164,6 +201,7 @@ describe('Dashboard', () => { providers: [ { provide: StatsService, useValue: statsMock }, { provide: AlertsService, useValue: alertsMock }, + { provide: PredictionsService, useValue: predictionsMock() }, provideRouter([]), ], }); @@ -179,4 +217,26 @@ describe('Dashboard', () => { dashboard.badgeToneForSeverity('critical'), ); }); + + 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 dashboard = fixture.componentInstance; + + expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success'); + expect(dashboard.badgeToneForPredictionStatus('insufficient_data')).toBe('warning'); + expect(dashboard.badgeToneForPredictionStatus('error')).toBe('danger'); + }); }); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index e9f9792..d41d258 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, catchError, EMPTY, Observable } from 'rxjs'; -import { DecimalPipe } from '@angular/common'; +import { DecimalPipe, DatePipe } from '@angular/common'; 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 { 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'; import { Badge, BadgeTone } from '../../shared/components/ui/badge/badge'; @@ -27,11 +29,21 @@ const TON_PAR_SEVERITE: Record = { 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. +const TON_PAR_STATUT_PREDICTION: Record = { + available: 'success', + insufficient_data: 'warning', + error: 'danger', +}; + @Component({ selector: 'app-dashboard', standalone: true, imports: [ DecimalPipe, + DatePipe, RouterLink, ConsumptionGauge, SiteLoadChart, @@ -47,12 +59,14 @@ const TON_PAR_SEVERITE: Record = { export class Dashboard implements OnInit { private statsService = inject(StatsService); private alertsService = inject(AlertsService); + private predictionsService = inject(PredictionsService); private auth = inject(AuthService); private router = inject(Router); private destroyRef = inject(DestroyRef); stats = signal(null); alerts = signal([]); + predictions = signal([]); error = signal(null); ngOnInit(): void { @@ -61,6 +75,13 @@ export class Dashboard implements OnInit { .pipe(catchError(() => this.reportUnavailable())) .subscribe((alerts) => 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)); + // 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) @@ -80,6 +101,10 @@ export class Dashboard implements OnInit { return TON_PAR_SEVERITE[severity]; } + badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone { + return TON_PAR_STATUT_PREDICTION[status]; + } + onLogout(): void { this.auth.logout().subscribe({ next: () => this.router.navigate(['/login']), diff --git a/apps/frontend/src/app/shared/models/prediction.model.ts b/apps/frontend/src/app/shared/models/prediction.model.ts new file mode 100644 index 0000000..50d726a --- /dev/null +++ b/apps/frontend/src/app/shared/models/prediction.model.ts @@ -0,0 +1,24 @@ +export type PredictionStatus = 'available' | 'insufficient_data' | 'error'; +export type PredictionTargetMetric = 'consumption_kwh' | 'consumption_kw'; + +export interface SitePrediction { + target_at: string; + target_metric: PredictionTargetMetric; + period_minutes: number | null; + predicted_value: number | null; + status: PredictionStatus; + failure_reason: string | null; + model_reference: string; + created_at: string; +} + +export interface SitePredictionSummary { + site_id: string; + site_name: string; + prediction: SitePrediction | null; +} + +export interface PredictionSummary { + timestamp: string; + sites: SitePredictionSummary[]; +} diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index de73599..b7f6d10 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -13,24 +13,28 @@ Ce qui est en place : - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et `provideHttpClient(withInterceptors([mockApiInterceptor]))`. - Une route `/dashboard` en composant différé, et une redirection depuis la racine. -- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de - fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le +- `core/services` porte `StatsService`, `AlertsService`, `PredictionsService`, `SitesService` et + `AuthService`, `core/interceptors` l'intercepteur de fixtures et l'intercepteur d'authentification + (jeton porteur, rafraîchissement sur 401), `core/guards` la garde de route `authGuard`, + `features/dashboard` la page principale, `shared/components` la jauge de consommation et le graphique de charge par site, tous deux construits sur Chart.js. +- Une authentification complète côté interface : connexion, mot de passe oublié/réinitialisation, + changement de mot de passe, garde de route sur `/dashboard` et `/sites`. Détail : + [31-contrat-authentification.md](31-contrat-authentification.md). - Un système de design partagé (`shared/components/ui/` : `ev-button`, `ev-card`, `ev-alert`, `ev-badge`, `ev-brand`, tokens CSS dans `styles/_tokens.scss`) que toute nouvelle page doit réutiliser plutôt que redéfinir ses propres styles. Détail : [32-design-systeme-frontend.md](32-design-systeme-frontend.md). - L'état vit dans des signaux, sans bibliothèque dédiée. -- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test. +- Vitest via le builder `@angular/build:unit-test`, couverture activée. - Prettier configuré, parser `angular` pour les gabarits HTML. Ce qui n'existe pas encore : -- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts` - sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre. -- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que - les routes métier de l'API en exigent un. Voir - [31-contrat-authentification.md](31-contrat-authentification.md). +- **Aucun endpoint métier réel derrière l'écran du tableau de bord.** `GET /api/v1/stats/summary`, + `GET /api/v1/alerts` et `GET /api/v1/predictions` sont servis par l'intercepteur de fixtures ; + l'API expose bien ces routes désormais, mais rien ne bascule `useMockFixtures` à `false` en + développement pour les consommer réellement. - Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide. - Aucun lint : ESLint n'est pas installé. @@ -81,22 +85,20 @@ sequenceDiagram S-->>C: modèle typé ``` -`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si -`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en -production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel. +`mockApiInterceptor` n'intercepte que `/stats/summary`, `/alerts` et `/predictions`, et seulement +si `environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en +production : toute autre requête (dont tout ce qui touche `/auth`), et toutes les requêtes en +production, suivent le chemin réel. En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un `apiUrl` relatif, `/api/v1`. -En production, il n'y a pas de proxy : `environment.ts` porte une URL absolue. Angular substitue -le fichier via `fileReplacements`, et la configuration `production` est celle par défaut. - -**Dette connue.** `src/environments/environment.ts`, qui est la configuration de production, -pointe `http://localhost:8000/api/v1` en dur. La valeur est celle du poste de développement : -telle quelle, un build de production ne joindra jamais l'API. À corriger avant le premier -déploiement, en même temps que sera tranchée la question de l'ingress dans -[10-infra.md](10-infra.md). +En production, il n'y a pas de proxy, mais `environment.ts` porte lui aussi un `apiUrl` relatif +(`/api/v1`) plutôt qu'une URL absolue : la dette qui pointait en dur sur +`http://localhost:8000/api/v1` a été corrigée. Un build de production sert donc l'appel `/api/v1/...` +sur son propre origin, ce qui suppose qu'un ingress ou un reverse proxy route `/api` vers le +backend une fois déployé — question toujours ouverte dans [10-infra.md](10-infra.md). ## Exécution @@ -124,9 +126,10 @@ avec un service statique, il reste à écrire. ## Sécurité - Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. -- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun - intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui - que les fixtures. +- L'authentification existe des deux côtés désormais : `authGuard` protège `/dashboard` et + `/sites`, `authInterceptor` pose le jeton porteur sur les requêtes sortantes et déclenche le + rafraîchissement sur 401. Détail complet dans + [31-contrat-authentification.md](31-contrat-authentification.md). ## Tests