Merge pull request #73 from ineszang/feat/dashboard-graph

Feat/dashboard graph
This commit is contained in:
Johan LEROY
2026-09-16 09:42:55 +02:00
committed by GitHub
35 changed files with 1109 additions and 401 deletions
+4 -3
View File
@@ -19,7 +19,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Domaine | Technologie | Emplacement | Etat | | Domaine | Technologie | Emplacement | Etat |
|------------|-------------------------------------|---------------------|---------------| |------------|-------------------------------------|---------------------|---------------|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | | Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
| Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Squelette | | Frontend | Angular 22, Node 24 LTS | `apps/frontend` | Tableau de bord |
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise | | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
| ETL | Apache Airflow | `etl/airflow` | A initialiser | | ETL | Apache Airflow | `etl/airflow` | A initialiser |
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
@@ -27,8 +27,9 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
porte le squelette Angular, sans code metier : aucune route, aucun appel d'API. Les autres dossiers sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie. correspondants restent à écrire côté API. Les autres dossiers portent l'arborescence et un README
de cadrage, leur contenu fait l'objet d'un ticket dedie.
L'etat detaille de chaque brique et les vues d'architecture sont dans L'etat detaille de chaque brique et les vues d'architecture sont dans
[docs/architecture](docs/architecture/README.md). [docs/architecture](docs/architecture/README.md).
+1
View File
@@ -34,6 +34,7 @@ yarn-error.log
.sass-cache/ .sass-cache/
/connect.lock /connect.lock
/coverage /coverage
/test-results
/libpeerconnection.log /libpeerconnection.log
testem.log testem.log
/typings /typings
+19
View File
@@ -14,6 +14,7 @@
"@angular/forms": "^22.1.0", "@angular/forms": "^22.1.0",
"@angular/platform-browser": "^22.1.0", "@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0", "@angular/router": "^22.1.0",
"chart.js": "^4.5.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
@@ -2038,6 +2039,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@listr2/prompt-adapter-inquirer": { "node_modules/@listr2/prompt-adapter-inquirer": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz",
@@ -4220,6 +4227,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@angular/forms": "^22.1.0", "@angular/forms": "^22.1.0",
"@angular/platform-browser": "^22.1.0", "@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0", "@angular/router": "^22.1.0",
"chart.js": "^4.5.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
+7 -1
View File
@@ -1,7 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([mockApiInterceptor])),
],
}; };
+1 -353
View File
@@ -1,353 +1 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> <router-outlet></router-outlet>
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Helvetica,
Arial,
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol';
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
display: block;
height: 100dvh;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family:
'Inter Tight',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Helvetica,
Arial,
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol';
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
white-space: nowrap;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--electric-violet);
}
.pill-group .pill:nth-child(6n + 3) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5),
.pill-group .pill:nth-child(6n + 6) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient id="b" x1="0" x2="982" y1="192" y2="192" gradientUnits="userSpaceOnUse">
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title() }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (
item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{
title: 'Prompt and best practices for AI',
link: 'https://angular.dev/ai/develop-with-ai',
},
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{
title: 'Angular Language Service',
link: 'https://angular.dev/tools/language-service',
},
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
];
track item.title
) {
<a class="pill" [href]="item.link" target="_blank" rel="noopener">
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a href="https://x.com/angular" aria-label="X" target="_blank" rel="noopener">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="X"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet />
+7 -1
View File
@@ -1,3 +1,9 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
export const routes: Routes = []; export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
},
];
-7
View File
@@ -13,11 +13,4 @@ describe('App', () => {
const app = fixture.componentInstance; const app = fixture.componentInstance;
expect(app).toBeTruthy(); expect(app).toBeTruthy();
}); });
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
});
}); });
@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { mockApiInterceptor } from './mock-api-interceptor';
import { environment } from '../../../environments/environment';
import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
describe('mockApiInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([mockApiInterceptor])),
provideHttpClientTesting(),
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
environment.useMockFixtures = true;
httpMock.verify();
});
it('renvoie la fixture sans appel réseau quand useMockFixtures est activé', () => {
environment.useMockFixtures = true;
let result: unknown;
http.get(`${environment.apiUrl}/stats/summary`).subscribe((r) => (result = r));
httpMock.expectNone(`${environment.apiUrl}/stats/summary`);
expect((result as typeof STATS_SUMMARY_FIXTURE).total_sites).toBe(
STATS_SUMMARY_FIXTURE.total_sites,
);
});
it('laisse passer la vraie requête quand useMockFixtures est désactivé', () => {
environment.useMockFixtures = false;
http.get(`${environment.apiUrl}/stats/summary`).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`);
req.flush({});
});
it("laisse passer une requête qui ne correspond à aucune route connue de l'interceptor", () => {
environment.useMockFixtures = true;
http.get('/api/v1/autre-chose').subscribe();
const req = httpMock.expectOne('/api/v1/autre-chose');
req.flush({});
});
it('renvoie la fixture des alertes sans appel réseau quand useMockFixtures est activé', () => {
environment.useMockFixtures = true;
let result: unknown;
http.get(`${environment.apiUrl}/alerts`).subscribe((r) => (result = r));
httpMock.expectNone(`${environment.apiUrl}/alerts`);
expect((result as unknown[]).length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,30 @@
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of } from 'rxjs';
import { environment } from '../../../environments/environment';
import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
import { ALERTS_FIXTURE } from '../mocks/alerts.fixture';
function withJitter(base: typeof STATS_SUMMARY_FIXTURE) {
const jitter = () => (Math.random() - 0.5) * 40;
const totalConsumption = Math.max(0, base.total_consumption_kw + jitter());
return {
...base,
timestamp: new Date().toISOString(),
total_consumption_kw: Math.round(totalConsumption * 100) / 100,
average_load_percent: Math.round((totalConsumption / base.total_capacity_kw) * 1000) / 10,
};
}
export const mockApiInterceptor: HttpInterceptorFn = (req, next) => {
if (!environment.useMockFixtures) {
return next(req);
}
if (req.url.endsWith(`${environment.apiUrl}/stats/summary`)) {
return of(new HttpResponse({ status: 200, body: withJitter(STATS_SUMMARY_FIXTURE) }));
}
if (req.url.endsWith(`${environment.apiUrl}/alerts`)) {
return of(new HttpResponse({ status: 200, body: ALERTS_FIXTURE }));
}
return next(req);
};
@@ -0,0 +1,54 @@
import { Alert } from '../../shared/models/alert.model';
export const ALERTS_FIXTURE: Alert[] = [
{
alert_id: 'ALR-SITE002-1718458320',
timestamp: '2026-09-15T11:12:00',
site_id: 'SITE002',
severity: 'critical',
type: 'outage',
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
value: 812.5,
threshold: 720.0,
},
{
alert_id: 'ALR-SITE003-1718458321',
timestamp: '2026-09-15T11:05:00',
site_id: 'SITE003',
severity: 'critical',
type: 'sensor',
message: 'Perte réseau totale sur Data Center Marseille',
value: 0,
threshold: 0,
},
{
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',
severity: 'low',
type: 'anomaly',
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
value: 62.0,
threshold: 55.0,
},
];
@@ -0,0 +1,67 @@
import { StatsSummary } from '../../shared/models/stats.model';
export const STATS_SUMMARY_FIXTURE: StatsSummary = {
timestamp: '2026-09-15T11:32:00',
total_sites: 7,
total_consumption_kw: 1826.44,
total_capacity_kw: 3830,
average_load_percent: 55.1,
sites: [
{
site_id: 'SITE001',
site_name: 'Bureau Paris La Défense',
current_consumption_kw: 87.34,
capacity_kw: 200,
load_percent: 43.7,
data_quality: 'good',
},
{
site_id: 'SITE002',
site_name: 'Usine Lyon Vénissieux',
current_consumption_kw: 542.1,
capacity_kw: 1000,
load_percent: 54.2,
data_quality: 'good',
},
{
site_id: 'SITE003',
site_name: 'Data Center Marseille',
current_consumption_kw: null,
capacity_kw: 800,
load_percent: null,
data_quality: 'critical',
},
{
site_id: 'SITE004',
site_name: 'Bureau Bordeaux',
current_consumption_kw: 62.0,
capacity_kw: 150,
load_percent: 41.3,
data_quality: 'partial',
},
{
site_id: 'SITE005',
site_name: 'Usine Toulouse',
current_consumption_kw: 410.0,
capacity_kw: 600,
load_percent: 68.3,
data_quality: 'good',
},
{
site_id: 'SITE006',
site_name: 'Bureau Lille',
current_consumption_kw: 95.0,
capacity_kw: 180,
load_percent: 52.8,
data_quality: 'degraded',
},
{
site_id: 'SITE007',
site_name: 'Data Center Nantes',
current_consumption_kw: 630.0,
capacity_kw: 900,
load_percent: 70.0,
data_quality: 'good',
},
],
};
@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AlertsService } from './alerts.service';
import { environment } from '../../../environments/environment';
describe('AlertsService', () => {
let service: AlertsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(AlertsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
let result: unknown;
service.getAlerts().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
expect(req.request.method).toBe('GET');
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,
},
]);
expect((result as unknown[]).length).toBe(1);
});
});
@@ -0,0 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model';
@Service()
export class AlertsService {
private http = inject(HttpClient);
getAlerts() {
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
}
}
@@ -0,0 +1,39 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { StatsService } from './stats.service';
import { environment } from '../../../environments/environment';
describe('StatsService', () => {
let service: StatsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(StatsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('appelle le bon endpoint et retourne le résumé', () => {
let result: unknown;
service.getSummary().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/stats/summary`);
expect(req.request.method).toBe('GET');
req.flush({
timestamp: '2026-09-15T12:00:00',
total_sites: 7,
total_consumption_kw: 1800,
total_capacity_kw: 3800,
average_load_percent: 47.4,
sites: [],
});
expect((result as { total_sites: number }).total_sites).toBe(7);
});
});
@@ -0,0 +1,13 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { StatsSummary } from '../../shared/models/stats.model';
@Service()
export class StatsService {
private http = inject(HttpClient);
getSummary() {
return this.http.get<StatsSummary>(`${environment.apiUrl}/stats/summary`);
}
}
@@ -0,0 +1,58 @@
<div class="dashboard">
<header class="dashboard__header">
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
</header>
@if (error(); as message) {
<p class="banner-error" role="alert">{{ message }}</p>
}
@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,146 @@
: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;
}
.banner-error {
margin: 0 0 1.5rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-critical);
border-left-width: 4px;
border-radius: var(--radius);
background: #fdecea;
color: var(--color-critical);
}
.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,95 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { of, throwError } 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();
destroy = vi.fn();
data = { datasets: [{}] };
static register = vi.fn();
}
return { Chart: ChartMock, registerables: [] };
});
describe('Dashboard', () => {
afterEach(() => vi.useRealTimers());
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);
expect(fixture.componentInstance.error()).toBeNull();
});
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
vi.useFakeTimers();
const statsMock = {
getSummary: vi
.fn()
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
.mockReturnValue(of({ total_sites: 7, sites: [] })),
};
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
vi.advanceTimersByTime(1);
expect(statsMock.getSummary).toHaveBeenCalledTimes(1);
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.stats()).toBeNull();
vi.advanceTimersByTime(10000);
expect(statsMock.getSummary).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.stats()).not.toBeNull();
expect(fixture.componentInstance.error()).toBeNull();
});
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
expect(fixture.componentInstance.alerts().length).toBe(0);
});
});
@@ -0,0 +1,57 @@
import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap, catchError, EMPTY, Observable } 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;
const UNAVAILABLE_MESSAGE =
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
@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[]>([]);
error = signal<string | null>(null);
ngOnInit(): void {
this.alertsService
.getAlerts()
.pipe(catchError(() => this.reportUnavailable()))
.subscribe((alerts) => this.alerts.set(alerts));
// Piège : le catchError porte sur l'observable interne. Sur le flux externe il
// terminerait le timer, et le rafraîchissement ne repartirait jamais.
timer(0, REFRESH_INTERVAL_MS)
.pipe(
switchMap(() =>
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable())),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((stats) => {
this.error.set(null);
this.stats.set(stats);
});
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
}
}
@@ -0,0 +1 @@
<canvas #canvas></canvas>
@@ -0,0 +1,6 @@
:host {
display: block;
height: 200px;
width: 200px;
margin: 0 auto;
}
@@ -0,0 +1,59 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { Chart } from 'chart.js';
import { ConsumptionGauge } from './consumption-gauge';
vi.mock('chart.js', () => {
class ChartMock {
static instances: ChartMock[] = [];
static register = vi.fn();
update = vi.fn();
destroy = vi.fn();
data = { datasets: [{}] };
constructor() {
ChartMock.instances.push(this);
}
}
return { Chart: ChartMock, registerables: [] };
});
type ChartDouble = { destroy: ReturnType<typeof vi.fn> };
function lastChart(): ChartDouble | undefined {
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
}
describe('ConsumptionGauge', () => {
it('se crée sans erreur avec des entrées valides', () => {
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
const fixture = TestBed.createComponent(ConsumptionGauge);
fixture.componentRef.setInput('consumption', 300);
fixture.componentRef.setInput('capacity', 1000);
expect(() => fixture.detectChanges()).not.toThrow();
});
it('met à jour le graphique quand les valeurs changent après initialisation', () => {
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
const fixture = TestBed.createComponent(ConsumptionGauge);
fixture.componentRef.setInput('consumption', 300);
fixture.componentRef.setInput('capacity', 1000);
fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais
fixture.componentRef.setInput('consumption', 500);
fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini
expect(() => fixture.detectChanges()).not.toThrow();
});
it('détruit le graphique quand le composant est détruit', () => {
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
const fixture = TestBed.createComponent(ConsumptionGauge);
fixture.componentRef.setInput('consumption', 300);
fixture.componentRef.setInput('capacity', 1000);
fixture.detectChanges();
const chart = lastChart();
fixture.destroy();
expect(chart?.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,67 @@
import {
Component,
ElementRef,
ViewChild,
input,
effect,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
@Component({
selector: 'app-consumption-gauge',
standalone: true,
templateUrl: './consumption-gauge.html',
styleUrl: './consumption-gauge.scss',
})
export class ConsumptionGauge implements AfterViewInit, OnDestroy {
consumption = input.required<number>();
capacity = input.required<number>();
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
private chart?: Chart;
constructor() {
effect(() => {
const used = this.consumption();
const remaining = Math.max(0, this.capacity() - used);
if (this.chart) {
this.chart.data.datasets[0].data = [used, remaining];
this.chart.update('none');
}
});
}
ngAfterViewInit(): void {
const used = this.consumption();
const remaining = Math.max(0, this.capacity() - used);
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'doughnut',
data: {
labels: ['Utilisé', 'Disponible'],
datasets: [
{
data: [used, remaining],
backgroundColor: ['#3b82f6', '#e5e7eb'],
borderWidth: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
animation: { duration: 300 },
plugins: { legend: { display: false } },
},
});
}
ngOnDestroy(): void {
this.chart?.destroy();
}
}
@@ -0,0 +1 @@
<canvas #canvas></canvas>
@@ -0,0 +1,4 @@
:host {
display: block;
height: 260px;
}
@@ -0,0 +1,92 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { Chart } from 'chart.js';
import { SiteLoadChart } from './site-load-chart';
vi.mock('chart.js', () => {
class ChartMock {
static instances: ChartMock[] = [];
static register = vi.fn();
update = vi.fn();
destroy = vi.fn();
data = { datasets: [{}] };
constructor() {
ChartMock.instances.push(this);
}
}
return { Chart: ChartMock, registerables: [] };
});
type ChartDouble = { destroy: ReturnType<typeof vi.fn> };
function lastChart(): ChartDouble | undefined {
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
}
describe('SiteLoadChart', () => {
it('se crée sans erreur avec une liste de sites valide', () => {
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
const fixture = TestBed.createComponent(SiteLoadChart);
fixture.componentRef.setInput('sites', [
{
site_id: 'S1',
site_name: 'Test',
current_consumption_kw: 50,
capacity_kw: 100,
load_percent: 50,
data_quality: 'good',
},
]);
expect(() => fixture.detectChanges()).not.toThrow();
});
it('met à jour le graphique quand les sites changent après initialisation', () => {
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
const fixture = TestBed.createComponent(SiteLoadChart);
fixture.componentRef.setInput('sites', [
{
site_id: 'S1',
site_name: 'A',
current_consumption_kw: 50,
capacity_kw: 100,
load_percent: 50,
data_quality: 'good',
},
]);
fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais
fixture.componentRef.setInput('sites', [
{
site_id: 'S2',
site_name: 'B',
current_consumption_kw: 80,
capacity_kw: 100,
load_percent: 80,
data_quality: 'critical',
},
]);
fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini
expect(() => fixture.detectChanges()).not.toThrow();
});
it('détruit le graphique quand le composant est détruit', () => {
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
const fixture = TestBed.createComponent(SiteLoadChart);
fixture.componentRef.setInput('sites', [
{
site_id: 'S1',
site_name: 'A',
current_consumption_kw: 50,
capacity_kw: 100,
load_percent: 50,
data_quality: 'good',
},
]);
fixture.detectChanges();
const chart = lastChart();
fixture.destroy();
expect(chart?.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,76 @@
import {
Component,
ElementRef,
ViewChild,
input,
effect,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { Chart, registerables } from 'chart.js';
import { SiteSummary } from '../../models/stats.model';
Chart.register(...registerables);
const QUALITY_COLORS: Record<SiteSummary['data_quality'], string> = {
good: '#2e7d32',
partial: '#f9a825',
degraded: '#ef6c00',
critical: '#c62828',
};
@Component({
selector: 'app-site-load-chart',
standalone: true,
templateUrl: './site-load-chart.html',
styleUrl: './site-load-chart.scss',
})
export class SiteLoadChart implements AfterViewInit, OnDestroy {
sites = input.required<SiteSummary[]>();
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
private chart?: Chart;
constructor() {
effect(() => {
const sites = this.sites();
if (this.chart) {
this.chart.data.labels = sites.map((s) => s.site_name);
this.chart.data.datasets[0].data = sites.map((s) => s.load_percent ?? 0);
this.chart.data.datasets[0].backgroundColor = sites.map(
(s) => QUALITY_COLORS[s.data_quality],
);
this.chart.update('none');
}
});
}
ngAfterViewInit(): void {
const sites = this.sites();
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'bar',
data: {
labels: sites.map((s) => s.site_name),
datasets: [
{
data: sites.map((s) => s.load_percent ?? 0),
backgroundColor: sites.map((s) => QUALITY_COLORS[s.data_quality]),
borderRadius: 4,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, max: 100, title: { display: true, text: 'Charge (%)' } },
},
},
});
}
ngOnDestroy(): void {
this.chart?.destroy();
}
}
@@ -0,0 +1,13 @@
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
export interface Alert {
alert_id: string;
timestamp: string;
site_id: string;
severity: AlertSeverity;
type: AlertType;
message: string;
value: number;
threshold: number;
}
@@ -0,0 +1,17 @@
export interface SiteSummary {
site_id: string;
site_name: string;
current_consumption_kw: number | null;
capacity_kw: number;
load_percent: number | null;
data_quality: 'good' | 'partial' | 'degraded' | 'critical';
}
export interface StatsSummary {
timestamp: string;
total_sites: number;
total_consumption_kw: number;
total_capacity_kw: number;
average_load_percent: number;
sites: SiteSummary[];
}
@@ -1,4 +1,5 @@
export const environment = { export const environment = {
production: false, production: false,
apiUrl: '/api/v1' apiUrl: '/api/v1',
useMockFixtures: true, // a passer a false une fois le backend prêt
}; };
@@ -1,4 +1,5 @@
export const environment = { export const environment = {
production: true, production: true,
apiUrl: 'http://localhost:8000/api/v1' apiUrl: 'http://localhost:8000/api/v1',
useMockFixtures: false,
}; };
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<testsuites name="vitest tests" tests="2" failures="0" errors="0" time="0.0699261">
<testsuite name="src/app/app.spec.ts" timestamp="2026-09-14T14:27:28.895Z" hostname="76SE37-GL5HHZ3" tests="2" failures="0" errors="0" skipped="0" time="0.0699261">
<testcase classname="src/app/app.spec.ts" name="App &gt; should create the app" time="0.0527847">
</testcase>
<testcase classname="src/app/app.spec.ts" name="App &gt; should render title" time="0.015831">
</testcase>
</testsuite>
</testsuites>
+4 -3
View File
@@ -63,8 +63,9 @@ flowchart TB
grafana -.-> prom grafana -.-> prom
``` ```
Le lien `front -.-> api` est en pointillé à dessein : le frontend n'appelle aujourd'hui aucune Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un
API, `provideHttpClient` n'est pas encore installé. Voir [30-frontend.md](30-frontend.md). intercepteur répond à sa place tant que les endpoints n'existent pas. Voir
[30-frontend.md](30-frontend.md).
Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun Le lien `prom -.-> api` de même : l'API expose bien `/metrics` au format Prometheus, mais aucun
collecteur ne vient le lire. collecteur ne vient le lire.
@@ -74,7 +75,7 @@ collecteur ne vient le lire.
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement | | Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|---|---|---|---|---| |---|---|---|---|---|
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier | | Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Squelette `ng new` standalone, routes vides, aucun service HTTP | | Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative | | Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative |
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée | | Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
+43 -22
View File
@@ -4,30 +4,36 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
## État actuel ## État actuel
Statut : `En cours`. Le projet est un `ng new` intact. Le tableau de la Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée
[vue d'ensemble](00-vue-ensemble.md) le classe désormais correctement, le `README.md` racine le par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API.
disait encore « à initialiser » alors que le squelette existe depuis `49f4697`.
Ce qui est en place : Ce qui est en place :
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt. - Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()` et `provideRouter(routes)`. - `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
- Vitest via le builder `@angular/build:unit-test`, couverture activée, un fichier de test. `provideHttpClient(withInterceptors([mockApiInterceptor]))`.
- Une route `/dashboard` en composant différé, et une redirection depuis la racine.
- `core/services` porte `StatsService` et `AlertsService`, `core/interceptors` l'intercepteur de
fixtures, `features/dashboard` la page, `shared/components` la jauge de consommation et le
graphique de charge par site, tous deux construits sur Chart.js.
- L'état vit dans des signaux, sans bibliothèque dédiée.
- Vitest via le builder `@angular/build:unit-test`, couverture activée, sept fichiers de test.
- Prettier configuré, parser `angular` pour les gabarits HTML. - Prettier configuré, parser `angular` pour les gabarits HTML.
Ce qui n'existe pas encore : Ce qui n'existe pas encore :
- `routes` est un tableau vide. Aucune page, aucune navigation. - **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
- **`provideHttpClient` n'est pas fourni** et `@angular/common/http` n'est importé nulle part : sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre.
l'application n'appelle aucune API. - Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que
- `app.html` est la page d'accueil Angular par défaut, commentaires de remplacement compris. les routes métier de l'API en exigent un. Voir
- Aucune bibliothèque de graphiques, aucun kit d'interface, aucune gestion d'état. [31-contrat-authentification.md](31-contrat-authentification.md).
- Aucun état de chargement : tant que la première réponse n'est pas arrivée, la page reste vide.
- Aucun lint : ESLint n'est pas installé. - Aucun lint : ESLint n'est pas installé.
## Arborescence cible ## Arborescence
Statut : `Cible`. Elle n'est pas inventée ici : [`TESTING.md`](../../apps/frontend/TESTING.md) la Statut : `Fait`. Elle suit ce que [`TESTING.md`](../../apps/frontend/TESTING.md) prescrit dans ses
prescrit déjà dans ses gabarits de tests. gabarits de tests.
```mermaid ```mermaid
flowchart TB flowchart TB
@@ -48,22 +54,33 @@ directement : ils passent par un service, ce qui rend le double de test trivial.
## Flux HTTP ## Flux HTTP
Statut : `Cible`. Le chemin est câblé, rien ne l'emprunte encore. Statut : `En cours`. Le chemin complet est câblé, mais un intercepteur se place devant et répond
lui-même tant que les endpoints n'existent pas.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant C as Composant participant C as Composant
participant S as Service Angular participant S as Service Angular
participant I as mockApiInterceptor
participant P as ng serve, proxy participant P as ng serve, proxy
participant A as FastAPI participant A as FastAPI
C->>S: appel de méthode C->>S: appel de méthode
S->>P: GET /api/v1/... S->>I: GET /api/v1/...
P->>A: http://localhost:8000/api/v1/... alt useMockFixtures actif et route connue
A-->>S: JSON I-->>S: fixture locale
else
I->>P: la requête poursuit
P->>A: http://localhost:8000/api/v1/...
A-->>S: JSON
end
S-->>C: modèle typé S-->>C: modèle typé
``` ```
`mockApiInterceptor` n'intercepte que `/stats/summary` et `/alerts`, et seulement si
`environment.useMockFixtures` est vrai. Le drapeau est à `true` en développement, à `false` en
production : toute autre requête, et toutes les requêtes en production, suivent le chemin réel.
En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce En développement, `proxy.conf.json` redirige tout `/api` vers `http://localhost:8000`. C'est ce
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
`apiUrl` relatif, `/api/v1`. `apiUrl` relatif, `/api/v1`.
@@ -87,6 +104,10 @@ déploiement, en même temps que sera tranchée la question de l'ingress dans
| `npm run test` | Vitest en mode observateur | | `npm run test` | Vitest en mode observateur |
| `npm run test:ci` | Vitest en une passe | | `npm run test:ci` | Vitest en une passe |
**Version de Node.** L'Angular CLI refuse de démarrer en dessous de 22.22.3, 24.15.0 ou 26.0.0, et
le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.12 ne peut donc ni
tester ni construire le frontend.
Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans
`docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200 `docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200
n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend. n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend.
@@ -98,8 +119,9 @@ avec un service statique, il reste à écrire.
## Sécurité ## Sécurité
- Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL. - Le frontend ne détient aucun secret : `environment.ts` ne porte qu'une URL.
- L'authentification n'existe pas côté API, donc pas de garde ni d'intercepteur de jeton à ce - L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun
stade. `core/guards` et `core/interceptors` sont prévus pour cela. intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui
que les fixtures.
## Tests ## Tests
@@ -107,8 +129,7 @@ Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTI
## Questions ouvertes ## Questions ouvertes
- **Quelle bibliothèque de graphiques** pour les séries temporelles, et si Grafana en couvre déjà - **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
une partie du besoin. pages partageront le même état.
- **Gestion d'état** : signaux seuls, ou une bibliothèque dédiée.
- **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue - **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue
au démarrage. au démarrage.