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[]; +}