Compare commits

..
Author SHA1 Message Date
Johan LEROY ad149db0cb fix(backend): corrige une assertion tautologique dans test_list_all_returns_the_sites_sorted_by_identifier
Backend / Lint, typage et tests (push) Successful in 1m7s
L'assertion comparait le résultat à lui-même trié, donc vraie quel que
soit l'ordre réellement renvoyé par SiteRepository.list_all(). Compare
désormais à des identifiants connus à l'avance.
2026-09-16 11:55:03 +02:00
Dorian PESCE d25e544db6 fix(backend): contourne un bug de ruff format sur le except à deux types de health.py 2026-09-16 11:41:53 +02:00
Dorian PESCE 22ff1d93f4 fix(backend): type le retour de SiteRepository.get_by_id pour mypy strict 2026-09-16 11:29:18 +02:00
Dorian PESCE 31a9cb109f fix(backend): corrige la syntaxe except invalide de la sonde /health/ready 2026-09-16 11:04:27 +02:00
Dorian PESCE 50dddf952b feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id} 2026-09-16 11:03:06 +02:00
Dorian PESCE fc6600aeaf Revert "feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id}"
This reverts commit 1325a75e9a.
2026-09-16 11:02:00 +02:00
Dorian PESCE 1325a75e9a feat(backend): ajoute les endpoints GET /sites et GET /sites/{site_id} 2026-09-16 11:01:13 +02:00
Johan LEROYandGitHub 44468e85d7 Merge pull request #73 from ineszang/feat/dashboard-graph
Feat/dashboard graph
2026-09-16 09:42:55 +02:00
Johan LEROY 580da72eff docs(architecture): acte le tableau de bord dans les vues frontend
30-frontend.md decrivait encore un ng new intact : routes vides,
provideHttpClient absent, app.html par defaut, aucune bibliotheque de
graphiques. Les sections Arborescence et Flux HTTP passent de Cible a
realisees, et le diagramme de sequence montre ou l'intercepteur se place.

La section Securite affirmait que l'authentification n'existe pas cote API :
elle existe depuis la PR #70, c'est cote interface qu'il n'y a rien.

Ajout verifie sur le poste : l'Angular CLI refuse de demarrer en dessous de
Node 22.22.3, 24.15.0 ou 26.0.0.
2026-09-16 09:23:15 +02:00
Johan LEROY e85c83972a chore(frontend): sort le rapport JUnit du suivi git
La ligne /test-results ajoutee au .gitignore n'avait aucun effet : le fichier
etait deja suivi, et un .gitignore ne s'applique pas a un fichier indexe. Il
reapparaissait donc modifie dans le diff de chacun a chaque execution de ng
test, qui le regenere a l'emplacement fixe par angular.json.
2026-09-16 09:20:48 +02:00
Johan LEROY da97e6aa8b style(frontend): applique prettier aux fichiers du tableau de bord
Les onze fichiers non conformes au .prettierrc du projet etaient exactement
ceux introduits ou modifies par cette branche ; les vingt-deux autres du
frontend etaient deja propres.

Aucune modification de comportement : indentation, virgules finales et
longueur de ligne a 100 caracteres.
2026-09-16 09:20:40 +02:00
Johan LEROY 0259f66b62 fix(frontend): detruit les graphiques avec leur composant
Chart.js conserve chaque instance dans un registre lie au canvas et lui
attache un observateur de redimensionnement. Sans destroy, tout survit a la
destruction du composant, et une re-creation sur le meme canvas echoue avec
"Canvas is already in use".

Les doubles de test gagnent destroy : TestBed detruit les fixtures apres
chaque test, un mock sans cette methode fait tomber les specs existantes.
2026-09-16 09:20:17 +02:00
Johan LEROY 7b9406965e fix(frontend): maintient le rafraichissement du tableau de bord en cas d'erreur
Sans catchError, la premiere reponse en erreur terminait le flux du timer :
le rafraichissement ne repartait jamais et l'ecran restait fige sur des
chiffres perimes, sans rien signaler.

