Compare commits
7
Commits
pr118-head
...
pr107-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26f834485c | ||
|
|
777cd0ac64 | ||
|
|
f3ea2785b3 | ||
|
|
901ceffd72 | ||
|
|
f9c2a4610c | ||
|
|
b5fa7b0010 | ||
|
|
7f710c9084 |
@@ -0,0 +1,84 @@
|
||||
name: Airflow
|
||||
|
||||
# Piège : la version de Python vient de etl/airflow/.python-version. C'est 3.12 et non 3.14
|
||||
# (contrairement à backend.yml et ml.yml) : apache-airflow 2.10 ne supporte pas 3.14. Le 3.14 de
|
||||
# ml/ ne vit que dans l'image Docker, dans son propre environnement (cf. etl/airflow/Dockerfile).
|
||||
#
|
||||
# Piège : l'image COPY les fichiers de dépendances et le code de ml/. Une modification de ml/
|
||||
# peut donc casser sa construction, d'où ces chemins dans les déclencheurs.
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "etl/airflow/**"
|
||||
- "ml/pyproject.toml"
|
||||
- "ml/uv.lock"
|
||||
- "ml/enervision_ml/**"
|
||||
- ".github/workflows/airflow.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "etl/airflow/**"
|
||||
- "ml/pyproject.toml"
|
||||
- "ml/uv.lock"
|
||||
- "ml/enervision_ml/**"
|
||||
- ".github/workflows/airflow.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: airflow-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verification:
|
||||
name: Lint et intégrité des DAGs
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: etl/airflow
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Installe uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: etl/airflow/uv.lock
|
||||
|
||||
- name: Installe l'interpréteur déclaré par .python-version
|
||||
run: uv python install
|
||||
|
||||
- name: Synchronise les dépendances sans dévier du verrou
|
||||
run: uv sync --all-groups --frozen
|
||||
|
||||
- name: Vérifie le formatage
|
||||
run: uv run ruff format --check .
|
||||
|
||||
- name: Analyse statique
|
||||
run: uv run ruff check --output-format=github .
|
||||
|
||||
# Aucun test ne lance de tâche ni de scheduler : DagBag charge les fichiers de dags/ et
|
||||
# vérifie import, planification, plafonds d'exécution et commande de chaque tâche.
|
||||
- name: Tests d'intégrité des DAGs
|
||||
run: uv run pytest
|
||||
|
||||
image:
|
||||
name: Construction de l'image
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Récupère le dépôt
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Construit l'image (contexte à la racine, elle COPY ml/)
|
||||
run: docker build -f etl/airflow/Dockerfile -t enervision-airflow:ci .
|
||||
|
||||
# Vérifie ce qui ne casse qu'à l'exécution, pas à la construction : libgomp1 absent
|
||||
# (`OSError: libgomp.so.1` au premier import) ou environnement ml/ non figé.
|
||||
- name: Vérifie que le pipeline ML s'importe sans réseau
|
||||
run: >
|
||||
docker run --rm --network none enervision-airflow:ci
|
||||
bash -c "cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train --help"
|
||||
@@ -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[];
|
||||
}
|
||||
+29
-16
@@ -16,17 +16,20 @@ x-airflow-common: &airflow-common
|
||||
environment: &airflow-common-env
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:?}
|
||||
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:?}
|
||||
# Piege : pas de `:?` sur les secrets Airflow. Compose interpole le fichier entier avant de
|
||||
# filtrer les services : une variable requise manquante casserait aussi `make db-up`,
|
||||
# `make dev`... pour quiconque n'a pas encore complete son `.env`. Le refus est porte par
|
||||
# `airflow-init` (ci-dessous), dont `webserver` et `scheduler` dependent.
|
||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
|
||||
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW_WEBSERVER_SECRET_KEY:-}
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/airflow
|
||||
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003/CLAUDE.md) :
|
||||
# Role `enervision_ml` dedie pas encore provisionne (dette assumee, cf. ADR 0003) :
|
||||
# memes identifiants que le backend en attendant.
|
||||
ML_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
MLFLOW_TRACKING_URI: sqlite:////opt/ml/state/mlflow.db
|
||||
volumes:
|
||||
- ./etl/airflow/dags:/opt/airflow/dags
|
||||
- ./etl/airflow/plugins:/opt/airflow/plugins
|
||||
- ./etl/airflow/include:/opt/airflow/include
|
||||
- airflow_logs:/opt/airflow/logs
|
||||
- airflow_ml_state:/opt/ml/state
|
||||
restart: unless-stopped
|
||||
@@ -98,25 +101,35 @@ services:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
restart: unless-stopped
|
||||
|
||||
# Conteneur unique, jamais redemarre : migre la base de metadonnees puis cree le premier compte
|
||||
# (idempotent, `|| true` sur la creation qui echoue si le compte existe deja). `webserver` et
|
||||
# `scheduler` attendent qu'il se termine avec succes avant de demarrer.
|
||||
# Conteneur unique, jamais redemarre. La migration et la creation du premier compte sont
|
||||
# portees par l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`, `_AIRFLOW_WWW_USER_*`), qui porte
|
||||
# aussi leur code de sortie : une migration ratee (ex. base `airflow` absente sur un volume
|
||||
# `pgdata` deja peuple) fait echouer ce service, et `webserver`/`scheduler`, qui attendent son
|
||||
# succes, ne demarrent pas sur une base non migree. Le mot de passe passe par l'environnement,
|
||||
# jamais par `argv` (ni `ps`, ni `docker compose config`).
|
||||
# Sans mot de passe, l'entrypoint refuse lui-meme de creer le compte ; la commande ci-dessous
|
||||
# refuse en plus les deux cles de chiffrement vides.
|
||||
airflow-init:
|
||||
<<: *airflow-common
|
||||
restart: "no"
|
||||
environment:
|
||||
<<: *airflow-common-env
|
||||
_AIRFLOW_DB_MIGRATE: "true"
|
||||
_AIRFLOW_WWW_USER_CREATE: "true"
|
||||
_AIRFLOW_WWW_USER_USERNAME: ${AIRFLOW_ADMIN_USERNAME:-admin}
|
||||
_AIRFLOW_WWW_USER_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-}
|
||||
_AIRFLOW_WWW_USER_EMAIL: ${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
airflow db migrate
|
||||
airflow users create \
|
||||
--username "${AIRFLOW_ADMIN_USERNAME:-admin}" \
|
||||
--password "${AIRFLOW_ADMIN_PASSWORD:?}" \
|
||||
--firstname Admin \
|
||||
--lastname EnerVision \
|
||||
--role Admin \
|
||||
--email "${AIRFLOW_ADMIN_EMAIL:-admin@enervision.fr}" \
|
||||
|| true
|
||||
set -euo pipefail
|
||||
: "$${AIRFLOW__CORE__FERNET_KEY:?AIRFLOW_FERNET_KEY manquant dans .env}"
|
||||
: "$${AIRFLOW__WEBSERVER__SECRET_KEY:?AIRFLOW_WEBSERVER_SECRET_KEY manquant dans .env}"
|
||||
exec airflow version
|
||||
|
||||
airflow-webserver:
|
||||
<<: *airflow-common
|
||||
|
||||
@@ -40,13 +40,15 @@ seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste ave
|
||||
des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le
|
||||
port 8000, ils ne se lancent donc pas ensemble.
|
||||
|
||||
Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
|
||||
Trois pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
|
||||
|
||||
- `PGDATA` vaut `/home/postgres/pgdata/data` pour l'image `-ha`, et non le chemin habituel de
|
||||
l'image `postgres`. Monté ailleurs, le volume ne retient rien, sans le moindre message.
|
||||
- `db/init` est monté **fichier par fichier**. Monter le dossier masquerait les scripts d'init de
|
||||
l'image, dont `timescaledb-tune`. Ajouter un fichier dans `db/init/` impose donc une ligne dans
|
||||
le compose. Voir [`db/README.md`](../../db/README.md).
|
||||
- `LocalExecutor` exécute les tâches comme sous-processus du **scheduler**, jamais du webserver :
|
||||
c'est le scheduler qui a besoin du volume `airflow_ml_state` (modèle, magasin MLflow).
|
||||
|
||||
### Airflow (`ml_train`/`ml_score`, issue #115)
|
||||
|
||||
@@ -65,6 +67,27 @@ synchroniser un second environnement Python **3.14** (`/opt/ml/.venv`, `uv sync
|
||||
construction), distinct du Python 3.12 qui fait tourner Airflow lui-même. Les DAGs shellent vers
|
||||
ce venv plutôt que d'importer LightGBM/MLflow dans le process Airflow.
|
||||
|
||||
`airflow-init` s'appuie sur l'entrypoint de l'image (`_AIRFLOW_DB_MIGRATE`,
|
||||
`_AIRFLOW_WWW_USER_*`) plutôt que sur un script maison : l'entrypoint porte le code de sortie, une
|
||||
migration ratée (typiquement la base `airflow` absente, cf. ci-dessous) fait échouer le service et
|
||||
`webserver`/`scheduler` ne démarrent pas sur une base non migrée. Le mot de passe du compte admin
|
||||
passe par l'environnement, jamais par `argv` (ni `ps`, ni `docker compose config`).
|
||||
|
||||
Les variables `AIRFLOW_*` ne sont volontairement pas en `${VAR:?}` : Compose interpole le fichier
|
||||
entier avant de filtrer les services, une variable requise manquante casserait `make db-up`,
|
||||
`make dev`... pour tout poste dont le `.env` est antérieur. Elles valent `${VAR:-}` et c'est
|
||||
`airflow-init` qui refuse de démarrer (clé Fernet, clé Flask ou mot de passe vides).
|
||||
|
||||
**Pourquoi `ml_train` est manuel.** Réentraîner est coûteux et sa cadence n'est pas une décision
|
||||
prise. Surtout, `train.py` écrase le modèle sans comparer ses métriques à celles de l'ancien : un
|
||||
cron déploierait silencieusement un modèle dégradé. Tant que ce garde-fou n'existe pas, le
|
||||
déclenchement reste humain. `ml_score`, lui, est planifié à l'heure, avec `max_active_runs=1`
|
||||
(pas deux scorings simultanés dans `prediction`), 2 tentatives et un plafond de 30 minutes.
|
||||
|
||||
CI : `.github/workflows/airflow.yml` (Python 3.12 via `etl/airflow/.python-version`) lance lint et
|
||||
tests d'intégrité des DAGs, et construit l'image (elle `COPY` `ml/`, une modification de `ml/`
|
||||
peut donc la casser) avant de vérifier que le pipeline s'y importe sans réseau.
|
||||
|
||||
Piège à connaître : sur un volume `pgdata` déjà peuplé (poste de dev existant plutôt que premier
|
||||
`make db-up`), `db/init/120-airflow-database.sql` ne se rejoue pas (PostgreSQL n'exécute
|
||||
`docker-entrypoint-initdb.d/` que sur un volume vide). Créer la base `airflow` à la main une fois :
|
||||
|
||||
@@ -256,8 +256,8 @@ auraient pu comparer des lectures/choisir une prévision au hasard. `_detect_spi
|
||||
explicitement les paires de lectures qui partagent le même horodatage (deux `source` pour un seul
|
||||
instant réel, pas une variation).
|
||||
|
||||
Comme `enervision_ml.score`, la détection est un script lancé à la main, pas encore ordonnancé par
|
||||
Airflow : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans
|
||||
La détection est un script lancé à la main, pas encore ordonnancé par Airflow (contrairement à
|
||||
`enervision_ml.score`, orchestré par le DAG `ml_score` depuis l'issue #115) : `uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...]`, dans
|
||||
`apps/backend` puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une
|
||||
connexion SQL directe (contrairement à `app/etl/historical_import.py`). Cette issue (#104)
|
||||
débloquait #38 (moteur de règles pour recommandations), dont la FK `alert_id` `NOT NULL` n'avait
|
||||
|
||||
@@ -57,7 +57,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu
|
||||
| **API10 Unsafe Consumption of APIs** | **partiel, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse est traitée comme une entrée hostile par `app/etl/mock_api_import.py`, son seul consommateur à ce jour : les quatre garde-fous attendus sont en place, voir la ligne correspondante plus haut. Reste ouvert : le plafond de taille s'applique après désérialisation de la réponse, borner le corps HTTP lui-même demanderait une lecture en flux ; et `APP_MOCK_API_BASE_URL` n'impose pas `https`, donc les identifiants Basic partiraient en clair sur une URL en `http`. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
||||
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |
|
||||
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
|
||||
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. |
|
||||
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. Plus coûteuse depuis Airflow (#115) : ce service publie le port 8080, détient les identifiants Postgres complets (`ML_DATABASE_URL`, mêmes que le backend) et permet de déclencher l'exécution de code depuis son interface. Un compte Airflow compromis atteint donc toute la base, pas seulement `reading`/`site`. Le compte admin Airflow est distinct des `app_user` et son mot de passe passe par l'environnement, jamais par `argv`. |
|
||||
| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. |
|
||||
|
||||
## Ce qu'il faut répondre, et ne pas répondre
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -1,8 +1,7 @@
|
||||
# Image Airflow EnerVision : ajoute le projet ml/ dans son propre environnement Python 3.14,
|
||||
# distinct du Python 3.12 qui fait tourner Airflow lui-meme, pour que les DAGs puissent lancer
|
||||
# `uv run python -m enervision_ml.train`/`.score` en sous-processus (cf. docs/architecture/
|
||||
# 20-backend.md, section Détection d'alertes internes pour le meme raisonnement applique a
|
||||
# app/detection). Airflow ne devient jamais un consommateur direct de LightGBM/MLflow.
|
||||
# distinct du Python 3.12 qui fait tourner Airflow lui-meme (apache-airflow 2.10 ne supporte pas
|
||||
# 3.14), pour que les DAGs puissent lancer `uv run python -m enervision_ml.train`/`.score` en
|
||||
# sous-processus. Airflow ne devient jamais un consommateur direct de LightGBM/MLflow.
|
||||
FROM apache/airflow:2.10.4-python3.12
|
||||
|
||||
# LightGBM est compile contre libgomp (OpenMP), absent de l'image de base (minimale, sans
|
||||
|
||||
@@ -8,7 +8,7 @@ ce DAG ne reentraine jamais rien. Si aucun modele n'a encore ete entraine, la ta
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from airflow.models.dag import DAG
|
||||
from airflow.operators.bash import BashOperator
|
||||
@@ -21,13 +21,21 @@ with DAG(
|
||||
schedule="@hourly",
|
||||
start_date=datetime(2026, 1, 1),
|
||||
catchup=False,
|
||||
# Deux scorings qui se chevauchent inseraient en meme temps dans `prediction` (pas de contrainte
|
||||
# d'unicite sur `(site_id, target_at)`, chaque run garde sa ligne).
|
||||
max_active_runs=1,
|
||||
tags=["ml"],
|
||||
) as dag:
|
||||
# `--frozen --no-dev` : cf. `ml_train.py`, meme raisonnement.
|
||||
# `--no-sync`, `env -u VIRTUAL_ENV` : cf. `ml_train.py`, meme raisonnement.
|
||||
BashOperator(
|
||||
task_id="score",
|
||||
bash_command=(
|
||||
"cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.score "
|
||||
"cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.score "
|
||||
f"--model {MODEL_PATH}"
|
||||
),
|
||||
# Un incident transitoire sur Postgres ne doit pas faire perdre le creneau horaire.
|
||||
retries=2,
|
||||
retry_delay=timedelta(minutes=2),
|
||||
# Bien en dessous du pas horaire : un scoring pendu ne doit pas empieter sur le suivant.
|
||||
execution_timeout=timedelta(minutes=30),
|
||||
)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""DAG d'entrainement du modele LightGBM (issue #115).
|
||||
|
||||
Pas de planification : reentrainer est couteux et sa cadence n'est pas une decision prise
|
||||
(cf. `docs/architecture/20-backend.md`). Declenchement manuel depuis l'UI ou la CLI Airflow en
|
||||
attendant. `ml_score` (DAG separe, planifie toutes les heures) reutilise le modele que ce DAG
|
||||
ecrit, il ne reentraine jamais rien lui-meme.
|
||||
Pas de planification : reentrainer est couteux et sa cadence n'est pas une decision prise, en
|
||||
particulier tant que `train.py` ecrase le modele sans comparer ses metriques a l'ancien (cf.
|
||||
`docs/architecture/10-infra.md`, section Airflow). Declenchement manuel depuis l'UI ou la CLI
|
||||
Airflow en attendant. `ml_score` (DAG separe, planifie toutes les heures) reutilise le modele que
|
||||
ce DAG ecrit, il ne reentraine jamais rien lui-meme.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from airflow.models.dag import DAG
|
||||
from airflow.operators.bash import BashOperator
|
||||
@@ -22,15 +23,21 @@ with DAG(
|
||||
schedule=None,
|
||||
start_date=datetime(2026, 1, 1),
|
||||
catchup=False,
|
||||
# Deux entrainements simultanes ecriraient le meme fichier modele.
|
||||
max_active_runs=1,
|
||||
tags=["ml"],
|
||||
) as dag:
|
||||
# `--frozen --no-dev` : l'environnement `/opt/ml/.venv` est fige a la construction de l'image
|
||||
# (groupe `dev` exclu). Sans `--no-dev` ici, `uv run` resynchronise ruff/mypy a chaque
|
||||
# execution : un acces reseau evitable, sur le chemin d'execution d'une tache planifiee.
|
||||
# `--no-sync` : l'environnement `/opt/ml/.venv` est fige a la construction de l'image, `uv run`
|
||||
# ne le resynchronise pas (sinon `enervision-ml` est reconstruit a chaque tache).
|
||||
# `env -u VIRTUAL_ENV` : l'image de base positionne celui d'Airflow, que `uv` signale a chaque
|
||||
# execution sans qu'il change quoi que ce soit.
|
||||
BashOperator(
|
||||
task_id="train",
|
||||
bash_command=(
|
||||
"cd /opt/ml && uv run --frozen --no-dev python -m enervision_ml.train "
|
||||
"cd /opt/ml && env -u VIRTUAL_ENV uv run --no-sync python -m enervision_ml.train "
|
||||
f"--model-output {MODEL_PATH} --mlflow-tracking-uri {MLFLOW_TRACKING_URI}"
|
||||
),
|
||||
# Un entrainement complet dure quelques minutes ; une connexion pendue ne doit pas
|
||||
# immobiliser un slot du scheduler indefiniment.
|
||||
execution_timeout=timedelta(hours=1),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests d'integrite des DAGs : s'importent sans erreur, structure attendue. Pas d'execution
|
||||
reelle des taches (ca reclamerait le conteneur avec `uv`/`enervision_ml`), juste la definition."""
|
||||
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -22,14 +23,12 @@ def test_every_expected_dag_is_discovered(dagbag: DagBag) -> None:
|
||||
assert set(dagbag.dag_ids) == {"ml_train", "ml_score"}
|
||||
|
||||
|
||||
def test_ml_train_has_no_schedule() -> None:
|
||||
dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
||||
def test_ml_train_has_no_schedule(dagbag: DagBag) -> None:
|
||||
assert dagbag.dags["ml_train"].timetable.summary == "None"
|
||||
|
||||
|
||||
def test_ml_score_runs_every_hour() -> None:
|
||||
def test_ml_score_runs_every_hour(dagbag: DagBag) -> None:
|
||||
# `@hourly` est un alias Airflow pour ce cron, c'est sous cette forme que `.summary` le rend.
|
||||
dagbag = DagBag(dag_folder=str(DAGS_FOLDER), include_examples=False)
|
||||
assert dagbag.dags["ml_score"].timetable.summary == "0 * * * *"
|
||||
|
||||
|
||||
@@ -50,3 +49,34 @@ def test_ml_score_reuses_the_model_path_written_by_ml_train(dagbag: DagBag) -> N
|
||||
|
||||
assert chemin_modele in entrainement
|
||||
assert chemin_modele in scoring
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dag_id", ["ml_train", "ml_score"])
|
||||
def test_no_two_runs_of_a_dag_overlap(dagbag: DagBag, dag_id: str) -> None:
|
||||
# Deux entrainements ecriraient le meme fichier modele, deux scorings inseriraient en meme
|
||||
# temps dans `prediction`.
|
||||
assert dagbag.dags[dag_id].max_active_runs == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("dag_id", "task_id"), [("ml_train", "train"), ("ml_score", "score")])
|
||||
def test_every_task_has_an_execution_timeout(dagbag: DagBag, dag_id: str, task_id: str) -> None:
|
||||
# Sans plafond, une connexion pendue immobilise un slot du scheduler indefiniment.
|
||||
assert dagbag.dags[dag_id].get_task(task_id).execution_timeout is not None
|
||||
|
||||
|
||||
def test_ml_score_execution_timeout_stays_below_its_hourly_step(dagbag: DagBag) -> None:
|
||||
timeout = dagbag.dags["ml_score"].get_task("score").execution_timeout
|
||||
assert timeout is not None
|
||||
assert timeout < timedelta(hours=1)
|
||||
|
||||
|
||||
def test_ml_score_retries_after_a_transient_failure(dagbag: DagBag) -> None:
|
||||
assert dagbag.dags["ml_score"].get_task("score").retries >= 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("dag_id", "task_id"), [("ml_train", "train"), ("ml_score", "score")])
|
||||
def test_tasks_never_resync_the_baked_environment(
|
||||
dagbag: DagBag, dag_id: str, task_id: str
|
||||
) -> None:
|
||||
# Sans `--no-sync`, `uv run` reconstruit `enervision-ml` a chaque execution.
|
||||
assert "--no-sync" in dagbag.dags[dag_id].get_task(task_id).bash_command
|
||||
|
||||
Reference in New Issue
Block a user