Compare commits

...
Author SHA1 Message Date
Johan LEROY 26f834485c Merge branch 'dev' into feat/supervision-des-capteurs 2026-09-21 11:39:00 +02:00
Johan LEROY 777cd0ac64 fix(frontend): affiche since comme la dernière lecture reçue, pas comme un début de panne
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 <date> », 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.
2026-09-21 11:38:52 +02:00
PhyriosandGitHub f3ea2785b3 Merge pull request #118 from ineszang/feat/dag-ml-train-score
feat(etl,ml): orchestre l'entrainement et le scoring LightGBM via deu…
2026-09-21 11:19:43 +02:00
ValentinDeFariaandGitHub f9c2a4610c Update dashboard.ts
Frontend / Audit des dépendances (push) Successful in 6s
SonarQube / build-back (push) Successful in 1m6s
Frontend / build (push) Successful in 9m52s
SonarQube / build-front (push) Successful in 9m44s
SonarQube / test-back (push) Failing after 52s
Frontend / test (push) Failing after 5m0s
SonarQube / test-front (push) Failing after 5m2s
SonarQube / SonarQube (push) Skipped
2026-09-18 16:56:59 +02:00
ValentinDeFariaandGitHub b5fa7b0010 Merge branch 'dev' into feat/supervision-des-capteurs 2026-09-18 16:54:39 +02:00
Valentin 7f710c9084 feat(frontend): supervision des capteurs par site (admin) 2026-09-18 12:02:48 +02:00
12 changed files with 444 additions and 4 deletions
+7
View File
@@ -23,4 +23,11 @@ export const routes: Routes = [
loadComponent: () =>
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),
},
];
@@ -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);
});
});
@@ -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<SensorStatusResponse>(`${environment.apiUrl}/sensors/status`);
}
}
@@ -10,6 +10,9 @@
</div>
</div>
<div class="dashboard__actions">
@if (auth.principal()?.role === 'admin') {
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
}
<a routerLink="/sites" class="ev-link">Voir les sites</a>
<ev-button
class="logout-button"
@@ -68,6 +68,15 @@ h2 {
text-align: center;
}
.card--link {
cursor: pointer;
transition: border-color 0.15s ease;
&:hover {
border-color: var(--color-primary);
}
}
.card__label {
font-size: 0.8rem;
color: var(--color-text-muted);
@@ -170,8 +170,11 @@ describe('Dashboard', () => {
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: [
@@ -198,9 +201,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],
@@ -59,8 +59,8 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
export class Dashboard implements OnInit {
private statsService = inject(StatsService);
private alertsService = inject(AlertsService);
public auth = inject(AuthService);
private predictionsService = inject(PredictionsService);
private auth = inject(AuthService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
@@ -0,0 +1,51 @@
<div class="sensor-status">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
</nav>
<header class="sensor-status__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="sensor-status__logo" />
</a>
<div>
<h1>Supervision des capteurs</h1>
<p class="sensor-status__subtitle">État de santé par capteur et par site</p>
</div>
</header>
@if (error(); as message) {
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
}
@if (data(); as d) {
<div class="sites-grid">
@for (site of d.sites; track site.site_id) {
<ev-card class="site-card">
<div class="site-card__header">
<span class="site-card__name">{{ site.site_name }}</span>
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
</div>
<ul class="sensor-list">
@for (entry of sensorEntries; track entry[0]) {
@let diagnostic = sensorOf(site.sensors, entry[0]);
<li class="sensor-item">
<span class="sensor-dot" [class]="'sensor-dot--' + diagnostic.status"></span>
<span class="sensor-item__label">{{ entry[1] }}</span>
@if (diagnostic.status === 'failing') {
<span class="sensor-item__since">
@if (diagnostic.since; as since) {
dernière lecture le {{ since | date: 'dd/MM/yyyy HH:mm' }}
} @else {
aucune lecture reçue
}
</span>
}
</li>
}
</ul>
</ev-card>
}
</div>
}
</div>
@@ -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;
}
@@ -0,0 +1,122 @@
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<typeof vi.fn> };
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 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();
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');
});
});
@@ -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<string, string> = {
consumption: 'Consommation',
electrical: 'Électrique',
temperature: 'Température',
humidity: 'Humidité',
network: 'Réseau',
};
const TON_PAR_OVERALL: Record<string, BadgeTone> = {
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<SensorStatusResponse | null>(null);
error = signal<string | null>(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<string, SensorDiagnostic>, key: string): SensorDiagnostic {
return sensors[key];
}
badgeToneForOverall(overall: string): BadgeTone {
return TON_PAR_OVERALL[overall] ?? 'neutral';
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -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[];
}