feat(frontend): widget app-alert-feed filtrable, rafraîchi toutes les 60 s
Flux des alertes actives sur GET /alerts : filtres site et sévérité posés en paramètres de requête (l'API les accepte), couleur et badge par sévérité, icône par type, nom du site résolu depuis GET /sites, mesure et seuil avec leur unité. Rafraîchissement par timer(0, 60 s) relancé à chaque changement de filtre, états chargement / vide / indisponible, pagination côté client par dix car l'API ne pagine pas. L'input `siteId` fige le site et masque son filtre, pour la vue détail d'un site.
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
<section class="alert-feed">
|
||||||
|
<header class="alert-feed__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="alert-feed__title">Alertes actives</h2>
|
||||||
|
@if (!loading() || alerts().length > 0) {
|
||||||
|
<p class="alert-feed__count">
|
||||||
|
{{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="alert-feed__filters">
|
||||||
|
@if (!siteId()) {
|
||||||
|
<label class="alert-feed__filter">
|
||||||
|
<span class="form-label">Site</span>
|
||||||
|
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
|
||||||
|
<option value="">Tous les sites</option>
|
||||||
|
@for (site of sites(); track site.site_id) {
|
||||||
|
<option [value]="site.site_id">{{ site.site_name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label class="alert-feed__filter">
|
||||||
|
<span class="form-label">Sévérité</span>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
data-testid="severity-filter"
|
||||||
|
(change)="onSeverityChange($event)"
|
||||||
|
>
|
||||||
|
<option value="">Toutes</option>
|
||||||
|
@for (severite of severites; track severite) {
|
||||||
|
<option [value]="severite">{{ severityLabel(severite) }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="alert-feed__banner">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loading() && alerts().length === 0 && !error()) {
|
||||||
|
<p class="alert-feed__state" aria-live="polite">Chargement des alertes…</p>
|
||||||
|
} @else if (alerts().length === 0 && !error()) {
|
||||||
|
<ev-alert severity="success" class="alert-feed__banner"
|
||||||
|
>Aucune alerte pour ces critères.</ev-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ul class="alert-feed__list" [attr.aria-busy]="loading()">
|
||||||
|
@for (alert of visibleAlerts(); track alert.alert_id) {
|
||||||
|
<li class="alert-feed__item" [class]="'alert-feed__item--' + alert.severity">
|
||||||
|
<span class="alert-feed__icon">
|
||||||
|
<ev-icon [name]="alert.type" [label]="typeLabel(alert.type)" />
|
||||||
|
</span>
|
||||||
|
<div class="alert-feed__body">
|
||||||
|
<div class="alert-feed__meta">
|
||||||
|
<ev-badge [tone]="toneFor(alert.severity)">{{
|
||||||
|
severityLabel(alert.severity)
|
||||||
|
}}</ev-badge>
|
||||||
|
<span class="alert-feed__type">{{ typeLabel(alert.type) }}</span>
|
||||||
|
<span class="alert-feed__site">{{ siteName(alert.site_id) }}</span>
|
||||||
|
<time class="alert-feed__time" [attr.datetime]="alert.timestamp">
|
||||||
|
{{ alert.timestamp | date: 'dd/MM/yyyy HH:mm' }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p class="alert-feed__message">{{ alert.message }}</p>
|
||||||
|
@if (alert.value !== null) {
|
||||||
|
<p class="alert-feed__values">
|
||||||
|
<strong>{{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }}</strong>
|
||||||
|
@if (alert.threshold !== null) {
|
||||||
|
<span
|
||||||
|
>seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (hiddenCount() > 0) {
|
||||||
|
<ev-button
|
||||||
|
variant="secondary"
|
||||||
|
[fullWidth]="false"
|
||||||
|
class="alert-feed__more"
|
||||||
|
data-testid="show-more"
|
||||||
|
(click)="showMore()"
|
||||||
|
>
|
||||||
|
Afficher plus ({{ hiddenCount() }} restantes)
|
||||||
|
</ev-button>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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> = {}): 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<typeof vi.fn> },
|
||||||
|
sitesMock: { getSites: ReturnType<typeof vi.fn> } = {
|
||||||
|
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<AlertFeed>) {
|
||||||
|
fixture.detectChanges();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function texte(fixture: ComponentFixture<AlertFeed>): string {
|
||||||
|
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function choisir(fixture: ComponentFixture<AlertFeed>, 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null>(null);
|
||||||
|
|
||||||
|
readonly severites = SEVERITES;
|
||||||
|
severity = signal<AlertSeverity | null>(null);
|
||||||
|
siteFilter = signal<string | null>(null);
|
||||||
|
|
||||||
|
alerts = signal<Alert[]>([]);
|
||||||
|
loading = signal(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
visibleCount = signal(PAGE_SIZE);
|
||||||
|
|
||||||
|
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
|
||||||
|
initialValue: [] as Site[],
|
||||||
|
});
|
||||||
|
|
||||||
|
private filters = computed<AlertFilters>(() => ({
|
||||||
|
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<never> {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user