Aller au contenu

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 :

  1. 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 Prometheus perfshop-docker et donc tous les panels « Containers » des dashboards Grafana.
  2. Réceptionnaire des métriques navigateur — il reçoit en POST les Web Vitals envoyés par chaos-agent.js depuis le navigateur de l'étudiant (FPS, heap JS, long tasks, fetch/s, DOM nodes, CPU worker actif) et les ré-expose dans /metrics sous le préfixe perfshop_client_*.
  3. Dashboard HTML autonome — il sert une page HTML statique qui interroge directement l'API Spring Boot Actuator (via /api/prometheus-raw qui 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

volumes:
  - ./monitoring/public:/app/public
  - /var/run/docker.sock:/var/run/docker.sock:ro
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=false sur 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 Read et Write dans blkio_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>.hprof pour que le navigateur propose un téléchargement avec un nom horodaté
  • Streaming via pipe() — le fichier .hprof peut 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-docker qui scrape /metrics de ce service
  • Dashboards livrés — dashboard perfshop-frontend-eleve qui consomme les métriques perfshop_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