Compare commits

..
Author SHA1 Message Date
Johan LEROY 65baf3fddb docs(infra): aligne le README du proxy sur nginx 1.31-alpine
Le tag de l'image y est cité deux fois (présentation, commande nginx -t) ;
il suit celui de docker-compose.prod.yml. Le runner du Dockerfile frontend
est une image durcie dhi.io, suivie séparément, et n'est pas concerné.
2026-09-22 08:38:03 +02:00
dependabot[bot]andGitHub 7f025f72ac chore(deps): bump nginx from 1.28-alpine to 1.31-alpine
Bumps nginx from 1.28-alpine to 1.31-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.31-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 12:17:47 +00:00
PhyriosandGitHub 1cd206adc8 Merge pull request #125 from ineszang/dev
Push dev to main
2026-09-21 14:17:01 +02:00
12 changed files with 69 additions and 215 deletions
+1 -4
View File
@@ -1,4 +1,4 @@
# Conventions de tests unitaires : Frontend
# Conventions de tests unitaires — Frontend
## Outil
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
@@ -83,6 +83,3 @@ describe('MonComposant', () => {
## Lancer les tests
- Développement (mode watch) : `npm test`
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
- Un fichier ou un dossier seulement :
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
@@ -2,63 +2,53 @@ import { Alert } from '../../shared/models/alert.model';
export const ALERTS_FIXTURE: Alert[] = [
{
alert_id: 5,
alert_id: 'ALR-SITE002-1718458320',
timestamp: '2026-09-15T11:12:00',
site_id: 'SITE002',
timestamp: '2026-09-15T11:12:00Z',
type: 'threshold',
severity: 'critical',
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
type: 'outage',
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 4,
alert_id: 'ALR-SITE003-1718458321',
timestamp: '2026-09-15T11:05:00',
site_id: 'SITE003',
timestamp: '2026-09-15T11:05:00Z',
type: 'outage',
severity: 'critical',
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
value: null,
threshold: null,
metric: null,
prediction_id: null,
},
{
alert_id: 3,
site_id: 'SITE005',
timestamp: '2026-09-15T10:47:00Z',
type: 'spike',
severity: 'high',
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
value: 410.0,
threshold: 260.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 2,
site_id: 'SITE006',
timestamp: '2026-09-15T10:30:00Z',
type: 'sensor',
severity: 'medium',
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
value: null,
threshold: null,
metric: null,
prediction_id: null,
message: 'Perte réseau totale sur Data Center Marseille',
value: 0,
threshold: 0,
},
{
alert_id: 1,
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',
timestamp: '2026-09-15T09:58:00Z',
type: 'anomaly',
severity: 'low',
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
type: 'anomaly',
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
value: 62.0,
threshold: 55.0,
metric: 'consumption_kwh',
prediction_id: 42,
},
];
@@ -3,20 +3,6 @@ import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AlertsService } from './alerts.service';
import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model';
const ALERT_API: Alert = {
alert_id: 1,
site_id: 'site-1',
timestamp: '2026-09-16T00:00:00Z',
type: 'threshold',
severity: 'high',
message: 'Dépassement du seuil configuré',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
};
describe('AlertsService', () => {
let service: AlertsService;
@@ -32,35 +18,26 @@ describe('AlertsService', () => {
afterEach(() => httpMock.verify());
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
let result: Alert[] = [];
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
let result: unknown;
service.getAlerts().subscribe((r) => (result = r));
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
);
expect(req.request.params.keys()).toEqual([]);
req.flush([ALERT_API]);
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
expect(req.request.method).toBe('GET');
expect(result.length).toBe(1);
expect(result[0].alert_id).toBe(1);
expect(result[0].prediction_id).toBeNull();
});
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,
},
]);
it('transmet les filtres site_id et severity en paramètres de requête', () => {
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.params.get('severity')).toBe('high');
req.flush([]);
});
it('ne pose pas de paramètre pour un filtre omis', () => {
service.getAlerts({ site_id: 'SITE001' }).subscribe();
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
expect(req.request.params.has('severity')).toBe(false);
req.flush([]);
expect((result as unknown[]).length).toBe(1);
});
});
@@ -1,25 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
export interface AlertFilters {
site_id?: string;
severity?: AlertSeverity;
}
import { Alert } from '../../shared/models/alert.model';
@Service()
export class AlertsService {
private http = inject(HttpClient);
getAlerts(filters: AlertFilters = {}) {
let params = new HttpParams();
if (filters.site_id) {
params = params.set('site_id', filters.site_id);
}
if (filters.severity) {
params = params.set('severity', filters.severity);
}
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
getAlerts() {
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
}
}
@@ -1,37 +0,0 @@
import {
LIBELLE_PAR_SEVERITE,
LIBELLE_PAR_TYPE,
SEVERITES,
TON_PAR_SEVERITE,
TYPES_ALERTE,
UNITE_PAR_METRIQUE,
} from './alert-presentation';
describe('alert-presentation', () => {
it('distingue le ton des sévérités high et critical', () => {
expect(TON_PAR_SEVERITE.high).toBe('danger');
expect(TON_PAR_SEVERITE.critical).toBe('critical');
expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical);
});
it("n'affiche pas une alerte faible avec le ton de succès", () => {
expect(TON_PAR_SEVERITE.low).toBe('neutral');
expect(TON_PAR_SEVERITE.medium).toBe('warning');
});
it('donne un libellé français à chaque sévérité et à chaque type', () => {
for (const severite of SEVERITES) {
expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy();
}
for (const type of TYPES_ALERTE) {
expect(LIBELLE_PAR_TYPE[type]).toBeTruthy();
}
expect(SEVERITES.length).toBe(4);
expect(TYPES_ALERTE.length).toBe(5);
});
it('associe une unité à chaque métrique du contrat', () => {
expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW');
expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh');
});
});
@@ -1,41 +0,0 @@
import { BadgeTone } from '../components/ui/badge/badge';
import { AlertMetric, AlertSeverity, AlertType } from './alert.model';
// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se
// lisait comme « tout va bien » à côté des rouges.
export const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
low: 'neutral',
medium: 'warning',
high: 'danger',
critical: 'critical',
};
export const LIBELLE_PAR_SEVERITE: Record<AlertSeverity, string> = {
low: 'Faible',
medium: 'Moyenne',
high: 'Élevée',
critical: 'Critique',
};
export const LIBELLE_PAR_TYPE: Record<AlertType, string> = {
spike: 'Pic de consommation',
threshold: 'Seuil dépassé',
anomaly: 'Anomalie',
outage: 'Coupure',
sensor: 'Capteur',
};
export const UNITE_PAR_METRIQUE: Record<AlertMetric, string> = {
consumption_kw: 'kW',
consumption_kwh: 'kWh',
};
export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical'];
export const TYPES_ALERTE: readonly AlertType[] = [
'spike',
'threshold',
'anomaly',
'outage',
'sensor',
];
@@ -1,16 +1,13 @@
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
export interface Alert {
alert_id: number;
site_id: string;
alert_id: string;
timestamp: string;
type: AlertType;
site_id: string;
severity: AlertSeverity;
type: AlertType;
message: string;
value: number | null;
threshold: number | null;
metric: AlertMetric | null;
prediction_id: number | null;
value: number;
threshold: number;
}
-15
View File
@@ -29,18 +29,3 @@
color: var(--color-disabled);
margin-top: 0.25rem;
}
// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur
// reprend en dur la valeur de --color-text-muted.
.form-select {
@extend .form-input;
padding-right: 2.25rem;
color: var(--color-text);
background-color: var(--color-surface);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
background-size: 1rem;
appearance: none;
cursor: pointer;
}
-1
View File
@@ -3,7 +3,6 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
+1 -1
View File
@@ -44,7 +44,7 @@ services:
ports: !reset null
proxy:
image: nginx:1.28-alpine
image: nginx:1.31-alpine
depends_on:
backend:
condition: service_healthy
@@ -24,13 +24,12 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
| `--shadow-card` | Ombre portée des cartes |
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`)
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>`/`<select>` natifs, liés par `formControlName` ou
par un simple `(change)` : pas de composant `ControlValueAccessor` dédié, le gain n'en vaut pas la
complexité pour des formulaires aussi simples que ceux de ce projet. `.form-select` habille un
`<select>` natif avec la bordure et le focus de `.form-input`, plus un chevron. Les erreurs de
formulaire, elles, s'affichent via `<ev-alert severity="danger">`, pas une classe dédiée.
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
`<ev-alert severity="danger">`, pas une classe dédiée.
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
+2 -2
View File
@@ -9,7 +9,7 @@ Terminaison TLS et routage de la stack déployée. Seul composant publié sur le
- `tls/` : les deux fichiers que nginx lit, `fullchain.pem` et `privkey.pem`. Ignorés par git.
- `acme-deploy-hook.sh` : recopie le résultat de certbot dans `tls/`.
Pas de `Dockerfile` : l'image officielle `nginx:1.28-alpine` est utilisée telle quelle et la
Pas de `Dockerfile` : l'image officielle `nginx:1.31-alpine` est utilisée telle quelle et la
configuration est montée en volume par `docker-compose.prod.yml`.
L'overlay emploie les marqueurs `!override` et `!reset`, qui demandent **Docker Compose 2.24.4
@@ -82,7 +82,7 @@ docker run --rm \
-v "$PWD/infra/proxy/nginx.conf:/etc/nginx/nginx.conf:ro" \
-v "$PWD/infra/proxy/conf.d:/etc/nginx/conf.d:ro" \
-v "$PWD/infra/proxy/tls:/etc/nginx/tls:ro" \
nginx:1.28-alpine nginx -t
nginx:1.31-alpine nginx -t
```
Monter `infra/proxy/` entier sur `/etc/nginx` échouerait : `mime.types` vient de l'image.