diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html new file mode 100644 index 0000000..12cb095 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.html @@ -0,0 +1,95 @@ +
+
+
+

Alertes actives

+ @if (!loading() || alerts().length > 0) { +

+ {{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }} +

+ } +
+
+ @if (!siteId()) { + + } + +
+
+ + @if (error(); as message) { + {{ message }} + } + + @if (loading() && alerts().length === 0 && !error()) { +

Chargement des alertes…

+ } @else if (alerts().length === 0 && !error()) { + Aucune alerte pour ces critères. + } + + + + @if (hiddenCount() > 0) { + + Afficher plus ({{ hiddenCount() }} restantes) + + } +
diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss new file mode 100644 index 0000000..f5aa194 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.scss @@ -0,0 +1,154 @@ +:host { + display: block; +} + +.alert-feed__header { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.alert-feed__title { + margin: 0; + font-size: var(--font-size-lg); + font-weight: 600; +} + +.alert-feed__count { + margin: 0.15rem 0 0; + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +.alert-feed__filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.alert-feed__filter { + display: flex; + flex-direction: column; + min-width: 10rem; + + .form-label { + margin-top: 0; + } +} + +.alert-feed__banner { + display: block; + margin-bottom: var(--space-3); +} + +.alert-feed__state { + margin: 0 0 var(--space-3); + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +.alert-feed__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.alert-feed__item { + display: flex; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + background: var(--color-surface); + border: 1px solid var(--color-border-light); + border-left: 4px solid var(--color-border); +} + +.alert-feed__item--medium { + border-left-color: var(--color-warning); + background: var(--color-warning-bg); + + .alert-feed__icon { + color: var(--color-warning-text); + } +} + +.alert-feed__item--high { + border-left-color: var(--color-danger); + background: var(--color-danger-bg); + + .alert-feed__icon { + color: var(--color-danger); + } +} + +.alert-feed__item--critical { + border-left-color: var(--color-critical); + background: var(--color-danger-bg); + + .alert-feed__icon { + color: var(--color-critical); + } +} + +.alert-feed__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + background: var(--color-surface); + color: var(--color-text-muted); + font-size: var(--font-size-lg); + box-shadow: var(--shadow-card); +} + +.alert-feed__body { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.alert-feed__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.alert-feed__type { + font-weight: 600; + color: var(--color-text); +} + +.alert-feed__message { + margin: 0; + font-size: var(--font-size-sm); +} + +.alert-feed__values { + display: flex; + gap: var(--space-2); + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + + strong { + color: var(--color-text); + } +} + +.alert-feed__more { + display: inline-block; + margin-top: var(--space-3); +} diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts new file mode 100644 index 0000000..e2068ec --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.spec.ts @@ -0,0 +1,218 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { of, throwError } from 'rxjs'; +import { AlertFeed } from './alert-feed'; +import { AlertsService } from '../../../core/services/alerts.service'; +import { SitesService } from '../../../core/services/sites.service'; +import { Alert } from '../../models/alert.model'; +import { Site } from '../../models/site.model'; + +const SITES: Site[] = [ + { + site_id: 'SITE001', + site_name: 'Usine Nantes', + site_type: 'industriel', + location: 'Nantes', + capacity_kw: 500, + status: 'actif', + }, +]; + +function alerte(surcharges: Partial = {}): Alert { + return { + alert_id: 1, + site_id: 'SITE001', + timestamp: '2026-09-15T11:12:00Z', + type: 'spike', + severity: 'critical', + message: 'Variation brutale entre deux lectures consécutives', + value: 812.5, + threshold: 400, + metric: 'consumption_kw', + prediction_id: null, + ...surcharges, + }; +} + +function setup( + alertsMock: { getAlerts: ReturnType }, + sitesMock: { getSites: ReturnType } = { + getSites: vi.fn().mockReturnValue(of(SITES)), + }, +) { + TestBed.configureTestingModule({ + imports: [AlertFeed], + providers: [ + { provide: AlertsService, useValue: alertsMock }, + { provide: SitesService, useValue: sitesMock }, + ], + }); + return TestBed.createComponent(AlertFeed); +} + +function premierChargement(fixture: ComponentFixture) { + fixture.detectChanges(); + vi.advanceTimersByTime(1); + fixture.detectChanges(); +} + +function texte(fixture: ComponentFixture): string { + return (fixture.nativeElement as HTMLElement).textContent ?? ''; +} + +function choisir(fixture: ComponentFixture, testId: string, value: string) { + const select = fixture.nativeElement.querySelector( + `[data-testid="${testId}"]`, + ) as HTMLSelectElement; + select.value = value; + select.dispatchEvent(new Event('change')); + fixture.detectChanges(); + vi.advanceTimersByTime(1); + fixture.detectChanges(); +} + +describe('AlertFeed', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('charge les alertes au démarrage sans filtre et les affiche avec leur contexte', () => { + const getAlerts = vi.fn().mockReturnValue(of([alerte()])); + const fixture = setup({ getAlerts }); + + premierChargement(fixture); + + expect(getAlerts).toHaveBeenCalledTimes(1); + expect(getAlerts.mock.calls[0][0]).toEqual({}); + const contenu = texte(fixture); + expect(contenu).toContain('Usine Nantes'); + expect(contenu).toContain('Critique'); + expect(contenu).toContain('Pic de consommation'); + expect(contenu).toContain('15/09/2026'); + expect(contenu).toContain('812.5 kW'); + expect(contenu).toContain('seuil 400 kW'); + expect(fixture.nativeElement.querySelector('ev-icon svg')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.alert-feed__item--critical')).not.toBeNull(); + }); + + it('annonce le chargement avant la première réponse', () => { + const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) }); + + fixture.detectChanges(); + + expect(fixture.componentInstance.loading()).toBe(true); + expect(texte(fixture)).toContain('Chargement des alertes'); + }); + + it("annonce l'absence d'alerte pour les critères choisis", () => { + const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) }); + + premierChargement(fixture); + + expect(texte(fixture)).toContain('Aucune alerte pour ces critères.'); + expect(fixture.nativeElement.querySelectorAll('li').length).toBe(0); + }); + + it('relance la requête avec la sévérité choisie et revient à la première page', () => { + const getAlerts = vi.fn().mockReturnValue(of([alerte()])); + const fixture = setup({ getAlerts }); + premierChargement(fixture); + fixture.componentInstance.showMore(); + + choisir(fixture, 'severity-filter', 'high'); + + expect(getAlerts).toHaveBeenCalledTimes(2); + expect(getAlerts.mock.calls[1][0]).toEqual({ severity: 'high' }); + expect(fixture.componentInstance.visibleCount()).toBe(10); + }); + + it('relance la requête avec le site choisi dans le filtre', () => { + const getAlerts = vi.fn().mockReturnValue(of([])); + const fixture = setup({ getAlerts }); + premierChargement(fixture); + + choisir(fixture, 'site-filter', 'SITE001'); + + expect(getAlerts.mock.calls[1][0]).toEqual({ site_id: 'SITE001' }); + }); + + it('masque le filtre site et force site_id quand le parent fixe le site', () => { + const getAlerts = vi.fn().mockReturnValue(of([])); + const fixture = setup({ getAlerts }); + fixture.componentRef.setInput('siteId', 'SITE001'); + + premierChargement(fixture); + + expect(getAlerts.mock.calls[0][0]).toEqual({ site_id: 'SITE001' }); + expect(fixture.nativeElement.querySelector('[data-testid="site-filter"]')).toBeNull(); + expect(fixture.nativeElement.querySelector('[data-testid="severity-filter"]')).not.toBeNull(); + }); + + it("signale l'indisponibilité en gardant la liste, puis repart au rafraîchissement suivant", () => { + const getAlerts = vi + .fn() + .mockReturnValueOnce(of([alerte()])) + .mockReturnValueOnce(throwError(() => new Error('API injoignable'))) + .mockReturnValue(of([alerte(), alerte({ alert_id: 2 })])); + const fixture = setup({ getAlerts }); + premierChargement(fixture); + + vi.advanceTimersByTime(60_000); + fixture.detectChanges(); + + expect(getAlerts).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.error()).not.toBeNull(); + expect(fixture.componentInstance.alerts().length).toBe(1); + expect(texte(fixture)).toContain('Alertes indisponibles'); + + vi.advanceTimersByTime(60_000); + fixture.detectChanges(); + + expect(getAlerts).toHaveBeenCalledTimes(3); + expect(fixture.componentInstance.error()).toBeNull(); + expect(fixture.componentInstance.alerts().length).toBe(2); + }); + + it('pagine côté client par dix et dévoile le reste à la demande', () => { + const alertes = Array.from({ length: 25 }, (_, i) => alerte({ alert_id: i + 1 })); + const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(alertes)) }); + premierChargement(fixture); + + expect(fixture.nativeElement.querySelectorAll('li').length).toBe(10); + expect(texte(fixture)).toContain('Afficher plus (15 restantes)'); + + fixture.nativeElement.querySelector('[data-testid="show-more"]').click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('li').length).toBe(20); + + fixture.nativeElement.querySelector('[data-testid="show-more"]').click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('li').length).toBe(25); + expect(fixture.nativeElement.querySelector('[data-testid="show-more"]')).toBeNull(); + }); + + it("replie sur l'identifiant quand le site est inconnu ou que la liste des sites échoue", () => { + const fixture = setup( + { getAlerts: vi.fn().mockReturnValue(of([alerte({ site_id: 'SITE999' })])) }, + { getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, + ); + + premierChargement(fixture); + + expect(texte(fixture)).toContain('SITE999'); + }); + + it("n'affiche pas de mesure pour une alerte sans valeur", () => { + const fixture = setup({ + getAlerts: vi + .fn() + .mockReturnValue( + of([alerte({ type: 'outage', value: null, threshold: null, metric: null })]), + ), + }); + + premierChargement(fixture); + + expect(fixture.nativeElement.querySelector('.alert-feed__values')).toBeNull(); + expect(texte(fixture)).toContain('Coupure'); + }); +}); diff --git a/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts new file mode 100644 index 0000000..91f5d42 --- /dev/null +++ b/apps/frontend/src/app/shared/components/alert-feed/alert-feed.ts @@ -0,0 +1,130 @@ +import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { catchError, EMPTY, Observable, of, switchMap, tap, timer } from 'rxjs'; +import { AlertFilters, AlertsService } from '../../../core/services/alerts.service'; +import { SitesService } from '../../../core/services/sites.service'; +import { Alert, AlertMetric, AlertSeverity, AlertType } from '../../models/alert.model'; +import { Site } from '../../models/site.model'; +import { + LIBELLE_PAR_SEVERITE, + LIBELLE_PAR_TYPE, + SEVERITES, + TON_PAR_SEVERITE, + UNITE_PAR_METRIQUE, +} from '../../models/alert-presentation'; +import { Badge, BadgeTone } from '../ui/badge/badge'; +import { Button } from '../ui/button/button'; +import { Alert as EvAlert } from '../ui/alert/alert'; +import { Icon } from '../ui/icon/icon'; + +// Le DAG de détection tourne toutes les heures : une minute suffit largement pour suivre le flux. +const REFRESH_INTERVAL_MS = 60_000; +const PAGE_SIZE = 10; +const UNAVAILABLE_MESSAGE = 'Alertes indisponibles, la liste affichée date du dernier chargement.'; + +@Component({ + selector: 'app-alert-feed', + standalone: true, + imports: [DatePipe, DecimalPipe, Badge, Button, EvAlert, Icon], + templateUrl: './alert-feed.html', + styleUrl: './alert-feed.scss', +}) +export class AlertFeed { + private alertsService = inject(AlertsService); + private sitesService = inject(SitesService); + private destroyRef = inject(DestroyRef); + + siteId = input(null); + + readonly severites = SEVERITES; + severity = signal(null); + siteFilter = signal(null); + + alerts = signal([]); + loading = signal(true); + error = signal(null); + visibleCount = signal(PAGE_SIZE); + + sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), { + initialValue: [] as Site[], + }); + + private filters = computed(() => ({ + site_id: this.siteId() ?? this.siteFilter() ?? undefined, + severity: this.severity() ?? undefined, + })); + + private siteNameById = computed( + () => new Map(this.sites().map((site) => [site.site_id, site.site_name])), + ); + + visibleAlerts = computed(() => this.alerts().slice(0, this.visibleCount())); + hiddenCount = computed(() => Math.max(this.alerts().length - this.visibleCount(), 0)); + + constructor() { + toObservable(this.filters) + .pipe( + tap(() => { + this.loading.set(true); + this.visibleCount.set(PAGE_SIZE); + }), + // Piège : catchError sur l'observable interne ; sur le flux externe il terminerait le + // timer et le rafraîchissement ne repartirait jamais. + switchMap((filters) => + timer(0, REFRESH_INTERVAL_MS).pipe( + switchMap(() => + this.alertsService + .getAlerts(filters) + .pipe(catchError(() => this.reportUnavailable())), + ), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((alerts) => { + this.loading.set(false); + this.error.set(null); + this.alerts.set(alerts); + }); + } + + onSiteChange(event: Event): void { + this.siteFilter.set((event.target as HTMLSelectElement).value || null); + } + + onSeverityChange(event: Event): void { + const value = (event.target as HTMLSelectElement).value; + this.severity.set(value ? (value as AlertSeverity) : null); + } + + showMore(): void { + this.visibleCount.update((count) => count + PAGE_SIZE); + } + + toneFor(severity: AlertSeverity): BadgeTone { + return TON_PAR_SEVERITE[severity]; + } + + severityLabel(severity: AlertSeverity): string { + return LIBELLE_PAR_SEVERITE[severity]; + } + + typeLabel(type: AlertType): string { + return LIBELLE_PAR_TYPE[type]; + } + + siteName(siteId: string): string { + return this.siteNameById().get(siteId) ?? siteId; + } + + unitFor(metric: AlertMetric | null): string { + return metric ? UNITE_PAR_METRIQUE[metric] : ''; + } + + private reportUnavailable(): Observable { + this.loading.set(false); + this.error.set(UNAVAILABLE_MESSAGE); + return EMPTY; + } +}