Le catchError porte sur l'observable interne du switchMap. Place sur le flux
externe il terminerait le timer tout autant. Un signal error alimente un
bandeau, efface des qu'une reponse valide revient.
2026-09-16 09:20:17 +02:00
Johan LEROY 881f503f1a Merge remote-tracking branch 'origin/dev' into feat/dashboard-graph 2026-09-16 09:15:26 +02:00
Johan LEROYandGitHub 918bd971da Merge pull request #71 from ineszang/feat/data-schema
feat(apps): ajoute le schéma de données EnerVision
2026-09-16 08:52:04 +02:00
valentin cdef30736a Creation dashboard (graph chart.js) + tests 2026-09-15 16:48:55 +02:00
49 changed files with 1490 additions and 413 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 |
|------------|-------------------------------------|---------------------|---------------|
| 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 |
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
| 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 |
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
portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
sert un tableau de bord sur `/dashboard`, dont les données proviennent de fixtures : les endpoints
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
[docs/architecture](docs/architecture/README.md).
+2
View File
@@ -103,6 +103,8 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
| `/api/v1/users` | Liste et crée des comptes | `admin` |
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` |
| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` |
| `/api/v1/sites` | Liste les sites | `lecteur` |
| `/api/v1/sites/{site_id}` | Décrit un site | `lecteur` |
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
+9
View File
@@ -24,8 +24,10 @@ from app.db.session import get_session
from app.repositories.audit_log import AuditLogRepository
from app.repositories.login_attempt import LoginAttemptRepository
from app.repositories.refresh_token import RefreshTokenRepository
from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository
from app.services.auth import AuthService, LoginPolicy
from app.services.site import SiteService
from app.services.user import UserService
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -131,6 +133,13 @@ def get_user_service(
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
def get_site_service(session: SessionDep) -> SiteService:
return SiteService(sites=SiteRepository(session))
SiteServiceDep = Annotated[SiteService, Depends(get_site_service)]
async def get_current_principal(
credentials: CredentialsDep,
session: SessionDep,
+3 -1
View File
@@ -26,7 +26,9 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
async def readiness(session: SessionDep) -> ReadinessStatus:
try:
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
except SQLAlchemyError, OSError:
# `# fmt: skip` contourne un bug de ruff format 0.16.7 : il retire les parenthèses de ce
# `except` à deux types, ce qui produit une syntaxe invalide (`except A, B:`).
except (SQLAlchemyError, OSError): # fmt: skip
logger.exception("Base de données injoignable")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -0,0 +1,24 @@
from fastapi import APIRouter, HTTPException, status
from app.api.deps import LecteurDep, SiteServiceDep
from app.schemas.site import SiteResponse
from app.services.site import SiteNotFoundError
router = APIRouter()
@router.get("", response_model=list[SiteResponse], summary="Liste les sites")
async def list_sites(_: LecteurDep, service: SiteServiceDep) -> list[SiteResponse]:
sites = await service.list_all()
return [SiteResponse.model_validate(site) for site in sites]
@router.get("/{site_id}", response_model=SiteResponse, summary="Décrit un site")
async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteResponse:
try:
site = await service.get_by_id(site_id)
except SiteNotFoundError as erreur:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable"
) from erreur
return SiteResponse.model_validate(site)
+2 -1
View File
@@ -1,8 +1,9 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, users
from app.api.v1.endpoints import auth, health, sites, users
api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(sites.router, prefix="/sites", tags=["sites"])
+20
View File
@@ -0,0 +1,20 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Site
class SiteRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_all(self) -> Sequence[Site]:
requete = select(Site).order_by(Site.site_id)
return (await self._session.scalars(requete)).all()
async def get_by_id(self, site_id: str) -> Site | None:
requete = select(Site).where(Site.site_id == site_id)
site: Site | None = await self._session.scalar(requete)
return site
+12
View File
@@ -0,0 +1,12 @@
from pydantic import BaseModel, ConfigDict
class SiteResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
site_type: str
location: str | None
capacity_kw: float | None
status: str | None
+26
View File
@@ -0,0 +1,26 @@
from collections.abc import Sequence
from app.models.energy import Site
from app.repositories.site import SiteRepository
class SiteError(Exception):
pass
class SiteNotFoundError(SiteError):
pass
class SiteService:
def __init__(self, *, sites: SiteRepository) -> None:
self._sites = sites
async def list_all(self) -> Sequence[Site]:
return await self._sites.list_all()
async def get_by_id(self, site_id: str) -> Site:
site = await self._sites.get_by_id(site_id)
if site is None:
raise SiteNotFoundError(site_id)
return site
+141
View File
@@ -0,0 +1,141 @@
from collections.abc import Callable, Iterator
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_site_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Site
from app.services.site import SiteNotFoundError
def principal(role: Role = Role.LECTEUR) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def site(site_id: str = "site-1") -> Site:
return Site(
site_id=site_id,
site_name="Site de test",
site_type="industriel",
location="Toulouse",
capacity_kw=42.0,
status="actif",
)
class FauxService:
def __init__(self, erreur: Exception | None = None) -> None:
self._erreur = erreur
self.site = site()
async def list_all(self) -> list[Site]:
return [self.site]
async def get_by_id(self, site_id: str) -> Site:
if self._erreur is not None:
raise self._erreur
return self.site
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(
app: FastAPI, lecteur_connecte: None
) -> Iterator[Callable[[Exception | None], FauxService]]:
def installe(erreur: Exception | None = None) -> FauxService:
service = FauxService(erreur)
app.dependency_overrides[get_site_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_site_service, None)
async def test_list_sites_returns_the_sites(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sites")
assert response.status_code == 200
corps = response.json()
assert corps == [
{
"site_id": "site-1",
"site_name": "Site de test",
"site_type": "industriel",
"location": "Toulouse",
"capacity_kw": 42.0,
"status": "actif",
}
]
async def test_get_site_returns_the_matching_site(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sites/site-1")
assert response.status_code == 200
assert response.json()["site_id"] == "site-1"
async def test_get_site_returns_404_for_an_unknown_site(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(SiteNotFoundError("site-inconnu"))
response = await client.get("/api/v1/sites/site-inconnu")
assert response.status_code == 404
async def test_list_sites_reaches_the_repository_through_the_session(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=[site("a"), site("b")])
response = await client.get("/api/v1/sites")
assert response.status_code == 200
assert [s["site_id"] for s in response.json()] == ["a", "b"]
async def test_get_site_reaches_the_repository_through_the_session(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=site("a"))
response = await client.get("/api/v1/sites/a")
assert response.status_code == 200
assert response.json()["site_id"] == "a"
async def test_get_site_returns_404_when_the_session_finds_nothing(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=None)
response = await client.get("/api/v1/sites/inconnu")
assert response.status_code == 404
+14
View File
@@ -1,3 +1,4 @@
from collections.abc import Sequence
from typing import Any
from app.core.config import Settings
@@ -12,6 +13,16 @@ SETTINGS_DE_TEST: dict[str, Any] = {
}
class FakeScalars:
"""Resultat factice pour `.scalars()` : `.all()` renvoie les lignes fournies."""
def __init__(self, rows: Sequence[object]) -> None:
self._rows = rows
def all(self) -> Sequence[object]:
return self._rows
class FakeSession:
"""Session factice : renvoie `result`, ou leve `failure` si elle est fournie."""
@@ -25,6 +36,9 @@ class FakeSession:
async def execute(self, *_: object, **__: object) -> object:
return self._repondre()
async def scalars(self, *_: object, **__: object) -> FakeScalars:
return FakeScalars(self._repondre() or [])
def _repondre(self) -> object:
if self._failure is not None:
raise self._failure
@@ -0,0 +1,59 @@
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Site
from app.repositories.site import SiteRepository
pytestmark = pytest.mark.integration
def identifiant() -> str:
return f"site-{uuid.uuid4().hex[:12]}"
async def creer(session: AsyncSession, **overrides: object) -> Site:
site = Site(
site_id=overrides.get("site_id", identifiant()),
site_name=overrides.get("site_name", "Site de test"),
site_type=overrides.get("site_type", "industriel"),
location=overrides.get("location", "Toulouse"),
capacity_kw=overrides.get("capacity_kw", 42.0),
status=overrides.get("status", "actif"),
)
session.add(site)
await session.flush()
return site
async def test_get_by_id_returns_the_matching_site(session: AsyncSession) -> None:
depot = SiteRepository(session)
cree = await creer(session)
trouve = await depot.get_by_id(cree.site_id)
nom = trouve.site_name if trouve else None
await session.rollback()
assert nom == "Site de test"
async def test_get_by_id_returns_nothing_for_an_unknown_identifier(
session: AsyncSession,
) -> None:
trouve = await SiteRepository(session).get_by_id(identifiant())
assert trouve is None
async def test_list_all_returns_the_sites_sorted_by_identifier(session: AsyncSession) -> None:
depot = SiteRepository(session)
premier, second = sorted([f"zz-{identifiant()}", f"aa-{identifiant()}"])
await creer(session, site_id=second)
await creer(session, site_id=premier)
sites = await depot.list_all()
identifiants = [site.site_id for site in sites if site.site_id in (premier, second)]
await session.rollback()
assert identifiants == [premier, second]
+49
View File
@@ -0,0 +1,49 @@
import pytest
from app.models.energy import Site
from app.services.site import SiteNotFoundError, SiteService
def site(site_id: str = "site-1") -> Site:
return Site(
site_id=site_id,
site_name="Site de test",
site_type="industriel",
location="Toulouse",
capacity_kw=42.0,
status="actif",
)
class FakeRepository:
def __init__(self, sites: list[Site]) -> None:
self._sites = sites
async def list_all(self) -> list[Site]:
return self._sites
async def get_by_id(self, site_id: str) -> Site | None:
return next((s for s in self._sites if s.site_id == site_id), None)
async def test_list_all_returns_the_repository_sites() -> None:
service = SiteService(sites=FakeRepository([site("a"), site("b")]))
sites = await service.list_all()
assert [s.site_id for s in sites] == ["a", "b"]
async def test_get_by_id_returns_the_matching_site() -> None:
service = SiteService(sites=FakeRepository([site("a")]))
trouve = await service.get_by_id("a")
assert trouve.site_id == "a"
async def test_get_by_id_raises_when_the_site_is_unknown() -> None:
service = SiteService(sites=FakeRepository([]))
with pytest.raises(SiteNotFoundError):
await service.get_by_id("inconnu")
+1
View File
@@ -34,6 +34,7 @@ yarn-error.log
.sass-cache/
/connect.lock
/coverage
/test-results
/libpeerconnection.log
testem.log
/typings
+19
View File
@@ -14,6 +14,7 @@
"@angular/forms": "^22.1.0",
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"chart.js": "^4.5.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
@@ -2038,6 +2039,12 @@
"@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": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz",
@@ -4220,6 +4227,18 @@
"dev": true,
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@angular/forms": "^22.1.0",
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"chart.js": "^4.5.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
+7 -1
View File
@@ -1,7 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([mockApiInterceptor])),
],
};
+1 -353
View File
@@ -1,353 +1 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * 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 />
<router-outlet></router-outlet>
+7 -1
View File
@@ -1,3 +1,9 @@
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;
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 = {
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 = {
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>
+6 -5
View File
@@ -63,8 +63,9 @@ flowchart TB
grafana -.-> prom
```
Le lien `front -.-> api` est en pointillé à dessein : le frontend n'appelle aujourd'hui aucune
API, `provideHttpClient` n'est pas encore installé. Voir [30-frontend.md](30-frontend.md).
Le lien `front -.-> api` reste en pointillé : le frontend appelle bien une API, mais un
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
collecteur ne vient le lire.
@@ -73,9 +74,9 @@ collecteur ne vient le lire.
| 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 |
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Squelette `ng new` standalone, routes vides, aucun service HTTP |
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative |
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) |
| 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. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
| 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 |
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
+14 -5
View File
@@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification.
```mermaid
flowchart TB
ep["endpoints<br/>health, auth, users"]
ep["endpoints<br/>health, auth, users, sites"]
sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log"]
md["models<br/>4 tables"]
sv["services<br/>AuthService, UserService,<br/>SiteService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site"]
md["models<br/>10 tables"]
db[("PostgreSQL")]
ep --> sc
@@ -140,6 +140,8 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
| POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` |
| PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` |
| POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` |
| GET | `/api/v1/sites` | oui | Liste les sites. `lecteur` |
| GET | `/api/v1/sites/{site_id}` | oui | Décrit un site. `lecteur` |
| GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé |
| GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` |
@@ -148,7 +150,14 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose
donc de modifier la liste dans ce fichier de test.
Aucune route métier n'existe à ce jour. Le contrat détaillé pour le frontend est dans
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit à réutiliser
pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `recommendation`) : les quatre
couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables
déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`,
contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par
`AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par
`execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base
réelle. Le contrat détaillé pour le frontend est dans
[31-contrat-authentification.md](31-contrat-authentification.md).
### `/health/ready`
+43 -22
View File
@@ -4,30 +4,36 @@ Application Angular 22, 100 % standalone, testée avec Vitest. Source dans `apps
## État actuel
Statut : `En cours`. Le projet est un `ng new` intact. Le tableau de la
[vue d'ensemble](00-vue-ensemble.md) le classe désormais correctement, le `README.md` racine le
disait encore « à initialiser » alors que le squelette existe depuis `49f4697`.
Statut : `En cours`. L'application sert une première page métier, le tableau de bord, alimentée
par des fixtures : les endpoints qu'elle appelle n'existent pas encore côté API.
Ce qui est en place :
- Bootstrap par `bootstrapApplication(App, appConfig)`, **aucun `NgModule`** dans le dépôt.
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()` et `provideRouter(routes)`.
- Vitest via le builder `@angular/build:unit-test`, couverture activée, un fichier de test.
- `app.config.ts` fournit `provideBrowserGlobalErrorListeners()`, `provideRouter(routes)` et
`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.
Ce qui n'existe pas encore :
- `routes` est un tableau vide. Aucune page, aucune navigation.
- **`provideHttpClient` n'est pas fourni** et `@angular/common/http` n'est importé nulle part :
l'application n'appelle aucune API.
- `app.html` est la page d'accueil Angular par défaut, commentaires de remplacement compris.
- Aucune bibliothèque de graphiques, aucun kit d'interface, aucune gestion d'état.
- **Aucun endpoint réel derrière l'écran.** `GET /api/v1/stats/summary` et `GET /api/v1/alerts`
sont servis par l'intercepteur ; l'API expose `/health`, `/auth` et `/users`, rien d'autre.
- Aucune authentification côté interface : ni garde de route, ni intercepteur de jeton, alors que
les routes métier de l'API en exigent un. Voir
[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é.
## Arborescence cible
## Arborescence
Statut : `Cible`. Elle n'est pas inventée ici : [`TESTING.md`](../../apps/frontend/TESTING.md) la
prescrit déjà dans ses gabarits de tests.
Statut : `Fait`. Elle suit ce que [`TESTING.md`](../../apps/frontend/TESTING.md) prescrit dans ses
gabarits de tests.
```mermaid
flowchart TB
@@ -48,22 +54,33 @@ directement : ils passent par un service, ce qui rend le double de test trivial.
## 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
sequenceDiagram
participant C as Composant
participant S as Service Angular
participant I as mockApiInterceptor
participant P as ng serve, proxy
participant A as FastAPI
C->>S: appel de méthode
S->>P: GET /api/v1/...
P->>A: http://localhost:8000/api/v1/...
A-->>S: JSON
S->>I: GET /api/v1/...
alt useMockFixtures actif et route connue
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é
```
`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
qui évite le CORS sur le poste, et c'est pourquoi `environment.development.ts` se contente d'un
`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: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
`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.
@@ -98,8 +119,9 @@ avec un service statique, il reste à écrire.
## Sécurité
- 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
stade. `core/guards` et `core/interceptors` sont prévus pour cela.
- L'authentification existe côté API mais pas côté interface : aucune garde de route, aucun
intercepteur de jeton. `core/guards` reste à créer, `core/interceptors` n'héberge aujourd'hui
que les fixtures.
## Tests
@@ -107,8 +129,7 @@ Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTI
## Questions ouvertes
- **Quelle bibliothèque de graphiques** pour les séries temporelles, et si Grafana en couvre déjà
une partie du besoin.
- **Gestion d'état** : signaux seuls, ou une bibliothèque dédiée.
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
pages partageront le même état.
- **Comment `apiUrl` est injecté en production** : build par environnement, ou configuration lue
au démarrage.
+4 -3
View File
@@ -8,8 +8,9 @@ de réponse honnête.
Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, l'item qu'il adresse,
et une section qui dit ce qui n'est pas couvert et pourquoi.
Statut : `Fait` pour le périmètre authentification et autorisation. Les endpoints métier
n'existent pas encore, donc plusieurs lignes resteront à compléter.
Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et
`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes
resteront à compléter une fois les endpoints d'écriture posés.
## Contrôles en place
@@ -48,7 +49,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu
| Item | État | Raison |
|---|---|---|
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site. Un opérateur du site A pourra agir sur le site B dès que les endpoints métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` répond à tout compte `lecteur` pour n'importe quel site, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
| **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. |
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |