refactor(frontend): aligne la vue détail sur le contrat SiteCurrentResponse
`/sites/{site_id}/current` renvoie un objet toujours présent, sans `reading_id`
ni `source`, avec `timestamp` nullable et `null_reasons` non nullable. La vue
s'appuyait sur une réponse nulle pour détecter l'absence de mesure : elle
s'appuie désormais sur `timestamp`, et affiche `data_quality`, que le contrat
précédent ne portait pas.
`getCurrent()` rejoint `SitesService`, l'endpoint appartenant à `/sites`.
This commit is contained in:
@@ -7,10 +7,6 @@ import { Reading } from '../../shared/models/reading.model';
|
|||||||
export class ReadingsService {
|
export class ReadingsService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
getLatest(siteId: string) {
|
|
||||||
return this.http.get<Reading | null>(`${environment.apiUrl}/sites/${siteId}/current`);
|
|
||||||
}
|
|
||||||
|
|
||||||
getHistory(siteId: string, start?: string, end?: string) {
|
getHistory(siteId: string, start?: string, end?: string) {
|
||||||
let params = new HttpParams().set('site_id', siteId);
|
let params = new HttpParams().set('site_id', siteId);
|
||||||
if (start) {
|
if (start) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Service, inject } from '@angular/core';
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { Site } from '../../shared/models/site.model';
|
import { Site } from '../../shared/models/site.model';
|
||||||
|
import { SiteCurrent } from '../../shared/models/site-current.model';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class SitesService {
|
export class SitesService {
|
||||||
@@ -14,4 +15,8 @@ export class SitesService {
|
|||||||
getSite(siteId: string) {
|
getSite(siteId: string) {
|
||||||
return this.http.get<Site>(`${environment.apiUrl}/sites/${siteId}`);
|
return this.http.get<Site>(`${environment.apiUrl}/sites/${siteId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCurrent(siteId: string) {
|
||||||
|
return this.http.get<SiteCurrent>(`${environment.apiUrl}/sites/${siteId}/current`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,14 @@
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="site-detail__badges">
|
||||||
@if (site(); as s) {
|
@if (site(); as s) {
|
||||||
<ev-badge [tone]="badgeToneForStatus(s.status)">{{ s.status ?? '-' }}</ev-badge>
|
<ev-badge [tone]="badgeToneForStatus(s.status)">{{ s.status ?? '-' }}</ev-badge>
|
||||||
}
|
}
|
||||||
|
@if (hasMeasurement() && qualityLabel(); as label) {
|
||||||
|
<ev-badge [tone]="qualityTone()">{{ label }}</ev-badge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (error(); as message) {
|
@if (error(); as message) {
|
||||||
@@ -27,15 +32,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (site(); as s) {
|
@if (site(); as s) {
|
||||||
|
@if (hasMeasurement()) {
|
||||||
<section class="overview">
|
<section class="overview">
|
||||||
<ev-card class="card card--gauge">
|
<ev-card class="card card--gauge">
|
||||||
<span class="card__label">Consommation vs capacité</span>
|
<span class="card__label">Consommation vs capacité</span>
|
||||||
<app-consumption-gauge
|
<app-consumption-gauge
|
||||||
[consumption]="latestReading()?.consumption_kw ?? 0"
|
[consumption]="current()?.consumption_kw ?? 0"
|
||||||
[capacity]="s.capacity_kw ?? 0"
|
[capacity]="s.capacity_kw ?? 0"
|
||||||
/>
|
/>
|
||||||
<span class="card__value">
|
<span class="card__value">
|
||||||
{{ latestReading()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW
|
{{ current()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW
|
||||||
</span>
|
</span>
|
||||||
</ev-card>
|
</ev-card>
|
||||||
|
|
||||||
@@ -65,6 +71,9 @@
|
|||||||
<app-reading-history-chart [readings]="history()" />
|
<app-reading-history-chart [readings]="history()" />
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
} @else {
|
||||||
|
<ev-alert severity="warning" class="banner-empty">{{ noMeasurementMessage }}</ev-alert>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
||||||
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { catchError, EMPTY, filter, map, Observable, switchMap } from 'rxjs';
|
import { catchError, EMPTY, filter, map, Observable, of, switchMap } from 'rxjs';
|
||||||
import { SitesService } from '../../../core/services/sites.service';
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
import { ReadingsService } from '../../../core/services/readings.service';
|
import { ReadingsService } from '../../../core/services/readings.service';
|
||||||
import { Site } from '../../../shared/models/site.model';
|
import { Site } from '../../../shared/models/site.model';
|
||||||
import { Reading } from '../../../shared/models/reading.model';
|
import { Reading, ReadingDataQuality } from '../../../shared/models/reading.model';
|
||||||
|
import { SiteCurrent } from '../../../shared/models/site-current.model';
|
||||||
import { Card } from '../../../shared/components/ui/card/card';
|
import { Card } from '../../../shared/components/ui/card/card';
|
||||||
import { Alert } from '../../../shared/components/ui/alert/alert';
|
import { Alert } from '../../../shared/components/ui/alert/alert';
|
||||||
import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
|
import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
|
||||||
@@ -14,6 +15,7 @@ import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/c
|
|||||||
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
||||||
|
|
||||||
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
||||||
|
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
||||||
const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const TON_PAR_STATUT: Record<string, BadgeTone> = {
|
const TON_PAR_STATUT: Record<string, BadgeTone> = {
|
||||||
@@ -22,6 +24,20 @@ const TON_PAR_STATUT: Record<string, BadgeTone> = {
|
|||||||
hors_service: 'danger',
|
hors_service: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TON_PAR_QUALITE: Record<ReadingDataQuality, BadgeTone> = {
|
||||||
|
good: 'success',
|
||||||
|
partial: 'warning',
|
||||||
|
degraded: 'danger',
|
||||||
|
critical: 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIBELLE_PAR_QUALITE: Record<ReadingDataQuality, string> = {
|
||||||
|
good: 'Données complètes',
|
||||||
|
partial: 'Données partielles',
|
||||||
|
degraded: 'Données dégradées',
|
||||||
|
critical: 'Données critiques',
|
||||||
|
};
|
||||||
|
|
||||||
type MetricKey =
|
type MetricKey =
|
||||||
| 'consumption_kw'
|
| 'consumption_kw'
|
||||||
| 'voltage_v'
|
| 'voltage_v'
|
||||||
@@ -45,9 +61,8 @@ const METRIC_DEFS: MetricDef[] = [
|
|||||||
{ key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` },
|
{ key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Contrainte : miroir de `RAISON_VERS_CAPTEUR`/`CHAMPS_PAR_CAPTEUR` côté backend
|
// Contrainte : miroir de RAISON_VERS_CAPTEUR et CHAMPS_PAR_CAPTEUR (backend, services/sensor.py) ;
|
||||||
// (apps/backend/app/services/sensor.py) - `null_reasons` porte le code de panne du capteur,
|
// `null_reasons` porte le code de panne du capteur, jamais le nom du champ resté vide.
|
||||||
// jamais le nom du champ.
|
|
||||||
const RAISONS_PAR_CHAMP: Record<MetricKey, string[]> = {
|
const RAISONS_PAR_CHAMP: Record<MetricKey, string[]> = {
|
||||||
consumption_kw: ['consumption_sensor_failure', 'network_loss'],
|
consumption_kw: ['consumption_sensor_failure', 'network_loss'],
|
||||||
voltage_v: ['electrical_sensor_failure', 'network_loss'],
|
voltage_v: ['electrical_sensor_failure', 'network_loss'],
|
||||||
@@ -85,22 +100,36 @@ export class SiteDetail {
|
|||||||
private readingsService = inject(ReadingsService);
|
private readingsService = inject(ReadingsService);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
readonly noMeasurementMessage = NO_MEASUREMENT_MESSAGE;
|
||||||
|
|
||||||
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? '')));
|
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? '')));
|
||||||
|
|
||||||
site = signal<Site | null>(null);
|
site = signal<Site | null>(null);
|
||||||
latestReading = signal<Reading | null>(null);
|
current = signal<SiteCurrent | null>(null);
|
||||||
history = signal<Reading[]>([]);
|
history = signal<Reading[]>([]);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
||||||
|
|
||||||
|
qualityLabel = computed(() => {
|
||||||
|
const quality = this.current()?.data_quality;
|
||||||
|
return quality ? LIBELLE_PAR_QUALITE[quality] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
qualityTone = computed<BadgeTone>(() => {
|
||||||
|
const quality = this.current()?.data_quality;
|
||||||
|
return quality ? TON_PAR_QUALITE[quality] : 'neutral';
|
||||||
|
});
|
||||||
|
|
||||||
metrics = computed<MetricView[]>(() => {
|
metrics = computed<MetricView[]>(() => {
|
||||||
const reading = this.latestReading();
|
const current = this.current();
|
||||||
return METRIC_DEFS.map((def) => {
|
return METRIC_DEFS.map((def) => {
|
||||||
const valeur = reading ? reading[def.key] : null;
|
const valeur = current ? current[def.key] : null;
|
||||||
return {
|
return {
|
||||||
key: def.key,
|
key: def.key,
|
||||||
label: def.label,
|
label: def.label,
|
||||||
value: valeur != null ? def.format(valeur) : null,
|
value: valeur != null ? def.format(valeur) : null,
|
||||||
reason: valeur == null ? this.reasonFor(def.key, reading) : '',
|
reason: valeur == null ? this.reasonFor(def.key, current) : '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -117,7 +146,7 @@ export class SiteDetail {
|
|||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.site.set(result.site);
|
this.site.set(result.site);
|
||||||
this.latestReading.set(result.latest);
|
this.current.set(result.current);
|
||||||
this.history.set(result.history);
|
this.history.set(result.history);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -129,27 +158,29 @@ export class SiteDetail {
|
|||||||
private load(siteId: string) {
|
private load(siteId: string) {
|
||||||
return this.sitesService.getSite(siteId).pipe(
|
return this.sitesService.getSite(siteId).pipe(
|
||||||
switchMap((site) =>
|
switchMap((site) =>
|
||||||
this.readingsService.getLatest(siteId).pipe(map((latest) => ({ site, latest }))),
|
this.sitesService.getCurrent(siteId).pipe(map((current) => ({ site, current }))),
|
||||||
|
),
|
||||||
|
switchMap(({ site, current }) =>
|
||||||
|
this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))),
|
||||||
),
|
),
|
||||||
switchMap(({ site, latest }) => {
|
|
||||||
// Piège : le dataset historique se termine bien avant « maintenant ». Ancrer la
|
|
||||||
// fenêtre sur la dernière mesure connue plutôt que sur l'horloge évite un historique
|
|
||||||
// vide dès que le jeu de données n'est plus récent.
|
|
||||||
const end = latest?.timestamp;
|
|
||||||
const start = end
|
|
||||||
? new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString()
|
|
||||||
: undefined;
|
|
||||||
return this.readingsService
|
|
||||||
.getHistory(siteId, start, end)
|
|
||||||
.pipe(map((history) => ({ site, latest, history })));
|
|
||||||
}),
|
|
||||||
catchError(() => this.reportUnavailable()),
|
catchError(() => this.reportUnavailable()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private reasonFor(field: MetricKey, reading: Reading | null): string {
|
private loadHistory(siteId: string, current: SiteCurrent): Observable<Reading[]> {
|
||||||
|
// Piège : le jeu de données s'arrête bien avant « maintenant » ; ancrer la fenêtre sur la
|
||||||
|
// dernière mesure connue plutôt que sur l'horloge évite un historique systématiquement vide.
|
||||||
|
const end = current.timestamp;
|
||||||
|
if (end === null) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
const start = new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString();
|
||||||
|
return this.readingsService.getHistory(siteId, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reasonFor(field: MetricKey, current: SiteCurrent | null): string {
|
||||||
const raisons = RAISONS_PAR_CHAMP[field];
|
const raisons = RAISONS_PAR_CHAMP[field];
|
||||||
const trouvees = (reading?.null_reasons ?? [])
|
const trouvees = (current?.null_reasons ?? [])
|
||||||
.filter((raison) => raisons.includes(raison))
|
.filter((raison) => raisons.includes(raison))
|
||||||
.map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison);
|
.map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison);
|
||||||
return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue';
|
return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ReadingDataQuality } from './reading.model';
|
||||||
|
|
||||||
|
export interface SiteCurrent {
|
||||||
|
timestamp: string | null;
|
||||||
|
site_id: string;
|
||||||
|
site_type: string;
|
||||||
|
consumption_kw: number | null;
|
||||||
|
consumption_kwh: number | null;
|
||||||
|
voltage_v: number | null;
|
||||||
|
current_a: number | null;
|
||||||
|
power_factor: number | null;
|
||||||
|
temperature_celsius: number | null;
|
||||||
|
humidity_percent: number | null;
|
||||||
|
null_reasons: string[];
|
||||||
|
data_quality: ReadingDataQuality;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user