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/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..ff4cafd 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,7 +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'; 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..8f2739c 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,3 +1,9 @@ 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..5d6e343 --- /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..f71a41b --- /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.1, + 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..a64d5d9 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -0,0 +1,58 @@ +
+
+

Vue d'ensemble

+

Consommation instantanée du parc

+
+ + @if (error(); as message) { + + } + + @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..01cc3a3 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -0,0 +1,146 @@ +: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; +} + +.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)); + 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..89a69ec --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -0,0 +1,95 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { of, throwError } 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(); + destroy = vi.fn(); + data = { datasets: [{}] }; + static register = vi.fn(); + } + return { Chart: ChartMock, registerables: [] }; +}); + +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' }])) }; + + 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); + 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 new file mode 100644 index 0000000..7733230 --- /dev/null +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -0,0 +1,57 @@ +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 { 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; +const UNAVAILABLE_MESSAGE = + 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; + +@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([]); + error = signal(null); + + ngOnInit(): void { + 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().pipe(catchError(() => this.reportUnavailable())), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((stats) => { + this.error.set(null); + this.stats.set(stats); + }); + } + + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} 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..672d50f --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.spec.ts @@ -0,0 +1,59 @@ +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 { + 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] }); + 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(); + }); + + 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 new file mode 100644 index 0000000..bda661a --- /dev/null +++ b/apps/frontend/src/app/shared/components/consumption-gauge/consumption-gauge.ts @@ -0,0 +1,67 @@ +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} 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, OnDestroy { + 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 } }, + }, + }); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } +} 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..59e6b6b --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.spec.ts @@ -0,0 +1,92 @@ +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 { + 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] }); + 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(); + }); + + 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 new file mode 100644 index 0000000..6c17803 --- /dev/null +++ b/apps/frontend/src/app/shared/components/site-load-chart/site-load-chart.ts @@ -0,0 +1,76 @@ +import { + Component, + ElementRef, + ViewChild, + input, + effect, + AfterViewInit, + OnDestroy, +} 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, OnDestroy { + 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 (%)' } }, + }, + }, + }); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } +} 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, }; 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 @@ - - - - - - - - - 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.