From cdef30736a6e3090ce5bc64254d08ec10b6ce02c Mon Sep 17 00:00:00 2001 From: valentin Date: Tue, 15 Sep 2026 16:48:55 +0200 Subject: [PATCH 1/6] Creation dashboard (graph chart.js) + tests --- apps/frontend/.gitignore | 1 + apps/frontend/package-lock.json | 19 + apps/frontend/package.json | 1 + apps/frontend/src/app/app.config.ts | 7 +- apps/frontend/src/app/app.html | 354 +----------------- apps/frontend/src/app/app.routes.ts | 5 +- apps/frontend/src/app/app.spec.ts | 7 - .../interceptors/mock-api-interceptor.spec.ts | 67 ++++ .../core/interceptors/mock-api-interceptor.ts | 30 ++ .../src/app/core/mocks/alerts.fixture.ts | 54 +++ .../app/core/mocks/stats-summary.fixture.ts | 67 ++++ .../app/core/services/alerts.service.spec.ts | 43 +++ .../src/app/core/services/alerts.service.ts | 13 + .../app/core/services/stats.service.spec.ts | 39 ++ .../src/app/core/services/stats.service.ts | 13 + .../src/app/features/dashboard/dashboard.html | 48 +++ .../src/app/features/dashboard/dashboard.scss | 130 +++++++ .../app/features/dashboard/dashboard.spec.ts | 41 ++ .../src/app/features/dashboard/dashboard.ts | 39 ++ .../consumption-gauge/consumption-gauge.html | 1 + .../consumption-gauge/consumption-gauge.scss | 6 + .../consumption-gauge.spec.ts | 34 ++ .../consumption-gauge/consumption-gauge.ts | 55 +++ .../site-load-chart/site-load-chart.html | 1 + .../site-load-chart/site-load-chart.scss | 4 + .../site-load-chart/site-load-chart.spec.ts | 38 ++ .../site-load-chart/site-load-chart.ts | 62 +++ .../src/app/shared/models/alert.model.ts | 13 + .../src/app/shared/models/stats.model.ts | 17 + .../environments/environment.development.ts | 3 +- apps/frontend/src/environments/environment.ts | 3 +- 31 files changed, 851 insertions(+), 364 deletions(-) create mode 100644 apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts create mode 100644 apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts create mode 100644 apps/frontend/src/app/core/mocks/alerts.fixture.ts create mode 100644 apps/frontend/src/app/core/mocks/stats-summary.fixture.ts create mode 100644 apps/frontend/src/app/core/services/alerts.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/alerts.service.ts create mode 100644 apps/frontend/src/app/core/services/stats.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/stats.service.ts create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.html create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.scss create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.spec.ts create mode 100644 apps/frontend/src/app/features/dashboard/dashboard.ts create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts create mode 100644 apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts create mode 100644 apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts create mode 100644 apps/frontend/src/app/shared/models/alert.model.ts create mode 100644 apps/frontend/src/app/shared/models/stats.model.ts diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index 854acd5..e1b6f74 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -34,6 +34,7 @@ yarn-error.log .sass-cache/ /connect.lock /coverage +/test-results /libpeerconnection.log testem.log /typings diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 5ba6595..a60cacb 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -14,6 +14,7 @@ "@angular/forms": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", + "chart.js": "^4.5.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -2038,6 +2039,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz", @@ -4220,6 +4227,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 552e346..1c934bc 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -18,6 +18,7 @@ "@angular/forms": "^22.1.0", "@angular/platform-browser": "^22.1.0", "@angular/router": "^22.1.0", + "chart.js": "^4.5.1", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 2261369..d89a118 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,7 +1,12 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import {mockApiInterceptor} from './core/interceptors/mock-api-interceptor'; +import {provideHttpClient, withInterceptors} from '@angular/common/http'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], + providers: [ + provideBrowserGlobalErrorListeners(), provideRouter(routes), + provideHttpClient(withInterceptors([mockApiInterceptor])), + ], }; diff --git a/apps/frontend/src/app/app.html b/apps/frontend/src/app/app.html index 4f4ddf5..0680b43 100644 --- a/apps/frontend/src/app/app.html +++ b/apps/frontend/src/app/app.html @@ -1,353 +1 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for ( - item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { - title: 'Prompt and best practices for AI', - link: 'https://angular.dev/ai/develop-with-ai', - }, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { - title: 'Angular Language Service', - link: 'https://angular.dev/tools/language-service', - }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; - track item.title - ) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - - - + diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index dc39edb..9852e8b 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,3 +1,6 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { path: 'dashboard', loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard) }, +]; diff --git a/apps/frontend/src/app/app.spec.ts b/apps/frontend/src/app/app.spec.ts index f13c264..75753d6 100644 --- a/apps/frontend/src/app/app.spec.ts +++ b/apps/frontend/src/app/app.spec.ts @@ -13,11 +13,4 @@ describe('App', () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); - - it('should render title', async () => { - const fixture = TestBed.createComponent(App); - await fixture.whenStable(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); - }); }); 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 new file mode 100644 index 0000000..34a58b2 --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.spec.ts @@ -0,0 +1,67 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { mockApiInterceptor } from './mock-api-interceptor'; +import { environment } from '../../../environments/environment'; +import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture'; + +describe('mockApiInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([mockApiInterceptor])), + provideHttpClientTesting(), + ], + }); + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + environment.useMockFixtures = true; + httpMock.verify(); + }); + + it('renvoie la fixture sans appel réseau quand useMockFixtures est activé', () => { + environment.useMockFixtures = true; + let result: unknown; + + http.get(`${environment.apiUrl}/stats/summary`).subscribe((r) => (result = r)); + + httpMock.expectNone(`${environment.apiUrl}/stats/summary`); + expect((result as typeof STATS_SUMMARY_FIXTURE).total_sites).toBe( + STATS_SUMMARY_FIXTURE.total_sites + ); + }); + + it('laisse passer la vraie requête quand useMockFixtures est désactivé', () => { + environment.useMockFixtures = false; + + http.get(`${environment.apiUrl}/stats/summary`).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`); + req.flush({}); + }); + + it("laisse passer une requête qui ne correspond à aucune route connue de l'interceptor", () => { + environment.useMockFixtures = true; + + http.get('/api/v1/autre-chose').subscribe(); + + const req = httpMock.expectOne('/api/v1/autre-chose'); + req.flush({}); + }); + + it('renvoie la fixture des alertes sans appel réseau quand useMockFixtures est activé', () => { + environment.useMockFixtures = true; + let result: unknown; + + http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r)); + + httpMock.expectNone(`${environment.apiUrl}/alerts`); + expect((result as unknown[]).length).toBeGreaterThan(0); +}); +}); diff --git a/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts new file mode 100644 index 0000000..58287ff --- /dev/null +++ b/apps/frontend/src/app/core/interceptors/mock-api-interceptor.ts @@ -0,0 +1,30 @@ +import { HttpInterceptorFn, HttpResponse } from '@angular/common/http'; +import { of } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture'; +import { ALERTS_FIXTURE } from '../mocks/alerts.fixture'; + +function withJitter(base: typeof STATS_SUMMARY_FIXTURE) { + const jitter = () => (Math.random() - 0.5) * 40; + const totalConsumption = Math.max(0, base.total_consumption_kw + jitter()); + + return { + ...base, + timestamp: new Date().toISOString(), + total_consumption_kw: Math.round(totalConsumption * 100) / 100, + average_load_percent: Math.round((totalConsumption / base.total_capacity_kw) * 1000) / 10, + }; +} + +export const mockApiInterceptor: HttpInterceptorFn = (req, next) => { + if (!environment.useMockFixtures) { + return next(req); + } + if (req.url.endsWith(`${environment.apiUrl}/stats/summary`)) { + return of(new HttpResponse({ status: 200, body: withJitter(STATS_SUMMARY_FIXTURE) })); + } + if (req.url.endsWith(`${environment.apiUrl}/alerts`)) { + return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE })); + } + return next(req); +}; diff --git a/apps/frontend/src/app/core/mocks/alerts.fixture.ts b/apps/frontend/src/app/core/mocks/alerts.fixture.ts new file mode 100644 index 0000000..c1f7a9a --- /dev/null +++ b/apps/frontend/src/app/core/mocks/alerts.fixture.ts @@ -0,0 +1,54 @@ +import { Alert } from '../../shared/models/alert.model'; + +export const ALERTS_FIXTURE: Alert[] = [ + { + alert_id: 'ALR-SITE002-1718458320', + timestamp: '2026-09-15T11:12:00', + site_id: 'SITE002', + severity: 'critical', + type: 'outage', + message: 'Risque de surcharge sur Usine Lyon Vénissieux', + value: 812.5, + threshold: 720.0, + }, + { + alert_id: 'ALR-SITE003-1718458321', + timestamp: '2026-09-15T11:05:00', + site_id: 'SITE003', + severity: 'critical', + type: 'sensor', + message: 'Perte réseau totale sur Data Center Marseille', + value: 0, + threshold: 0, + }, + { + alert_id: 'ALR-SITE005-1718458322', + timestamp: '2026-09-15T10:47:00', + site_id: 'SITE005', + severity: 'high', + type: 'threshold', + message: 'Usine Toulouse approche de son seuil de capacité', + value: 410.0, + threshold: 480.0, + }, + { + alert_id: 'ALR-SITE006-1718458323', + timestamp: '2026-09-15T10:30:00', + site_id: 'SITE006', + severity: 'medium', + type: 'sensor', + message: 'Capteur de température défaillant sur Bureau Lille', + value: 0, + threshold: 0, + }, + { + alert_id: 'ALR-SITE004-1718458324', + timestamp: '2026-09-15T09:58:00', + site_id: 'SITE004', + severity: 'low', + type: 'anomaly', + message: 'Comportement de consommation inhabituel sur Bureau Bordeaux', + value: 62.0, + threshold: 55.0, + }, +]; diff --git a/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts new file mode 100644 index 0000000..7057fb8 --- /dev/null +++ b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts @@ -0,0 +1,67 @@ +import { StatsSummary } from '../../shared/models/stats.model'; + +export const STATS_SUMMARY_FIXTURE: StatsSummary = { + timestamp: '2026-09-15T11:32:00', + total_sites: 7, + total_consumption_kw: 1826.44, + total_capacity_kw: 3830, + average_load_percent: 55.1, + sites: [ + { + site_id: 'SITE001', + site_name: 'Bureau Paris La Défense', + current_consumption_kw: 87.34, + capacity_kw: 200, + load_percent: 43.7, + data_quality: 'good', + }, + { + site_id: 'SITE002', + site_name: 'Usine Lyon Vénissieux', + current_consumption_kw: 542.10, + capacity_kw: 1000, + load_percent: 54.2, + data_quality: 'good', + }, + { + site_id: 'SITE003', + site_name: 'Data Center Marseille', + current_consumption_kw: null, + capacity_kw: 800, + load_percent: null, + data_quality: 'critical', + }, + { + site_id: 'SITE004', + site_name: 'Bureau Bordeaux', + current_consumption_kw: 62.0, + capacity_kw: 150, + load_percent: 41.3, + data_quality: 'partial', + }, + { + site_id: 'SITE005', + site_name: 'Usine Toulouse', + current_consumption_kw: 410.0, + capacity_kw: 600, + load_percent: 68.3, + data_quality: 'good', + }, + { + site_id: 'SITE006', + site_name: 'Bureau Lille', + current_consumption_kw: 95.0, + capacity_kw: 180, + load_percent: 52.8, + data_quality: 'degraded', + }, + { + site_id: 'SITE007', + site_name: 'Data Center Nantes', + current_consumption_kw: 630.0, + capacity_kw: 900, + load_percent: 70.0, + data_quality: 'good', + }, + ], +}; diff --git a/apps/frontend/src/app/core/services/alerts.service.spec.ts b/apps/frontend/src/app/core/services/alerts.service.spec.ts new file mode 100644 index 0000000..68b5740 --- /dev/null +++ b/apps/frontend/src/app/core/services/alerts.service.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { AlertsService } from './alerts.service'; +import { environment } from '../../../environments/environment'; + +describe('AlertsService', () => { + let service: AlertsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(AlertsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it("appelle le bon endpoint et retourne un tableau d'alertes", () => { + let result: unknown; + service.getAlerts().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/alerts`); + expect(req.request.method).toBe('GET'); + + 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 as unknown[]).length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/core/services/alerts.service.ts b/apps/frontend/src/app/core/services/alerts.service.ts new file mode 100644 index 0000000..ebd00e2 --- /dev/null +++ b/apps/frontend/src/app/core/services/alerts.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { Alert } from '../../shared/models/alert.model'; + +@Service() +export class AlertsService { + private http = inject(HttpClient); + + getAlerts() { + return this.http.get(`${environment.apiUrl}/alerts`); + } +} diff --git a/apps/frontend/src/app/core/services/stats.service.spec.ts b/apps/frontend/src/app/core/services/stats.service.spec.ts new file mode 100644 index 0000000..a65c38f --- /dev/null +++ b/apps/frontend/src/app/core/services/stats.service.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { StatsService } from './stats.service'; +import { environment } from '../../../environments/environment'; + +describe('StatsService', () => { + let service: StatsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(StatsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('appelle le bon endpoint et retourne le résumé', () => { + let result: unknown; + service.getSummary().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-15T12:00:00', + total_sites: 7, + total_consumption_kw: 1800, + total_capacity_kw: 3800, + average_load_percent: 47.4, + sites: [], + }); + + expect((result as { total_sites: number }).total_sites).toBe(7); + }); +}); diff --git a/apps/frontend/src/app/core/services/stats.service.ts b/apps/frontend/src/app/core/services/stats.service.ts new file mode 100644 index 0000000..4cb630f --- /dev/null +++ b/apps/frontend/src/app/core/services/stats.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { StatsSummary } from '../../shared/models/stats.model'; + +@Service() +export class StatsService { + private http = inject(HttpClient); + + getSummary() { + return this.http.get(`${environment.apiUrl}/stats/summary`); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html new file mode 100644 index 0000000..70b333c --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -0,0 +1,48 @@ +
+
+

Vue d'ensemble

+

Consommation instantanée du parc

+
+ + @if (stats(); as s) { +
+
+ Consommation vs capacité + + {{ s.total_consumption_kw | number:'1.0-1' }} / {{ s.total_capacity_kw | number }} kW +
+ +
+ Charge moyenne du parc + {{ s.average_load_percent }} % +
+
+
+
+ +
+ Sites suivis + {{ s.total_sites }} +
+
+ +
+

Charge et alerte visuelle par site

+ +
+ } + + @if (alerts().length > 0) { +
+

Alertes actives

+
    + @for (alert of alerts(); track alert.alert_id) { +
  • + {{ alert.severity }} + {{ alert.message }} +
  • + } +
+
+ } +
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss new file mode 100644 index 0000000..d0b1088 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -0,0 +1,130 @@ +:host { + --color-good: #2e7d32; + --color-partial: #f9a825; + --color-degraded: #ef6c00; + --color-critical: #c62828; + --color-bg-card: #ffffff; + --color-border: #e5e7eb; + --color-text-muted: #6b7280; + --radius: 10px; + + display: block; + font-family: 'Segoe UI', system-ui, sans-serif; + color: #1f2937; + padding: 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.dashboard__header { + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.dashboard__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem; +} + +.overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin-bottom: 2.5rem; +} + +.card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.35rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.card--gauge { + align-items: center; + text-align: center; +} + +.card__label { + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.card__value { + font-size: 1.6rem; + font-weight: 700; +} + +.progress-bar { + height: 6px; + background: #e5e7eb; + border-radius: 999px; + overflow: hidden; + margin-top: 0.25rem; +} + +.progress-bar__fill { + height: 100%; + background: #3b82f6; + border-radius: 999px; + transition: width 0.3s ease; +} + +.chart-section { + 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); + background: #fef2f2; + border: 1px solid #fecaca; +} + +.alert-item__badge { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.2rem 0.55rem; + border-radius: 999px; + color: #fff; + background: var(--color-critical); + flex-shrink: 0; +} + +.alert-item--high .alert-item__badge { background: var(--color-degraded); } +.alert-item--medium .alert-item__badge { background: var(--color-partial); } +.alert-item--low .alert-item__badge { background: var(--color-good); } + +.alert-item__message { + font-size: 0.9rem; +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts new file mode 100644 index 0000000..7dcbe37 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { of } from 'rxjs'; +import { Dashboard } from './dashboard'; +import { StatsService } from '../../core/services/stats.service'; +import { AlertsService } from '../../core/services/alerts.service'; + +vi.mock('chart.js', () => { + class ChartMock { + update = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +describe('Dashboard', () => { + 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' }])) }; + + TestBed.configureTestingModule({ + imports: [Dashboard], + providers: [ + { provide: StatsService, useValue: statsMock }, + { provide: AlertsService, useValue: alertsMock }, + ], + }); + + 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(fixture.componentInstance.alerts().length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts new file mode 100644 index 0000000..2fe7768 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -0,0 +1,39 @@ +import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { timer, switchMap } 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'; + +const REFRESH_INTERVAL_MS = 10000; + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart], + templateUrl: './dashboard.html', + styleUrl: './dashboard.scss', +}) +export class Dashboard implements OnInit { + private statsService = inject(StatsService); + private alertsService = inject(AlertsService); + private destroyRef = inject(DestroyRef); + + stats = signal(null); + alerts = signal([]); + + ngOnInit(): void { + this.alertsService.getAlerts().subscribe((alerts) => this.alerts.set(alerts)); + + timer(0, REFRESH_INTERVAL_MS) + .pipe( + switchMap(() => this.statsService.getSummary()), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((stats) => this.stats.set(stats)); + } +} diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss new file mode 100644 index 0000000..1552ce8 --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.scss @@ -0,0 +1,6 @@ +:host { + display: block; + height: 200px; + width: 200px; + margin: 0 auto; +} diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts new file mode 100644 index 0000000..e793c39 --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { ConsumptionGauge } from './consumption-gauge'; + +vi.mock('chart.js', () => { + class ChartMock { + update = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +describe('ConsumptionGauge', () => { + it('se crée sans erreur avec des entrées valides', () => { + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + it('met à jour le graphique quand les valeurs changent après initialisation', () => { + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + + fixture.componentRef.setInput('consumption', 500); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + + expect(() => fixture.detectChanges()).not.toThrow(); +}); +}); diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts new file mode 100644 index 0000000..73544ea --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts @@ -0,0 +1,55 @@ +import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { Chart, registerables } from 'chart.js'; + +Chart.register(...registerables); + +@Component({ + selector: 'app-consumption-gauge', + standalone: true, + templateUrl: './consumption-gauge.html', + styleUrl: './consumption-gauge.scss', +}) +export class ConsumptionGauge implements AfterViewInit { + consumption = input.required(); + capacity = input.required(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart; + + constructor() { + effect(() => { + const used = this.consumption(); + const remaining = Math.max(0, this.capacity() - used); + if (this.chart) { + this.chart.data.datasets[0].data = [used, remaining]; + this.chart.update('none'); + } + }); + } + + ngAfterViewInit(): void { + const used = this.consumption(); + const remaining = Math.max(0, this.capacity() - used); + + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'doughnut', + data: { + labels: ['Utilisé', 'Disponible'], + datasets: [ + { + data: [used, remaining], + backgroundColor: ['#3b82f6', '#e5e7eb'], + borderWidth: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: '70%', + animation: { duration: 300 }, + plugins: { legend: { display: false } }, + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html new file mode 100644 index 0000000..c2e2ad0 --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.html @@ -0,0 +1 @@ + diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss new file mode 100644 index 0000000..bfa4956 --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 260px; +} diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts new file mode 100644 index 0000000..1e1c46b --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { SiteLoadChart } from './site-load-chart'; + +vi.mock('chart.js', () => { + class ChartMock { + update = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +describe('SiteLoadChart', () => { + it('se crée sans erreur avec une liste de sites valide', () => { + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { site_id: 'S1', site_name: 'Test', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, + ]); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + it('met à jour le graphique quand les sites changent après initialisation', () => { + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { site_id: 'S1', site_name: 'A', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, + ]); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + + fixture.componentRef.setInput('sites', [ + { site_id: 'S2', site_name: 'B', current_consumption_kw: 80, capacity_kw: 100, load_percent: 80, data_quality: 'critical' }, + ]); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + + expect(() => fixture.detectChanges()).not.toThrow(); +}); +}); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts new file mode 100644 index 0000000..f8ee5cb --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -0,0 +1,62 @@ +import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { Chart, registerables } from 'chart.js'; +import { SiteSummary } from '../../models/stats.model'; + +Chart.register(...registerables); + +const QUALITY_COLORS: Record = { + good: '#2e7d32', + partial: '#f9a825', + degraded: '#ef6c00', + critical: '#c62828', +}; + +@Component({ + selector: 'app-site-load-chart', + standalone: true, + templateUrl: './site-load-chart.html', + styleUrl: './site-load-chart.scss', +}) +export class SiteLoadChart implements AfterViewInit { + sites = input.required(); + + @ViewChild('canvas') private canvasRef!: ElementRef; + private chart?: Chart; + + constructor() { + effect(() => { + const sites = this.sites(); + if (this.chart) { + this.chart.data.labels = sites.map((s) => s.site_name); + this.chart.data.datasets[0].data = sites.map((s) => s.load_percent ?? 0); + this.chart.data.datasets[0].backgroundColor = sites.map((s) => QUALITY_COLORS[s.data_quality]); + this.chart.update('none'); + } + }); + } + + ngAfterViewInit(): void { + const sites = this.sites(); + this.chart = new Chart(this.canvasRef.nativeElement, { + type: 'bar', + data: { + labels: sites.map((s) => s.site_name), + datasets: [ + { + data: sites.map((s) => s.load_percent ?? 0), + backgroundColor: sites.map((s) => QUALITY_COLORS[s.data_quality]), + borderRadius: 4, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + y: { beginAtZero: true, max: 100, title: { display: true, text: 'Charge (%)' } }, + }, + }, + }); + } +} diff --git a/apps/frontend/src/app/shared/models/alert.model.ts b/apps/frontend/src/app/shared/models/alert.model.ts new file mode 100644 index 0000000..028f35a --- /dev/null +++ b/apps/frontend/src/app/shared/models/alert.model.ts @@ -0,0 +1,13 @@ +export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'; +export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor'; + +export interface Alert { + alert_id: string; + timestamp: string; + site_id: string; + severity: AlertSeverity; + type: AlertType; + message: string; + value: number; + threshold: number; +} diff --git a/apps/frontend/src/app/shared/models/stats.model.ts b/apps/frontend/src/app/shared/models/stats.model.ts new file mode 100644 index 0000000..eb0f03d --- /dev/null +++ b/apps/frontend/src/app/shared/models/stats.model.ts @@ -0,0 +1,17 @@ +export interface SiteSummary { + site_id: string; + site_name: string; + current_consumption_kw: number | null; + capacity_kw: number; + load_percent: number | null; + data_quality: 'good' | 'partial' | 'degraded' | 'critical'; +} + +export interface StatsSummary { + timestamp: string; + total_sites: number; + total_consumption_kw: number; + total_capacity_kw: number; + average_load_percent: number; + sites: SiteSummary[]; +} diff --git a/apps/frontend/src/environments/environment.development.ts b/apps/frontend/src/environments/environment.development.ts index 5ebc640..8409d18 100644 --- a/apps/frontend/src/environments/environment.development.ts +++ b/apps/frontend/src/environments/environment.development.ts @@ -1,4 +1,5 @@ export const environment = { production: false, - apiUrl: '/api/v1' + apiUrl: '/api/v1', + useMockFixtures: true, // a passer a false une fois le backend prêt }; diff --git a/apps/frontend/src/environments/environment.ts b/apps/frontend/src/environments/environment.ts index 5c2010d..bac99a8 100644 --- a/apps/frontend/src/environments/environment.ts +++ b/apps/frontend/src/environments/environment.ts @@ -1,4 +1,5 @@ export const environment = { production: true, - apiUrl: 'http://localhost:8000/api/v1' + apiUrl: 'http://localhost:8000/api/v1', + useMockFixtures: false, }; From 7b9406965e3bc00a14266d55bab30c086ddefbed Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:17 +0200 Subject: [PATCH 2/6] fix(frontend): maintient le rafraichissement du tableau de bord en cas d'erreur Sans catchError, la premiere reponse en erreur terminait le flux du timer : le rafraichissement ne repartait jamais et l'ecran restait fige sur des chiffres perimes, sans rien signaler. Le catchError porte sur l'observable interne du switchMap. Place sur le flux externe il terminerait le timer tout autant. Un signal error alimente un bandeau, efface des qu'une reponse valide revient. --- .../src/app/features/dashboard/dashboard.html | 4 ++ .../src/app/features/dashboard/dashboard.scss | 10 ++++ .../app/features/dashboard/dashboard.spec.ts | 56 ++++++++++++++++++- .../src/app/features/dashboard/dashboard.ts | 32 ++++++++--- 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 70b333c..d324c74 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -4,6 +4,10 @@

Consommation instantanée du parc

+ @if (error(); as message) { + + } + @if (stats(); as s) {
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; } } From 0259f66b62679158c0d64b4e26c0b154134cb4ac Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:17 +0200 Subject: [PATCH 3/6] fix(frontend): detruit les graphiques avec leur composant Chart.js conserve chaque instance dans un registre lie au canvas et lui attache un observateur de redimensionnement. Sans destroy, tout survit a la destruction du composant, et une re-creation sur le meme canvas echoue avec "Canvas is already in use". Les doubles de test gagnent destroy : TestBed detruit les fixtures apres chaque test, un mock sans cette methode fait tomber les specs existantes. --- .../consumption-gauge.spec.ts | 29 ++++++++++++++- .../consumption-gauge/consumption-gauge.ts | 16 +++++++- .../site-load-chart/site-load-chart.spec.ts | 37 ++++++++++++++++++- .../site-load-chart/site-load-chart.ts | 16 +++++++- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts index e793c39..be25fb1 100644 --- a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -1,16 +1,28 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; +import { Chart } from 'chart.js'; import { ConsumptionGauge } from './consumption-gauge'; vi.mock('chart.js', () => { class ChartMock { - update = vi.fn(); - data = { datasets: [{}] }; + static instances: ChartMock[] = []; static register = vi.fn(); + update = vi.fn(); + destroy = vi.fn(); + data = { datasets: [{}] }; + constructor() { + ChartMock.instances.push(this); + } } return { Chart: ChartMock, registerables: [] }; }); +type ChartDouble = { destroy: ReturnType }; + +function lastChart(): ChartDouble | undefined { + return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); +} + describe('ConsumptionGauge', () => { it('se crée sans erreur avec des entrées valides', () => { TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); @@ -31,4 +43,17 @@ describe('ConsumptionGauge', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); + + it('détruit le graphique quand le composant est détruit', () => { + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + fixture.detectChanges(); + + const chart = lastChart(); + fixture.destroy(); + + expect(chart?.destroy).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts index 73544ea..bda661a 100644 --- a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts @@ -1,4 +1,12 @@ -import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} from '@angular/core'; import { Chart, registerables } from 'chart.js'; Chart.register(...registerables); @@ -9,7 +17,7 @@ Chart.register(...registerables); templateUrl: './consumption-gauge.html', styleUrl: './consumption-gauge.scss', }) -export class ConsumptionGauge implements AfterViewInit { +export class ConsumptionGauge implements AfterViewInit, OnDestroy { consumption = input.required(); capacity = input.required(); @@ -52,4 +60,8 @@ export class ConsumptionGauge implements AfterViewInit { }, }); } + + ngOnDestroy(): void { + this.chart?.destroy(); + } } diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts index 1e1c46b..0d5944d 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -1,16 +1,28 @@ import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; +import { Chart } from 'chart.js'; import { SiteLoadChart } from './site-load-chart'; vi.mock('chart.js', () => { class ChartMock { - update = vi.fn(); - data = { datasets: [{}] }; + static instances: ChartMock[] = []; static register = vi.fn(); + update = vi.fn(); + destroy = vi.fn(); + data = { datasets: [{}] }; + constructor() { + ChartMock.instances.push(this); + } } return { Chart: ChartMock, registerables: [] }; }); +type ChartDouble = { destroy: ReturnType }; + +function lastChart(): ChartDouble | undefined { + return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1); +} + describe('SiteLoadChart', () => { it('se crée sans erreur avec une liste de sites valide', () => { TestBed.configureTestingModule({ imports: [SiteLoadChart] }); @@ -35,4 +47,25 @@ describe('SiteLoadChart', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); + + it('détruit le graphique quand le composant est détruit', () => { + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { + site_id: 'S1', + site_name: 'A', + current_consumption_kw: 50, + capacity_kw: 100, + load_percent: 50, + data_quality: 'good', + }, + ]); + fixture.detectChanges(); + + const chart = lastChart(); + fixture.destroy(); + + expect(chart?.destroy).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts index f8ee5cb..017ce1b 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -1,4 +1,12 @@ -import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core'; +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} from '@angular/core'; import { Chart, registerables } from 'chart.js'; import { SiteSummary } from '../../models/stats.model'; @@ -17,7 +25,7 @@ const QUALITY_COLORS: Record = { templateUrl: './site-load-chart.html', styleUrl: './site-load-chart.scss', }) -export class SiteLoadChart implements AfterViewInit { +export class SiteLoadChart implements AfterViewInit, OnDestroy { sites = input.required(); @ViewChild('canvas') private canvasRef!: ElementRef; @@ -59,4 +67,8 @@ export class SiteLoadChart implements AfterViewInit { }, }); } + + ngOnDestroy(): void { + this.chart?.destroy(); + } } From da97e6aa8b6236f313068cdc78f59e7c37cc0681 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:40 +0200 Subject: [PATCH 4/6] style(frontend): applique prettier aux fichiers du tableau de bord Les onze fichiers non conformes au .prettierrc du projet etaient exactement ceux introduits ou modifies par cette branche ; les vingt-deux autres du frontend etaient deja propres. Aucune modification de comportement : indentation, virgules finales et longueur de ligne a 100 caracteres. --- apps/frontend/src/app/app.config.ts | 7 +-- apps/frontend/src/app/app.routes.ts | 5 +- .../interceptors/mock-api-interceptor.spec.ts | 14 +++--- .../app/core/mocks/stats-summary.fixture.ts | 2 +- .../src/app/features/dashboard/dashboard.html | 10 +++- .../src/app/features/dashboard/dashboard.scss | 12 +++-- .../app/features/dashboard/dashboard.spec.ts | 2 +- .../src/app/features/dashboard/dashboard.ts | 4 +- .../consumption-gauge.spec.ts | 18 +++---- .../site-load-chart/site-load-chart.spec.ts | 47 ++++++++++++++----- .../site-load-chart/site-load-chart.ts | 4 +- 11 files changed, 82 insertions(+), 43 deletions(-) diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index d89a118..ff4cafd 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,12 +1,13 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; -import {mockApiInterceptor} from './core/interceptors/mock-api-interceptor'; -import {provideHttpClient, withInterceptors} from '@angular/common/http'; +import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; export const appConfig: ApplicationConfig = { providers: [ - provideBrowserGlobalErrorListeners(), provideRouter(routes), + provideBrowserGlobalErrorListeners(), + provideRouter(routes), provideHttpClient(withInterceptors([mockApiInterceptor])), ], }; diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 9852e8b..8f2739c 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -2,5 +2,8 @@ import { Routes } from '@angular/router'; export const routes: Routes = [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, - { path: 'dashboard', loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard) }, + { + path: 'dashboard', + loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard), + }, ]; 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 34a58b2..5d6e343 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 @@ -33,7 +33,7 @@ describe('mockApiInterceptor', () => { httpMock.expectNone(`${environment.apiUrl}/stats/summary`); expect((result as typeof STATS_SUMMARY_FIXTURE).total_sites).toBe( - STATS_SUMMARY_FIXTURE.total_sites + STATS_SUMMARY_FIXTURE.total_sites, ); }); @@ -56,12 +56,12 @@ describe('mockApiInterceptor', () => { }); it('renvoie la fixture des alertes sans appel réseau quand useMockFixtures est activé', () => { - environment.useMockFixtures = true; - let result: unknown; + environment.useMockFixtures = true; + let result: unknown; - http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r)); + http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r)); - httpMock.expectNone(`${environment.apiUrl}/alerts`); - expect((result as unknown[]).length).toBeGreaterThan(0); -}); + httpMock.expectNone(`${environment.apiUrl}/alerts`); + expect((result as unknown[]).length).toBeGreaterThan(0); + }); }); diff --git a/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts index 7057fb8..f71a41b 100644 --- a/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts +++ b/apps/frontend/src/app/core/mocks/stats-summary.fixture.ts @@ -18,7 +18,7 @@ export const STATS_SUMMARY_FIXTURE: StatsSummary = { { site_id: 'SITE002', site_name: 'Usine Lyon Vénissieux', - current_consumption_kw: 542.10, + current_consumption_kw: 542.1, capacity_kw: 1000, load_percent: 54.2, data_quality: 'good', diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index d324c74..a64d5d9 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -12,8 +12,14 @@
Consommation vs capacité - - {{ s.total_consumption_kw | number:'1.0-1' }} / {{ s.total_capacity_kw | number }} kW + + {{ s.total_consumption_kw | number: '1.0-1' }} / + {{ s.total_capacity_kw | number }} kW
diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 3cacb0f..01cc3a3 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -131,9 +131,15 @@ h2 { flex-shrink: 0; } -.alert-item--high .alert-item__badge { background: var(--color-degraded); } -.alert-item--medium .alert-item__badge { background: var(--color-partial); } -.alert-item--low .alert-item__badge { background: var(--color-good); } +.alert-item--high .alert-item__badge { + background: var(--color-degraded); +} +.alert-item--medium .alert-item__badge { + background: var(--color-partial); +} +.alert-item--low .alert-item__badge { + background: var(--color-good); +} .alert-item__message { font-size: 0.9rem; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index f55adac..89a69ec 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -43,7 +43,7 @@ describe('Dashboard', () => { expect(fixture.componentInstance.error()).toBeNull(); }); - it('signale l\'indisponibilité puis repart au rafraîchissement suivant', () => { + it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => { vi.useFakeTimers(); const statsMock = { getSummary: vi diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index b6a7627..7733230 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -40,9 +40,9 @@ export class Dashboard implements OnInit { timer(0, REFRESH_INTERVAL_MS) .pipe( switchMap(() => - this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())) + this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())), ), - takeUntilDestroyed(this.destroyRef) + takeUntilDestroyed(this.destroyRef), ) .subscribe((stats) => { this.error.set(null); diff --git a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts index be25fb1..672d50f 100644 --- a/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -32,17 +32,17 @@ describe('ConsumptionGauge', () => { expect(() => fixture.detectChanges()).not.toThrow(); }); it('met à jour le graphique quand les valeurs changent après initialisation', () => { - TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); - const fixture = TestBed.createComponent(ConsumptionGauge); - fixture.componentRef.setInput('consumption', 300); - fixture.componentRef.setInput('capacity', 1000); - fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); + const fixture = TestBed.createComponent(ConsumptionGauge); + fixture.componentRef.setInput('consumption', 300); + fixture.componentRef.setInput('capacity', 1000); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais - fixture.componentRef.setInput('consumption', 500); - fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + fixture.componentRef.setInput('consumption', 500); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini - expect(() => fixture.detectChanges()).not.toThrow(); -}); + expect(() => fixture.detectChanges()).not.toThrow(); + }); it('détruit le graphique quand le composant est détruit', () => { TestBed.configureTestingModule({ imports: [ConsumptionGauge] }); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts index 0d5944d..59e6b6b 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -28,25 +28,46 @@ describe('SiteLoadChart', () => { TestBed.configureTestingModule({ imports: [SiteLoadChart] }); const fixture = TestBed.createComponent(SiteLoadChart); fixture.componentRef.setInput('sites', [ - { site_id: 'S1', site_name: 'Test', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, + { + site_id: 'S1', + site_name: 'Test', + current_consumption_kw: 50, + capacity_kw: 100, + load_percent: 50, + data_quality: 'good', + }, ]); expect(() => fixture.detectChanges()).not.toThrow(); }); it('met à jour le graphique quand les sites changent après initialisation', () => { - TestBed.configureTestingModule({ imports: [SiteLoadChart] }); - const fixture = TestBed.createComponent(SiteLoadChart); - fixture.componentRef.setInput('sites', [ - { site_id: 'S1', site_name: 'A', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' }, - ]); - fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais + TestBed.configureTestingModule({ imports: [SiteLoadChart] }); + const fixture = TestBed.createComponent(SiteLoadChart); + fixture.componentRef.setInput('sites', [ + { + site_id: 'S1', + site_name: 'A', + current_consumption_kw: 50, + capacity_kw: 100, + load_percent: 50, + data_quality: 'good', + }, + ]); + fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais - fixture.componentRef.setInput('sites', [ - { site_id: 'S2', site_name: 'B', current_consumption_kw: 80, capacity_kw: 100, load_percent: 80, data_quality: 'critical' }, - ]); - fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini + fixture.componentRef.setInput('sites', [ + { + site_id: 'S2', + site_name: 'B', + current_consumption_kw: 80, + capacity_kw: 100, + load_percent: 80, + data_quality: 'critical', + }, + ]); + fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini - expect(() => fixture.detectChanges()).not.toThrow(); -}); + expect(() => fixture.detectChanges()).not.toThrow(); + }); it('détruit le graphique quand le composant est détruit', () => { TestBed.configureTestingModule({ imports: [SiteLoadChart] }); diff --git a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts index 017ce1b..6c17803 100644 --- a/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -37,7 +37,9 @@ export class SiteLoadChart implements AfterViewInit, OnDestroy { if (this.chart) { this.chart.data.labels = sites.map((s) => s.site_name); this.chart.data.datasets[0].data = sites.map((s) => s.load_percent ?? 0); - this.chart.data.datasets[0].backgroundColor = sites.map((s) => QUALITY_COLORS[s.data_quality]); + this.chart.data.datasets[0].backgroundColor = sites.map( + (s) => QUALITY_COLORS[s.data_quality], + ); this.chart.update('none'); } }); From e85c83972a62d510e8d418510b4c9c2755be349c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:20:48 +0200 Subject: [PATCH 5/6] chore(frontend): sort le rapport JUnit du suivi git La ligne /test-results ajoutee au .gitignore n'avait aucun effet : le fichier etait deja suivi, et un .gitignore ne s'applique pas a un fichier indexe. Il reapparaissait donc modifie dans le diff de chacun a chaque execution de ng test, qui le regenere a l'emplacement fixe par angular.json. --- apps/frontend/test-results/junit.xml | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 apps/frontend/test-results/junit.xml diff --git a/apps/frontend/test-results/junit.xml b/apps/frontend/test-results/junit.xml deleted file mode 100644 index 28e5ba4..0000000 --- a/apps/frontend/test-results/junit.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - From 580da72effe88610b32e58cc72ba59373e795a8a Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 09:23:15 +0200 Subject: [PATCH 6/6] docs(architecture): acte le tableau de bord dans les vues frontend 30-frontend.md decrivait encore un ng new intact : routes vides, provideHttpClient absent, app.html par defaut, aucune bibliotheque de graphiques. Les sections Arborescence et Flux HTTP passent de Cible a realisees, et le diagramme de sequence montre ou l'intercepteur se place. La section Securite affirmait que l'authentification n'existe pas cote API : elle existe depuis la PR #70, c'est cote interface qu'il n'y a rien. Ajout verifie sur le poste : l'Angular CLI refuse de demarrer en dessous de Node 22.22.3, 24.15.0 ou 26.0.0. --- README.md | 7 +-- docs/architecture/00-vue-ensemble.md | 7 +-- docs/architecture/30-frontend.md | 65 ++++++++++++++++++---------- 3 files changed, 51 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 7a8147f..75a1ab6 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Domaine | Technologie | Emplacement | Etat | |------------|-------------------------------------|---------------------|---------------| | Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | -| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Squelette | +| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord | | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | ETL | Apache Airflow | `etl/airflow` | A initialiser | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | @@ -27,8 +27,9 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend -porte le squelette Angular, sans code metier : aucune route, aucun appel d'API. Les autres dossiers -portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie. +sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints +correspondants restent à écrire côté API. Les autres dossiers portent l'arborescence et un README +de cadrage, leur contenu fait l'objet d'un ticket dedie. L'etat detaille de chaque brique et les vues d'architecture sont dans [docs/architecture](docs/architecture/README.md). diff --git a/docs/architecture/00-vue-ensemble.md b/docs/architecture/00-vue-ensemble.md index 15a54c0..d083985 100644 --- a/docs/architecture/00-vue-ensemble.md +++ b/docs/architecture/00-vue-ensemble.md @@ -63,8 +63,9 @@ flowchart TB grafana -.-> prom ``` -Le lien `front -.-> api` est en pointillé à dessein : le frontend n'appelle aujourd'hui aucune -API, `provideHttpClient` n'est pas encore installé. Voir [30-frontend.md](30-frontend.md). +Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un +intercepteur répond à sa place tant que les endpoints n'existent pas. Voir +[30-frontend.md](30-frontend.md). Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun collecteur ne vient le lire. @@ -74,7 +75,7 @@ collecteur ne vient le lire. | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | |---|---|---|---|---| | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier | -| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Squelette `ng new` standalone, routes vides, aucun service HTTP | +| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | diff --git a/docs/architecture/30-frontend.md b/docs/architecture/30-frontend.md index 98da40a..baea451 100644 --- a/docs/architecture/30-frontend.md +++ b/docs/architecture/30-frontend.md @@ -4,30 +4,36 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps ## État actuel -Statut : `En cours`. Le projet est un `ng new` intact. Le tableau de la -[vue d'ensemble](00-vue-ensemble.md) le classe désormais correctement, le `README.md` racine le -disait encore « à initialiser » alors que le squelette existe depuis `49f4697`. +Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée +par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API. Ce qui est en place : - Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. -- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()` et `provideRouter(routes)`. -- Vitest via le builder `@angular/build:unit-test`, couverture activée, un fichier de test. +- `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 + graphique de charge par site, tous deux construits sur Chart.js. +- 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. - Prettier configuré, parser `angular` pour les gabarits HTML. Ce qui n'existe pas encore : -- `routes` est un tableau vide. Aucune page, aucune navigation. -- **`provideHttpClient` n'est pas fourni** et `@angular/common/http` n'est importé nulle part : - l'application n'appelle aucune API. -- `app.html` est la page d'accueil Angular par défaut, commentaires de remplacement compris. -- Aucune bibliothèque de graphiques, aucun kit d'interface, aucune gestion d'état. +- **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 é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é. -## Arborescence cible +## Arborescence -Statut : `Cible`. Elle n'est pas inventée ici : [`TESTING.md`](../../apps/frontend/TESTING.md) la -prescrit déjà dans ses gabarits de tests. +Statut : `Fait`. Elle suit ce que [`TESTING.md`](../../apps/frontend/TESTING.md) prescrit dans ses +gabarits de tests. ```mermaid flowchart TB @@ -48,22 +54,33 @@ directement : ils passent par un service, ce qui rend le double de test trivial. ## Flux HTTP -Statut : `Cible`. Le chemin est câblé, rien ne l'emprunte encore. +Statut : `En cours`. Le chemin complet est câblé, mais un intercepteur se place devant et répond +lui-même tant que les endpoints n'existent pas. ```mermaid sequenceDiagram participant C as Composant participant S as Service Angular + participant I as mockApiInterceptor participant P as ng serve, proxy participant A as FastAPI C->>S: appel de méthode - S->>P: GET /api/v1/... - P->>A: http://localhost:8000/api/v1/... - A-->>S: JSON + S->>I: GET /api/v1/... + alt useMockFixtures actif et route connue + I-->>S: fixture locale + else + I->>P: la requête poursuit + P->>A: http://localhost:8000/api/v1/... + A-->>S: JSON + end 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. + 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`. @@ -87,6 +104,10 @@ déploiement, en même temps que sera tranchée la question de l'ingress dans | `npm run test` | Vitest en mode observateur | | `npm run test:ci` | Vitest en une passe | +**Version de Node.** L'Angular CLI refuse de démarrer en dessous de 22.22.3, 24.15.0 ou 26.0.0, et +le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.12 ne peut donc ni +tester ni construire le frontend. + Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans `docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200 n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend. @@ -98,8 +119,9 @@ 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 n'existe pas côté API, donc pas de garde ni d'intercepteur de jeton à ce - stade. `core/guards` et `core/interceptors` sont prévus pour cela. +- 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. ## Tests @@ -107,8 +129,7 @@ Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTI ## Questions ouvertes -- **Quelle bibliothèque de graphiques** pour les séries temporelles, et si Grafana en couvre déjà - une partie du besoin. -- **Gestion d'état** : signaux seuls, ou une bibliothèque dédiée. +- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs + pages partageront le même état. - **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue au démarrage.