Dashboard HTML temps réel¶
perfshop-monitoring est un service Node.js custom qui fournit un dashboard HTML temps réel parallèle à Grafana. Il joue trois rôles distincts dans la stack :
- Producteur de métriques Docker — il interroge le socket Docker pour calculer les statistiques CPU/RAM/réseau/I/O des principaux containers et les expose au format Prometheus sur sa propre route
/metrics. C'est cette source qui alimente le job Prometheusperfshop-dockeret donc tous les panels « Containers » des dashboards Grafana. - Réceptionnaire des métriques navigateur — il reçoit en POST les Web Vitals envoyés par
chaos-agent.jsdepuis le navigateur de l'étudiant (FPS, heap JS, long tasks, fetch/s, DOM nodes, CPU worker actif) et les ré-expose dans/metricssous le préfixeperfshop_client_*. - Dashboard HTML autonome — il sert une page HTML statique qui interroge directement l'API Spring Boot Actuator (via
/api/prometheus-rawqui est un proxy) et le socket Docker, pour offrir une vue temps réel sans dépendance Grafana.
Source de vérité
Cette page est tirée de monitoring/src/server.js (~440 lignes), des bind mounts du service perfshop-monitoring dans les fichiers compose, et de monitoring/public/index.html pour le dashboard HTML servi.
Architecture¶
flowchart LR
subgraph BROWSER["Navigateur étudiant"]
direction TB
HTML["index.html<br/>(dashboard temps réel)"]
JS["chaos-agent.js<br/>(injecté par le frontend)"]
end
subgraph MON["perfshop-monitoring (Node + Express)"]
direction TB
SVR["server.js"]
CACHE[("statsCache<br/>TTL 5s")]
CLIENT[("lastClientMetrics<br/>(Web Vitals)")]
end
SOCK["/var/run/docker.sock<br/>(bind mount RO)"]
BE["perfshop-app:9090<br/>/actuator/prometheus<br/>/actuator/heapdump"]
PROM["Prometheus<br/>(scrape /metrics 5s)"]
HTML -->|"polling 2s<br/>GET /api/docker/all<br/>GET /api/prometheus-raw"| SVR
JS -->|"POST /api/chaos/client-metrics<br/>(toutes les 2s)"| SVR
SVR -->|"docker API"| SOCK
SVR -->|"fetch /actuator/prometheus<br/>(timeout 5s)"| BE
SVR -->|"GET /api/heapdump<br/>(proxy timeout 60s)"| BE
PROM -->|"scrape GET /metrics"| SVR
SVR -.cache 5s.-> CACHE
SVR -.dernière valeur.-> CLIENT
Configuration runtime¶
Variables d'environnement¶
environment:
APP_METRICS_URL: http://perfshop-app:9090/actuator/prometheus
POLL_INTERVAL: 2000
DOCKER_SOCKET: /var/run/docker.sock
PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:8080}
PUBLIC_MONITORING_URL: ${PUBLIC_MONITORING_URL:-http://localhost:3001}
PUBLIC_GRAFANA_URL: ${PUBLIC_GRAFANA_URL:-http://localhost:3002}
PUBLIC_CHAOS_URL: ${PUBLIC_CHAOS_URL:-http://localhost:3003}
PERFSHOP_API_INTERNAL: ${PERFSHOP_API_INTERNAL:-http://perfshop-app:8080}
PERFSHOP_LANG: ${PERFSHOP_LANG:-fr}
| Variable | Effet |
|---|---|
APP_METRICS_URL |
Endpoint /actuator/prometheus du backend (port management 9090, interne) |
POLL_INTERVAL=2000 |
Polling backend toutes les 2 secondes côté Node |
DOCKER_SOCKET=/var/run/docker.sock |
Chemin du socket Docker (monté en bind mount) |
PUBLIC_*_URL |
Injectées dans window.__CONFIG__ côté navigateur pour générer les liens de l'UI |
PERFSHOP_API_INTERNAL |
Endpoint backend pour le proxy /api/admin/login |
PERFSHOP_LANG |
Langue de l'UI (fr ou en) |
Bind mounts¶
| Mount | Effet |
|---|---|
./monitoring/public:/app/public |
Sources statiques HTML/CSS/JS du dashboard |
/var/run/docker.sock:/var/run/docker.sock:ro |
Accès lecture seule au socket Docker — permet d'interroger l'API Docker pour les stats containers |
Socket Docker en lecture seule
Le socket est monté en :ro, mais c'est une protection cosmétique : un client qui parle à /var/run/docker.sock peut potentiellement lister, inspecter et stopper des containers (l'API Docker n'a pas de granularité GET/POST côté socket). C'est acceptable parce que perfshop-monitoring n'est pas exposé publiquement et n'exécute pas de code utilisateur — mais c'est à garder en tête en cas d'audit de sécurité.
Routes Express¶
Le service expose une dizaine de routes, organisées en quatre familles.
Famille 1 — Pages HTML statiques¶
| Route | Méthode | Description |
|---|---|---|
/ |
GET | Sert index.html en injectant window.__CONFIG__ = {API_URL, MONITORING_URL, GRAFANA_URL, CHAOS_URL, LANG} dans <head> |
/config.js |
GET | Sert window.__CONFIG__ = ...; au format JS — utilisé par les pages HTML qui chargent leur config en <script src="..."> |
/admin/*, /css/*, /js/*, /i18n/*, /fonts/* |
GET | Statiques servies par express.static depuis /app/public |
/heapdump-widget.html |
GET | Mini-widget HTML pour le bouton heapdump |
Famille 2 — API Docker¶
| Route | Méthode | Description |
|---|---|---|
/api/docker/all |
GET | JSON consolidé des stats des 4 containers surveillés (cache 5 s) |
/api/docker/stats?container=<name> |
GET | Stats détaillées d'un seul container |
Le code de calcul des stats est ce qui rend ce service utile :
async function fetchContainerStats(name) {
const realName = resolvedNames[name] || name;
const s = await dockerRequest(`/containers/${realName}/stats?stream=false`);
const cpuDelta = s.cpu_stats.cpu_usage.total_usage - s.precpu_stats.cpu_usage.total_usage;
const sysDelta = s.cpu_stats.system_cpu_usage - s.precpu_stats.system_cpu_usage;
const numCpus = s.cpu_stats.online_cpus || s.cpu_stats.cpu_usage.percpu_usage?.length || 1;
const cpuPercent = sysDelta > 0 ? (cpuDelta / sysDelta) * numCpus * 100 : 0;
const memUsage = s.memory_stats.usage || 0;
const memCache = s.memory_stats.stats?.cache || s.memory_stats.stats?.inactive_file || 0;
const memActual = Math.max(0, memUsage - memCache);
// ...
}
Points-clés :
stream=falsesur l'API Docker — sinon Docker stream les stats en continu (chunked HTTP), ce qui ne convient pas à un endpoint REST.- CPU% calculé à partir du delta entre l'appel courant et
precpu_stats(l'API Docker fournit les deux), multiplié par le nombre de CPU en ligne. - Mémoire utile =
memory_stats.usage - memory_stats.stats.cache— l'API brute compte le cache page comme « utilisé », ce qui gonfle artificiellement la valeur. PerfShop soustrait le cache pour avoir une mesure fidèle. - Réseau : agrégation de toutes les interfaces du container (
s.networks). - I/O disque : extraction des opérations
ReadetWritedansblkio_stats.io_service_bytes_recursive.
Résolution dynamique des noms¶
async function resolveContainerNames() {
const list = await dockerRequest('/containers/json?all=false');
for (const c of list) {
const names = (c.Names || []).map(n => n.replace(/^\//, ''));
for (const logical of CONTAINERS_TO_WATCH) {
for (const real of names) {
if (real === logical || real.endsWith('-' + logical) || real.endsWith('_' + logical)) {
resolvedNames[logical] = real;
}
}
}
}
}
setInterval(resolveContainerNames, 60000);
Selon le project_name Docker Compose utilisé (par défaut le nom du dossier), Docker peut préfixer les containers : perfshop-perfshop-app, myproject_perfshop-app, etc. Le code résout dynamiquement les vrais noms toutes les 60 secondes pour rester compatible avec n'importe quel project name.
Famille 3 — /metrics Prometheus¶
app.get('/metrics', async (req, res) => {
const all = await refreshStats();
const lines = [...];
// Pour chaque container surveillé
for (const [name, s] of Object.entries(all)) {
const l = `{container="${name}"}`;
lines.push(`docker_container_cpu_percent${l} ${s.cpu_percent}`);
lines.push(`docker_container_mem_usage_bytes${l} ${s.mem_usage}`);
lines.push(`docker_container_mem_limit_bytes${l} ${s.mem_limit}`);
// ... 9 métriques par container
}
// Métriques navigateur (si fraîches)
const m = lastClientMetrics;
const stale = !m.receivedAt || (Date.now() - m.receivedAt) > 10000;
if (!stale) {
lines.push(`perfshop_client_fps ${m.fps ?? 0}`);
lines.push(`perfshop_client_heap_used_mb ${m.heapUsedMB ?? 0}`);
// ...
}
res.set('Content-Type', 'text/plain; version=0.0.4');
res.send(lines.join('\n') + '\n');
});
C'est cet endpoint que le job Prometheus perfshop-docker scrape toutes les 5 secondes. Voir prometheus.md pour la liste complète des métriques produites.
Métriques navigateur — staleness¶
Les métriques perfshop_client_* sont conditionnellement émises : si plus de 10 secondes se sont écoulées depuis la dernière réception, elles ne sont pas exposées. Cela évite à Prometheus de continuer à voir des séries figées quand l'étudiant ferme son onglet navigateur.
Famille 4 — Métriques navigateur (push depuis le frontend)¶
| Route | Méthode | Description |
|---|---|---|
/api/chaos/client-metrics |
POST | Réception des Web Vitals depuis chaos-agent.js (toutes les 2 secondes) |
/api/chaos/client-metrics |
GET | Lecture de la dernière valeur (avec flag stale: true/false) |
Le POST attend un body JSON :
{
"fps": 60,
"longTasksPerSec": 0.5,
"heapUsedMB": 42.3,
"heapLimitMB": 2048,
"pendingFetches": 1.2,
"domNodeCount": 543,
"cpuWorkerActive": false,
"timestamp": 1712345678901
}
Toutes les valeurs sont optionnelles (le serveur conserve la dernière connue si une clé manque), avec validation de type explicite.
Famille 5 — Proxies vers le backend¶
/api/prometheus-raw¶
app.get('/api/prometheus-raw', async (req, res) => {
const response = await fetchWithTimeout(APP_METRICS_URL);
const text = await response.text();
res.set('Content-Type', 'text/plain; version=0.0.4');
res.send(text);
});
Proxy direct vers http://perfshop-app:9090/actuator/prometheus, avec un timeout de 5 secondes (AbortController). Utilisé par le dashboard HTML qui parse lui-même les métriques côté client (avec sa propre fonction parsePrometheus()) — c'est ce qui permet au dashboard de fonctionner sans Grafana.
/api/heapdump¶
const HEAPDUMP_URL = process.env.HEAPDUMP_URL || 'http://perfshop-app:9090/actuator/heapdump';
app.get('/api/heapdump', async (req, res) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 60000);
const response = await fetch(HEAPDUMP_URL, { signal: controller.signal });
// ...
const filename = `heapdump-${new Date().toISOString().replace(/[:.]/g, '-')}.hprof`;
res.set('Content-Type', 'application/octet-stream');
res.set('Content-Disposition', `attachment; filename="${filename}"`);
response.body.pipe(res);
});
Proxy vers /actuator/heapdump du backend, avec :
- Timeout généreux de 60 secondes (un heap dump peut prendre ~30 s sur une JVM chargée)
- Renommage en
heapdump-<ISO>.hprofpour que le navigateur propose un téléchargement avec un nom horodaté - Streaming via
pipe()— le fichier.hprofpeut peser plusieurs centaines de Mo, on ne le charge pas en RAM côté Node
C'est le point d'entrée du chaos mémoire pédagogique : l'étudiant clique sur un bouton « Télécharger heap dump », attend une trentaine de secondes, et reçoit un fichier .hprof qu'il ouvre dans Eclipse MAT ou VisualVM pour analyser une fuite mémoire. Voir ../architecture/multi-session.md pour le couplage avec le cache mémoire optionnel des sessions pédagogiques.
/api/admin/login¶
const BACKEND_INTERNAL = process.env.PERFSHOP_API_INTERNAL || 'http://perfshop-app:8080';
app.post('/api/admin/login', async (req, res) => {
const resp = await fetch(`${BACKEND_INTERNAL}/api/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req.body || {}),
});
if (resp.status === 402) return res.status(402).json({ error: 'Licence PerfShop manquante' });
const data = await resp.json();
res.status(resp.status).json(data);
});
Proxy vers le login admin du backend. Pourquoi un proxy ? Pour éviter d'avoir à gérer le cross-origin depuis le navigateur — si le dashboard perfshop-monitoring veut s'authentifier en admin, il appelle son propre backend Node qui relaye vers Spring Boot, restant ainsi sur la même origine.
Cache et fraîcheur¶
let statsCache = {};
let lastFetch = 0;
const CACHE_TTL = 5000;
async function refreshStats() {
const now = Date.now();
if (now - lastFetch < CACHE_TTL) return statsCache;
lastFetch = now;
const results = await Promise.all(CONTAINERS_TO_WATCH.map(async name =>
({ name, stats: await fetchContainerStats(name) })
));
statsCache = {};
for (const { name, stats } of results) if (stats) statsCache[name] = stats;
return statsCache;
}
Le cache de stats Docker a un TTL de 5 secondes. Cela évite que des dizaines de clients (Prometheus + dashboard HTML + autres) qui se rafraîchissent en parallèle ne déclenchent autant d'appels à l'API Docker — un seul appel toutes les 5 secondes suffit, et tous les consommateurs lisent depuis le cache.
C'est aligné sur le scrape_interval: 5s de Prometheus.
Containers surveillés¶
const CONTAINERS_TO_WATCH = ['perfshop-frontend', 'perfshop-app', 'perfshop-db', 'perfshop-monitoring'];
Quatre containers, pas plus. C'est volontaire :
perfshop-frontend— la chaîne de bout en bout (le front à observer)perfshop-app— le backend (cœur des chaos)perfshop-db— la base (impacts BDD des chaos)perfshop-monitoring— soi-même (utile pour vérifier que le service ne tire pas trop de ressources lui-même)
Les autres services (Grafana, Loki, Tempo, Squash TM, Forgejo, etc.) ne sont pas surveillés par ce job. Ils ont leurs propres logs (Loki / OpenSearch) et leurs propres dashboards Grafana ; leur santé en termes de ressources Docker est moins prioritaire pour les démos pédagogiques.
Dépendance Docker socket — implication architecturale¶
Le fait que perfshop-monitoring consomme le socket Docker en lecture est ce qui le rend indéplaçable : on ne peut pas le déployer sur une machine différente de celle qui héberge les autres containers. C'est cohérent avec le modèle PerfShop (mono-hôte), mais c'est une contrainte à connaître.
Pour aller plus loin¶
- Vue d'ensemble — trois interfaces de visualisation parallèles
- Prometheus — job
perfshop-dockerqui scrape/metricsde ce service - Dashboards livrés — dashboard
perfshop-frontend-elevequi consomme les métriquesperfshop_client_* - Multi-session pédagogique — couplage chaos mémoire ↔ heap dump via
/api/heapdump - Section Interfaces (LOT 4) — détail de l'UI du dashboard HTML monitoring servie par ce service