Creation dashboard (graph chart.js) + tests

This commit is contained in:
valentin
2026-09-15 16:48:55 +02:00
parent 0ca429ff3d
commit cdef30736a
31 changed files with 851 additions and 364 deletions
@@ -0,0 +1,48 @@
<div class="dashboard">
<header class="dashboard__header">
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
</header>
@if (stats(); as s) {
<section class="overview">
<div class="card card--gauge">
<span class="card__label">Consommation vs capacité</span>
<app-consumption-gauge [consumption]="s.total_consumption_kw" [capacity]="s.total_capacity_kw" />
<span class="card__value">{{ s.total_consumption_kw | number:'1.0-1' }} / {{ s.total_capacity_kw | number }} kW</span>
</div>
<div class="card">
<span class="card__label">Charge moyenne du parc</span>
<span class="card__value">{{ s.average_load_percent }} %</span>
<div class="progress-bar">
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
</div>
</div>
<div class="card">
<span class="card__label">Sites suivis</span>
<span class="card__value">{{ s.total_sites }}</span>
</div>
</section>
<section class="chart-section">
<h2>Charge et alerte visuelle par site</h2>
<app-site-load-chart [sites]="s.sites" />
</section>
}
@if (alerts().length > 0) {
<section class="alerts-section">
<h2>Alertes actives</h2>
<ul class="alerts-list">
@for (alert of alerts(); track alert.alert_id) {
<li class="alert-item" [class]="'alert-item--' + alert.severity">
<span class="alert-item__badge">{{ alert.severity }}</span>
<span class="alert-item__message">{{ alert.message }}</span>
</li>
}
</ul>
</section>
}
</div>
@@ -0,0 +1,130 @@
:host {
--color-good: #2e7d32;
--color-partial: #f9a825;
--color-degraded: #ef6c00;
--color-critical: #c62828;
--color-bg-card: #ffffff;
--color-border: #e5e7eb;
--color-text-muted: #6b7280;
--radius: 10px;
display: block;
font-family: 'Segoe UI', system-ui, sans-serif;
color: #1f2937;
padding: 2rem;
max-width: 1100px;
margin: 0 auto;
}
.dashboard__header {
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.dashboard__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 1rem;
}
.overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin-bottom: 2.5rem;
}
.card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.card--gauge {
align-items: center;
text-align: center;
}
.card__label {
font-size: 0.8rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.card__value {
font-size: 1.6rem;
font-weight: 700;
}
.progress-bar {
height: 6px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
margin-top: 0.25rem;
}
.progress-bar__fill {
height: 100%;
background: #3b82f6;
border-radius: 999px;
transition: width 0.3s ease;
}
.chart-section {
margin-bottom: 2.5rem;
}
.alerts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.alert-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-radius: var(--radius);
background: #fef2f2;
border: 1px solid #fecaca;
}
.alert-item__badge {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.2rem 0.55rem;
border-radius: 999px;
color: #fff;
background: var(--color-critical);
flex-shrink: 0;
}
.alert-item--high .alert-item__badge { background: var(--color-degraded); }
.alert-item--medium .alert-item__badge { background: var(--color-partial); }
.alert-item--low .alert-item__badge { background: var(--color-good); }
.alert-item__message {
font-size: 0.9rem;
}
@@ -0,0 +1,41 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { of } from 'rxjs';
import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service';
vi.mock('chart.js', () => {
class ChartMock {
update = vi.fn();
data = { datasets: [{}] };
static register = vi.fn();
}
return { Chart: ChartMock, registerables: [] };
});
describe('Dashboard', () => {
it('charge les stats et les alertes au démarrage', async () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
// laisse le timer(0, ...) se déclencher avant de vérifier
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(statsMock.getSummary).toHaveBeenCalled();
expect(alertsMock.getAlerts).toHaveBeenCalled();
expect(fixture.componentInstance.alerts().length).toBe(1);
});
});
@@ -0,0 +1,39 @@
import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap } from 'rxjs';
import { DecimalPipe } from '@angular/common';
import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
import {AlertsService} from '../../core/services/alerts.service';
import {StatsSummary} from '../../shared/models/stats.model';
import {Alert} from '../../shared/models/alert.model';
const REFRESH_INTERVAL_MS = 10000;
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [DecimalPipe, ConsumptionGauge, SiteLoadChart],
templateUrl: './dashboard.html',
styleUrl: './dashboard.scss',
})
export class Dashboard implements OnInit {
private statsService = inject(StatsService);
private alertsService = inject(AlertsService);
private destroyRef = inject(DestroyRef);
stats = signal<StatsSummary | null>(null);
alerts = signal<Alert[]>([]);
ngOnInit(): void {
this.alertsService.getAlerts().subscribe((alerts) => this.alerts.set(alerts));
timer(0, REFRESH_INTERVAL_MS)
.pipe(
switchMap(() => this.statsService.getSummary()),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((stats) => this.stats.set(stats));
}
}