From 7f710c90845f8c07528a90c6d2a87985381d9816 Mon Sep 17 00:00:00 2001 From: Valentin Date: Fri, 18 Sep 2026 12:02:48 +0200 Subject: [PATCH 1/3] feat(frontend): supervision des capteurs par site (admin) --- apps/frontend/src/app/app.routes.ts | 7 ++ .../app/core/services/sensors.service.spec.ts | 48 +++++++++ .../src/app/core/services/sensors.service.ts | 13 +++ .../src/app/features/dashboard/dashboard.html | 3 + .../src/app/features/dashboard/dashboard.scss | 9 ++ .../app/features/dashboard/dashboard.spec.ts | 10 +- .../src/app/features/dashboard/dashboard.ts | 2 +- .../sensor-status/sensor-status.html | 49 +++++++++ .../sensor-status/sensor-status.scss | 90 +++++++++++++++++ .../sensor-status/sensor-status.spec.ts | 99 +++++++++++++++++++ .../monitoring/sensor-status/sensor-status.ts | 65 ++++++++++++ .../app/shared/models/sensor-status.model.ts | 28 ++++++ 12 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 apps/frontend/src/app/core/services/sensors.service.spec.ts create mode 100644 apps/frontend/src/app/core/services/sensors.service.ts create mode 100644 apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html create mode 100644 apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.scss create mode 100644 apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts create mode 100644 apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.ts create mode 100644 apps/frontend/src/app/shared/models/sensor-status.model.ts diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index e619268..d8fe1d6 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -25,4 +25,11 @@ export const routes: Routes = [ (m) => m.SiteDetailPlaceholder, ), }, + { + path: 'monitoring/sensors', + canActivate: [authGuard], + data: { role: 'admin' }, + loadComponent: () => + import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView), +}, ]; diff --git a/apps/frontend/src/app/core/services/sensors.service.spec.ts b/apps/frontend/src/app/core/services/sensors.service.spec.ts new file mode 100644 index 0000000..d9cac1d --- /dev/null +++ b/apps/frontend/src/app/core/services/sensors.service.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { SensorsService } from './sensors.service'; +import { environment } from '../../../environments/environment'; + +describe('SensorsService', () => { + let service: SensorsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(SensorsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it("appelle l'endpoint /sensors/status et retourne la réponse", () => { + let result: unknown; + service.getStatus().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sensors/status`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-18T08:00:00', + sites: [ + { + site_id: 'SITE001', + site_name: 'Test', + overall: 'ok', + sensors: { + consumption: { status: 'ok', since: null }, + electrical: { status: 'ok', since: null }, + temperature: { status: 'ok', since: null }, + humidity: { status: 'ok', since: null }, + network: { status: 'ok', since: null }, + }, + }, + ], + }); + + expect((result as { sites: unknown[] }).sites.length).toBe(1); + }); +}); diff --git a/apps/frontend/src/app/core/services/sensors.service.ts b/apps/frontend/src/app/core/services/sensors.service.ts new file mode 100644 index 0000000..4976505 --- /dev/null +++ b/apps/frontend/src/app/core/services/sensors.service.ts @@ -0,0 +1,13 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import {SensorStatusResponse} from '../../shared/models/sensor-status.model'; + +@Service() +export class SensorsService { + private http = inject(HttpClient); + + getStatus() { + return this.http.get(`${environment.apiUrl}/sensors/status`); + } +} diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 08b015e..43751bf 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -10,6 +10,9 @@
+ @if (auth.principal()?.role === 'admin') { + Supervision des capteurs + } Voir les sites { it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() }; - + const authMock = { + logout: vi.fn().mockReturnValue(of(undefined)), + clearSession: vi.fn(), + principal: vi.fn().mockReturnValue({ role: 'admin' }), + }; TestBed.configureTestingModule({ imports: [Dashboard], providers: [ @@ -128,9 +131,10 @@ describe('Dashboard', () => { it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => { const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) }; const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) }; - const authMock = { + const authMock = { logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))), clearSession: vi.fn(), + principal: vi.fn().mockReturnValue({ role: 'admin' }), }; TestBed.configureTestingModule({ imports: [Dashboard], diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index e9f9792..efd0ffa 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -47,7 +47,7 @@ const TON_PAR_SEVERITE: Record = { export class Dashboard implements OnInit { private statsService = inject(StatsService); private alertsService = inject(AlertsService); - private auth = inject(AuthService); + public auth = inject(AuthService); private router = inject(Router); private destroyRef = inject(DestroyRef); diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html new file mode 100644 index 0000000..e918c90 --- /dev/null +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.html @@ -0,0 +1,49 @@ +
+ + +
+ + +
+

Supervision des capteurs

+

État de santé par capteur et par site

+
+
+ + @if (error(); as message) { + + } + + @if (data(); as d) { +
+ @for (site of d.sites; track site.site_id) { + +
+ {{ site.site_name }} + {{ site.overall }} +
+ +
    + @for (entry of sensorEntries; track entry[0]) { +
  • + + {{ entry[1] }} + @if (sensorOf(site.sensors, entry[0]).status === 'failing') { + + depuis {{ sensorOf(site.sensors, entry[0]).since | date: 'short' }} + + } +
  • + } +
+
+ } +
+ } +
diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.scss b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.scss new file mode 100644 index 0000000..7af9d32 --- /dev/null +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.scss @@ -0,0 +1,90 @@ +:host { + display: block; + color: var(--color-text); + padding: 2.5rem 2rem; + max-width: 1100px; + margin: 0 auto; +} + +.sensor-status__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 2rem; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + } +} + +.sensor-status__logo { + font-size: 1.3rem; +} + +.sensor-status__subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-muted); +} + +.banner-error { + display: block; + margin: 0 0 1.5rem; +} + +.sites-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} + +.site-card__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.site-card__name { + font-weight: 600; +} + +.sensor-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sensor-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; +} + +.sensor-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &--ok { + background: var(--color-success); + } + &--failing { + background: var(--color-danger); + } +} + +.sensor-item__label { + flex: 1; +} + +.sensor-item__since { + color: var(--color-text-muted); + font-size: 0.75rem; +} diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts new file mode 100644 index 0000000..189e241 --- /dev/null +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts @@ -0,0 +1,99 @@ +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { SensorStatusView } from './sensor-status'; +import { SensorsService } from '../../../core/services/sensors.service'; +import { SiteSensors } from '../../../shared/models/sensor-status.model'; +import {provideRouter} from '@angular/router'; + +const OK_SENSORS: SiteSensors = { + consumption: { status: 'ok', since: null }, + electrical: { status: 'ok', since: null }, + temperature: { status: 'ok', since: null }, + humidity: { status: 'ok', since: null }, + network: { status: 'ok', since: null }, +}; + +describe('SensorStatusView', () => { + let sensorsMock: { getStatus: ReturnType }; + + beforeEach(() => { + sensorsMock = { getStatus: vi.fn() }; + + TestBed.configureTestingModule({ + imports: [SensorStatusView], + providers: [ + { provide: SensorsService, useValue: sensorsMock }, + provideRouter([]), + ], + }); + }); + + it('charge et affiche les données au démarrage', () => { + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [ + { site_id: 'SITE001', site_name: 'Bureau Test', overall: 'ok', sensors: OK_SENSORS }, + ], + }) + ); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.componentInstance.data()?.sites.length).toBe(1); + expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Bureau Test'); + }); + + it("affiche un message d'erreur si l'appel échoue", () => { + sensorsMock.getStatus.mockReturnValue(throwError(() => new Error('boom'))); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.componentInstance.error()).toBe( + 'État des capteurs indisponible, réessayez plus tard.' + ); + expect(fixture.componentInstance.data()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('État des capteurs indisponible'); + }); + + it('associe le bon ton de badge à chaque statut global', () => { + sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] })); + const fixture = TestBed.createComponent(SensorStatusView); + const component = fixture.componentInstance; + + expect(component.badgeToneForOverall('ok')).toBe('success'); + expect(component.badgeToneForOverall('degraded')).toBe('warning'); + expect(component.badgeToneForOverall('critical')).toBe('critical'); + expect(component.badgeToneForOverall('inconnu')).toBe('neutral'); + }); + + it('retourne le bon diagnostic via sensorOf', () => { + sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] })); + const fixture = TestBed.createComponent(SensorStatusView); + const component = fixture.componentInstance; + + expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null }); + }); + + it('affiche la date depuis quand un capteur est en panne', () => { + const sensors: SiteSensors = { + ...OK_SENSORS, + temperature: { status: 'failing', since: '2026-09-18T08:00:00' }, + }; + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }], + }) + ); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('depuis'); + }); +}); diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.ts b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.ts new file mode 100644 index 0000000..13abcc2 --- /dev/null +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.ts @@ -0,0 +1,65 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { catchError, EMPTY, Observable } from 'rxjs'; +import {Badge, BadgeTone} from '../../../shared/components/ui/badge/badge'; +import {Card} from '../../../shared/components/ui/card/card'; +import {Alert} from '../../../shared/components/ui/alert/alert'; +import {Brand} from '../../../shared/components/ui/brand/brand'; +import {SensorsService} from '../../../core/services/sensors.service'; +import {SensorDiagnostic, SensorStatusResponse} from '../../../shared/models/sensor-status.model'; +import { DatePipe } from '@angular/common'; + +const UNAVAILABLE_MESSAGE = 'État des capteurs indisponible, réessayez plus tard.'; + +const SENSOR_LABELS: Record = { + consumption: 'Consommation', + electrical: 'Électrique', + temperature: 'Température', + humidity: 'Humidité', + network: 'Réseau', +}; + +const TON_PAR_OVERALL: Record = { + ok: 'success', + degraded: 'warning', + critical: 'critical', +}; + +@Component({ + selector: 'app-sensor-status', + standalone: true, + imports: [RouterLink, Card, Alert, Badge, Brand, DatePipe], + templateUrl: './sensor-status.html', + styleUrl: './sensor-status.scss', +}) +export class SensorStatusView implements OnInit { + private sensorsService = inject(SensorsService); + + data = signal(null); + error = signal(null); + + readonly sensorEntries = Object.entries(SENSOR_LABELS); + + ngOnInit(): void { + this.sensorsService + .getStatus() + .pipe(catchError(() => this.reportUnavailable())) + .subscribe((response) => { + this.error.set(null); + this.data.set(response); + }); + } + + sensorOf(sensors: Record, key: string): SensorDiagnostic { + return sensors[key]; + } + + badgeToneForOverall(overall: string): BadgeTone { + return TON_PAR_OVERALL[overall] ?? 'neutral'; + } + + private reportUnavailable(): Observable { + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +} diff --git a/apps/frontend/src/app/shared/models/sensor-status.model.ts b/apps/frontend/src/app/shared/models/sensor-status.model.ts new file mode 100644 index 0000000..c10b743 --- /dev/null +++ b/apps/frontend/src/app/shared/models/sensor-status.model.ts @@ -0,0 +1,28 @@ +export type SensorStatus = 'ok' | 'failing'; +export type OverallStatus = 'ok' | 'degraded' | 'critical'; + +export interface SensorDiagnostic { + status: SensorStatus; + since: string | null; +} + +export interface SiteSensors { + consumption: SensorDiagnostic; + electrical: SensorDiagnostic; + temperature: SensorDiagnostic; + humidity: SensorDiagnostic; + network: SensorDiagnostic; + [key: string]: SensorDiagnostic; +} + +export interface SiteSensorStatus { + site_id: string; + site_name: string; + sensors: SiteSensors; + overall: OverallStatus; +} + +export interface SensorStatusResponse { + timestamp: string; + sites: SiteSensorStatus[]; +} From f9c2a4610c25a91c798aca8ec1ba8f2bebaf60fb Mon Sep 17 00:00:00 2001 From: ValentinDeFaria <123947752+ValentinDeFaria@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:56:59 +0200 Subject: [PATCH 2/3] Update dashboard.ts --- apps/frontend/src/app/features/dashboard/dashboard.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index bfc5785..2ba20c0 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -61,7 +61,6 @@ export class Dashboard implements OnInit { private alertsService = inject(AlertsService); public auth = inject(AuthService); private predictionsService = inject(PredictionsService); - private auth = inject(AuthService); private router = inject(Router); private destroyRef = inject(DestroyRef); From 777cd0ac64186c0f1958edac3a1981fe4870abf0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 11:38:43 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(frontend):=20affiche=20since=20comme=20?= =?UTF-8?q?la=20derni=C3=A8re=20lecture=20re=C3=A7ue,=20pas=20comme=20un?= =?UTF-8?q?=20d=C3=A9but=20de=20panne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le schéma backend dit que since est l'horodatage de la dernière lecture du site, identique pour tous ses capteurs en panne et sans rapport avec le début de la panne. Le template annonçait « depuis », ce que l'exploitant lit comme une date de début de panne. Un site sans aucune lecture renvoie ses cinq capteurs en échec avec since à null : le template affichait « depuis » suivi d'une chaîne vide. Ce cas dit maintenant « aucune lecture reçue ». Format de date explicite plutôt que 'short' : aucune locale n'est enregistrée dans app.config.ts, donc 'short' rendait la date au format en-US. --- apps/frontend/src/app/app.routes.ts | 12 ++--- .../src/app/features/dashboard/dashboard.html | 2 +- .../sensor-status/sensor-status.html | 14 ++--- .../sensor-status/sensor-status.spec.ts | 51 ++++++++++++++----- 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index de0315f..40e814f 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -24,10 +24,10 @@ export const routes: Routes = [ import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail), }, { - path: 'monitoring/sensors', - canActivate: [authGuard], - data: { role: 'admin' }, - loadComponent: () => - import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView), -}, + path: 'monitoring/sensors', + canActivate: [authGuard], + data: { role: 'admin' }, + loadComponent: () => + import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView), + }, ]; diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index 2f48539..f9a3fb2 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -12,7 +12,7 @@
@if (auth.principal()?.role === 'admin') { Supervision des capteurs - } + } Voir les sites @for (entry of sensorEntries; track entry[0]) { + @let diagnostic = sensorOf(site.sensors, entry[0]);
  • - + {{ entry[1] }} - @if (sensorOf(site.sensors, entry[0]).status === 'failing') { + @if (diagnostic.status === 'failing') { - depuis {{ sensorOf(site.sensors, entry[0]).since | date: 'short' }} + @if (diagnostic.since; as since) { + dernière lecture le {{ since | date: 'dd/MM/yyyy HH:mm' }} + } @else { + aucune lecture reçue + } }
  • diff --git a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts index 189e241..2e6e84e 100644 --- a/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts +++ b/apps/frontend/src/app/features/monitoring/sensor-status/sensor-status.spec.ts @@ -79,21 +79,44 @@ describe('SensorStatusView', () => { expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null }); }); - it('affiche la date depuis quand un capteur est en panne', () => { - const sensors: SiteSensors = { - ...OK_SENSORS, - temperature: { status: 'failing', since: '2026-09-18T08:00:00' }, - }; - sensorsMock.getStatus.mockReturnValue( - of({ - timestamp: '2026-09-18T08:00:00', - sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }], - }) - ); + it('affiche la date de la dernière lecture reçue pour un capteur en panne', () => { + const sensors: SiteSensors = { + ...OK_SENSORS, + temperature: { status: 'failing', since: '2026-09-18T08:00:00' }, + }; + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }], + }) + ); - const fixture = TestBed.createComponent(SensorStatusView); - fixture.detectChanges(); + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('depuis'); + expect(fixture.nativeElement.textContent).toContain('dernière lecture le'); + expect(fixture.nativeElement.textContent).toContain('18/09/2026 08:00'); + }); + + it("annonce l'absence de lecture quand un site n'en a jamais reçu", () => { + const sensors: SiteSensors = { + consumption: { status: 'failing', since: null }, + electrical: { status: 'failing', since: null }, + temperature: { status: 'failing', since: null }, + humidity: { status: 'failing', since: null }, + network: { status: 'failing', since: null }, + }; + sensorsMock.getStatus.mockReturnValue( + of({ + timestamp: '2026-09-18T08:00:00', + sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'critical', sensors }], + }) + ); + + const fixture = TestBed.createComponent(SensorStatusView); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('aucune lecture reçue'); + expect(fixture.nativeElement.textContent).not.toContain('dernière lecture le'); }); });