La vitre web de la salle de contrôle & Le tableau de bord comme code
La salle de contrôle gagne sa vitre : un Grafana local, monté dans un conteneur Podman en une commande, qui lit un instantané de votre journal SQLite — sans collector, sans pipeline, sans un clic de configuration — et un tableau de bord écrit en code, dont chaque requête se vérifie à sec.
Au chapitre 20, vous avez ouvert la porte vers l’extérieur : un pont d’une commande projette vos
runs dans OpenTelemetry et Prometheus, « pour qui veut les voir dans Grafana ». Mais posez-vous
la question honnête : avez-vous un Grafana ? Un collector qui tourne, un Prometheus qui
scrape ? Le plus souvent, non, et la salle de contrôle reste ce qu’elle est depuis le chapitre
19 : un terminal, une requête sqlite3, des couloirs en texte. Excellent pour déboguer, ingrat
pour regarder : la tendance des coûts sur la semaine, la phase qui mange le budget, le run qui
s’est éternisé, ça se voit en une seconde sur un graphe et en dix minutes dans un tableau.
À la fin de ce chapitre, vous aurez une vitre web sur votre journal : un Grafana local, monté
par une seule commande dans un conteneur Podman, qui lit directement une copie de factory.db
(aucun collector, aucun pipeline, aucun clic de configuration), et un tableau de bord écrit en
code, neuf panneaux et une variable, dont chaque requête est vérifiée à sec par la gate avant
d’atteindre l’écran. Deux pièces : adws/obs_grafana.py et just/obs.just (qui remplace la
version du chapitre 19). Podman fait ici sa première apparition dans le livre. Le module 6 en
fera le moteur de vos boîtes, et le préflight du chapitre 21 le vérifiera. Ce chapitre se lit
et se teste à sec, sans conteneur : seule la dernière ligne de la gate en monte un.
Une vitre, pas un pipeline : Grafana lit un instantané du journal
L’idée en une phrase
La vitre est une lecture de plus sur le journal du chapitre 18, exactement comme le pont du
chapitre 20 : Grafana n’est pas branché sur l’usine, il lit un instantané de factory.db,
pris par le code, monté en lecture seule dans un conteneur qui n’écoute que sur votre poste.
Le chemin reste usine → SQLite → lecture, rien ne remonte vers le runner, et un Grafana
éteint ne coûte qu’une vitre noire, jamais un run.
Points clés
- Pas de pipeline, un fichier. Grafana possède une source de données SQLite (un greffon
communautaire,
frser-sqlite-datasource, installé au démarrage du conteneur) : il ouvre le fichier, exécute votre SQL, dessine. Vos requêtes du chapitre 20 (SUM(cost_usd) … GROUP BY name) deviennent des panneaux telles quelles. Aucun collector, aucun Prometheus, aucun format intermédiaire. - On ne lit jamais le journal en place.
factory.dbest en mode WAL (chapitre 18) : le lire depuis un autre système (le conteneur, et sous macOS ou Windows la machine virtuelle de Podman) exigerait des verrous partagés qui ne traversent pas ces frontières de façon fiable. Le code prend donc un instantané par l’API de sauvegarde de SQLite (cohérent, page à page, pendant que l’usine écrit), le convertit en journal classique, et le remplace atomiquement. Grafana rouvre le fichier à chaque requête : il voit l’instantané suivant sans redémarrer. - Lecture seule, trois fois. Le montage est
ro, la source de données ouvre le fichier enmode=ro&immutable=1(ni verrou ni fichier annexe, ce qui est juste puisque l’instantané est remplacé et jamais modifié), et le greffon imposequery_only. Une vitre ne peut pas écrire dans le journal. - Local par construction. Le port est publié sur
127.0.0.1seulement. L’accès anonyme est ouvert sur ce port local pour vous épargner un mot de passe, et la télémétrie de Grafana est coupée. Une version épinglée de l’image et du greffon, comme pour pi : « latest » n’est pas une version.
Exemple concret
Vous venez de lancer le SDLC du chapitre 13 pour la troisième fois de la journée. Depuis la
racine, just obs grafana up : l’instantané est pris (quelques millisecondes, trois runs),
deux fichiers de provisioning et le tableau de bord sont écrits sous adws/adw_data/grafana/,
le conteneur démarre. Le premier démarrage télécharge l’image (~300 Mo) et le greffon
(~10 Mo) : comptez une minute, les suivants prennent quelques secondes. La commande attend
que Grafana réponde, puis imprime l’adresse http://127.0.0.1:3000/d/plume-factory. Trois
runs, un vert, un rouge, un en cours, build en tête des dépenses, et le run rouge sélectionné
dans la liste montre sa phase test en rouge à côté d’un build qui a retenté deux fois. Coût
de l’opération : zéro token, ces runs sont déjà payés et la vitre ne fait que les montrer.
Lancez un nouveau run dans un autre terminal, just obs grafana watch dans un troisième : toutes
les cinq secondes un instantané, et le run apparaît, phase après phase, sur l’écran.
Trois façons de voir un run
| Terminal (ch. 19) | Pont OTel / Prometheus (ch. 20) | Vitre Grafana locale (20bis) | |
|---|---|---|---|
| Ce qu’il faut | rien | un collector ou un Prometheus debout | Podman |
| Lit | factory.db en place | factory.db, exporte après coup | un instantané de factory.db |
| Répond à | quoi, où, quand — un run à la fois | tendances, à côté de votre infra d’équipe | tendances et détail, sur votre poste |
| Rafraîchissement | à la commande | à l’export | sync ou watch |
| Source de vérité | oui | non — projection | non — projection |
Commande — monter Grafana avec Podman, ce que fait up
Podman est un moteur de conteneurs sans démon et rootless : chaque commande est un
processus ordinaire, sans service en arrière-plan ni sudo. Sous Linux, il vient de votre
gestionnaire de paquets. Sous macOS et Windows, il pilote une petite machine virtuelle qu’il faut
créer une fois puis démarrer, trois commandes, une par ligne :
podman --version
podman machine init
podman machine start
(podman machine … ne concerne que macOS et Windows. Sous Linux, podman --version suffit.
Sous macOS, votre dossier personnel est partagé avec la machine par défaut. Sous Windows, gardez
plume-factory sous votre profil utilisateur pour la même raison.)
La pièce du jour ne vous demande pas d’écrire la commande podman run : c’est up qui la
construit. La voici, expliquée, pour que rien ne reste magique, un montage par ligne :
podman run -d --name plume-grafana \
-p 127.0.0.1:3000:3000 \
-v plume-grafana-data:/var/lib/grafana \
-v …/adws/adw_data/grafana/provisioning:/etc/grafana/provisioning:ro,Z \
-v …/adws/adw_data/grafana/dashboards:/var/lib/factory/dashboards:ro,Z \
-v …/adws/adw_data/grafana/db:/var/lib/factory/db:ro,Z \
-e GF_PLUGINS_PREINSTALL_SYNC=frser-sqlite-datasource@4.0.6 \
-e GF_PLUGINS_PREINSTALL_AUTO_UPDATE=false \
-e GF_AUTH_ANONYMOUS_ENABLED=true -e GF_AUTH_ANONYMOUS_ORG_ROLE=Admin \
-e GF_ANALYTICS_REPORTING_ENABLED=false -e GF_ANALYTICS_CHECK_FOR_UPDATES=false \
docker.io/grafana/grafana:12.4.10
Ligne par ligne : le port n’est publié que sur 127.0.0.1, donc la vitre n’écoute que sur votre
poste. Un volume nommé garde ce que Grafana écrit lui-même (le greffon installé, ses
sessions) d’un démarrage à l’autre. Trois montages en lecture seule (ro, le Z étant
l’étiquette SELinux, ignorée quand il n’y en a pas) apportent le provisioning, le tableau de bord
et l’instantané. Des variables GF_* remplacent un fichier grafana.ini, et chaque réglage
de Grafana a son nom de variable, la doc les liste. Le greffon s’installe en mode synchrone
(PREINSTALL_SYNC) : sinon le provisioning de la source de données tournerait avant lui, et la
vitre resterait noire jusqu’au redémarrage suivant.
Piège courant : « je monte
adws/adw_data/directement dans le conteneur, Grafana lirafactory.dben direct » est inexact. Une base WAL lue depuis un autre système réclame un fichier-shmpartagé et des verrous que le montage ne garantit pas : au mieuxattempt to write a readonly database, au pire des lectures incohérentes. L’instantané coûte quelques millisecondes et rend la vitre indépendante de l’usine, c’est le même principe que le pont du chapitre 20 : une projection, reconstructible, jamais la source de vérité.
Le tableau de bord comme code : panneaux, variable, gate
L’idée en une phrase
Le tableau de bord n’est pas cliqué dans Grafana, il est écrit : une table PANELS dans
obs_grafana.py (un titre, un type de panneau, une requête SQL, une taille) rendue en JSON
par le code et provisionnée par fichiers au démarrage. Ajouter un panneau, c’est ajouter
une ligne, et la gate exécute chaque requête à sec sur un journal factice avant qu’un seul
conteneur ne démarre : la vitre est du code déterministe, versionné avec l’usine.
Points clés
- Le provisioning remplace les clics. Grafana lit au démarrage deux dossiers : les sources
de données (
provisioning/datasources/*.yaml, soit ici le greffon SQLite, le chemin de l’instantané, les options de lecture seule) et les fournisseurs de tableaux de bord (provisioning/dashboards/*.yaml, un dossier de JSON lu au démarrage). Le code écrit ces trois fichiers à chaqueup: effacez tout, relancez, la vitre revient à l’identique. Le fournisseur ne repasse pas sur le dossier tout seul (mesuré sur l’image épinglée, avecallowUiUpdates: le JSON change, la vitre ne bouge pas), doncuplui demande la relecture (/api/admin/provisioning/dashboards/reload) et bumpe la version du tableau de bord : sans ces deux gestes, votre panneau tout neuf reste sur le disque et la vitre affiche celui de la veille. - Neuf panneaux, quatre questions. Où en est-on (runs par verdict, dollars de la période), où va l’argent (coût par run dans le temps, coût cumulé par phase — la requête du chapitre 20 dessinée), quels runs (la liste des derniers), et pour le run choisi : ce qu’on a demandé, la trace, les phases, le journal brut. Un panneau, une question — pas deux panneaux pour dire la même minute.
- Une série par graphe. Des dollars et un compteur de reprises sur le même axe, c’était deux
abscisses et trois barres par phase : illisible. Le graphe porte une seule grandeur, les
comptes vivent dans les tables. Même hygiène pour les couloirs : la colle (les phases
code, quelques centièmes de seconde) y remplissait douze lignes vides, elle en sort — la table des phases, elle, garde tout. - Les couloirs deviennent une trace. Une phase = deux barres empilées : l’élan (le départ,
en secondes depuis le début du run), rendu invisible par une opacité de remplissage nulle,
puis la durée. Chaque phase se pose donc à son heure sur un axe partagé, et l’écran ressemble
à une trace Jaeger : une voie par phase, la durée dans le libellé (
05 plan · 221 s), ni étiquette ni légende sur le graphe. Deux détails coûtent une soirée si on les rate : l’empilement du barchart est une option du panneau (options.stacking), pas du champ, et cette trace ne dépend pas de la période choisie dans Grafana — un run de neuf minutes dans une fenêtre de sept jours serait un cheveu sur un axe de temps réel. Les vraies traces imbriquées, elles, sont le sujet du chapitre 21 (obs_export.py --otlp) et demandent un collector. - Une variable, le run. La liste déroulante
Runest elle-même une requête (adw_id, libelléid · adw · verdict, le plus récent en tête), et les quatre derniers panneaux la lisent par${adw_id}. Le filtre de période de Grafana arrive dans vos requêtes par$__fromet$__to, en millisecondes :strftime('%s', started_at) * 1000les rejoint. - Le temps est du texte, sauf où l’on décide. Le tracer date en ISO et SQLite le lit tel
quel (
strftime,julianday). Seule la colonne nommée danstimeColumnsest convertie en temps pour Grafana : le reste reste des tables, ce que la plupart des panneaux préfèrent. - La gate vérifie la vitre, pas son apparence.
--selftestfabrique un journal WAL de deux runs et ses événements, prend l’instantané, contrôle qu’il n’est plus en WAL, exécute chaque requête des panneaux et celle de la variable (variables remplacées par des valeurs de test), et vérifie quelques verdicts précis : un vert, un rouge,builden tête et la phase à 0 $ hors du graphe, la trace à 0 / 40 / 480 s de départ pour 40 s / 440 s / 5 s de durée. Les panneaux s’y désignent par leur titre : ajouter une ligne àPANELSne casse plus une assertion qui comptait sur un rang. Une requête qui casse casse la gate, pas votre soirée.
Exemple concret
Vous voulez un onzième panneau : le taux de reprise par phase. Une ligne dans PANELS (un
titre, "barchart", SELECT name AS phase, ROUND(AVG(attempt > 1) * 100) AS pct FROM phases GROUP BY name, une largeur, une hauteur), puis uv run adws/obs_grafana.py --selftest : si
la requête passe sur le journal factice, elle passera sur le vôtre. just obs grafana up
réécrit le JSON et demande la relecture, le panneau est là. Vous avez
retouché une couleur dans l’interface ? Elle tiendra jusqu’au prochain up, qui régénère le
fichier : la vérité est dans le code, et si la retouche vaut la peine elle vaut une ligne.
Le tableau de bord : les neuf panneaux
| Panneau | Type | La question | Lit |
|---|---|---|---|
| Runs sur la période | stat | où en est-on — verts, rouges, en cours | runs |
| Dollars et jetons | stat | ce que la période a coûté | runs |
| Coût par run dans le temps | timeseries (barres) | la tendance | runs |
| Coût cumulé par phase | barchart | où va l’argent (les phases à 0 $ sortent) | phases × runs |
| Les derniers runs | table | verdict, coût, jetons, durée | runs |
| Run choisi — la demande | table | ce qu’on a demandé, en toutes lettres | runs |
| Run choisi — les couloirs | barchart empilé | la trace : quand, et combien de temps | phases |
| Run choisi — les phases | table | tentatives, erreurs, coût par phase | phases |
| Run choisi — le journal | table | les événements bruts, payloads compris | events |
Config — un panneau, une ligne
L’entrée de PANELS qui dessine « où va l’argent », la requête du chapitre 20 jointe aux runs
pour respecter la période choisie dans Grafana. Une seule grandeur, usd, et un HAVING qui
met dehors les phases code qui ne coûtent rien :
("Où va l'argent — coût cumulé par phase", "barchart",
"SELECT p.name AS phase, ROUND(SUM(p.cost_usd), 3) AS usd"
" FROM phases p JOIN runs r ON r.adw_id = p.adw_id"
" WHERE CAST(strftime('%s', r.started_at) AS INTEGER) * 1000 BETWEEN $__from AND $__to"
" GROUP BY p.name HAVING SUM(p.cost_usd) > 0 ORDER BY usd DESC",
[], 12, 8, {"units": {"usd": "currencyUSD"}}),
Et la trace du run, où deux colonnes suffisent : depart prend la place et ne se peint pas
("stack" nomme la barre d’élan, _panel lui met une opacité de remplissage nulle), duree
se pose dessus, et la durée s’écrit dans le libellé de la voie plutôt que sur le graphe :
("Run $adw_id — les couloirs : la trace du run", "barchart",
"SELECT printf('%02d %s · %.0f s', seq, name, " + SECONDS + ") AS phase,"
" " + OFFSET + " AS depart, " + SECONDS + " AS duree FROM phases"
" WHERE adw_id = '${adw_id}' AND " + VISIBLE + " ORDER BY seq",
[], 24, 8, {"units": {"depart": "s", "duree": "s"}, "stack": "depart",
"colors": {"duree": "blue"}}),
Et les commandes de la vitre, une par ligne, identiques sous bash et PowerShell :
just obs grafana up
just obs grafana sync
just obs grafana watch --every 5
just obs grafana status
just obs grafana down
Piège courant : « un dashboard, ça se construit à la souris, puis on exporte le JSON » est inexact. Le JSON exporté d’un clic pèse des centaines de lignes que personne ne relit, ne se teste pas, et diverge dès le deuxième poste. Un tableau de bord qui appartient à l’usine se déclare comme le roster : des lignes courtes, lisibles en revue de code, rendues par le code et vérifiées par une gate : l’interface sert à regarder, pas à définir.
Fil rouge — la pièce posée aujourd’hui
La zone « salle de contrôle » du plan gagne sa vitre, sans déplacer un mur : le tracer du
chapitre 18 écrit, le runner du chapitre 20 compte, et adws/obs_grafana.py, une lecture de
plus à côté des couloirs du chapitre 19 et du pont du chapitre 20, prend un instantané, écrit
le provisioning et monte Grafana dans un conteneur Podman que le module 6 vous fera retrouver
comme moteur de boîtes. La couture ne bouge pas : l’agent propose, le code dispose, et le code
dessine. Les agents ignorent toujours qu’ils sont observés, aucune enveloppe ne traverse, et la
dépendance va de la vitre vers le journal, jamais du run vers la vitre. À l’usage : zéro
token, quelques millisecondes par instantané, une minute au premier démarrage et ~300 Mo
d’image. À l’économie : la question « quel étage du roster, quel prompt, quelle phase » se
répondait par une requête, elle se lit désormais en une seconde, sur des runs déjà payés.
Travaux pratiques — la pièce du jour
Une pièce complète à poser dans le repo compagnon plume-factory, qui devient, chapitre après
chapitre, votre usine logicielle agentique. Aujourd’hui, deux fichiers : la vitre, et le module
obs qui la monte.
Pièce — adws/obs_grafana.py
Nouveau. Côté code déterministe, hôte seulement : l’instantané (snapshot, API de sauvegarde
SQLite, journal classique, remplacement atomique), le tableau de bord (PANELS, render_dashboard),
le provisioning (write_provisioning), le conteneur (up, status, down), le
rafraîchissement (sync, watch) et la gate (--selftest, zéro conteneur, zéro réseau). Il ne
dépend d’aucune autre pièce : il lit le journal du chapitre 18, rien d’autre. L’image et le
greffon sont épinglés, et FACTORY_GRAFANA_IMAGE dans .env les surcharge le jour où vous
choisirez une autre version. Vérifiez alors que le greffon la supporte.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# ///
"""obs_grafana — la vitre web de la salle de controle : Grafana sur le journal.
Aucun pipeline : Grafana lit une COPIE du journal du chapitre 18 (factory.db),
par sa source de donnees SQLite, dans un conteneur Podman qui n'ecoute que sur
votre poste. Le chemin reste usine -> sqlite -> lecture ; la vitre est une
lecture de plus, comme le pont du chapitre 20. Le tableau de bord est du CODE
(la table PANELS ci-dessous), rendu en JSON et provisionne par fichiers :
zero clic de configuration, et chaque requete se verifie a sec.
Le journal vit en mode WAL : le lire depuis un autre systeme (le conteneur,
et sous macOS/Windows la machine virtuelle de Podman) demanderait des
verrous partages peu fiables. On ne le lit donc jamais en place : `sync`
en prend un instantane coherent (API de sauvegarde SQLite), en mode journal
classique, remplace atomiquement — et Grafana rouvre le fichier a chaque
requete, donc voit l'instantane suivant sans redemarrer.
uv run adws/obs_grafana.py up [--port 3000] # instantane + provisioning + conteneur
uv run adws/obs_grafana.py sync # rafraichir l'instantane (quelques ms)
uv run adws/obs_grafana.py watch [--every 5] # sync en boucle, Ctrl-C pour sortir
uv run adws/obs_grafana.py status
uv run adws/obs_grafana.py down [--purge] # arreter ; --purge oublie aussi le volume
uv run adws/obs_grafana.py --selftest # la gate : zero conteneur, zero reseau
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
DB_PATH = Path("adws/adw_data/factory.db")
WORK_DIR = Path("adws/adw_data/grafana") # tout ce que la vitre fabrique : jetable
SNAPSHOT = WORK_DIR / "db" / "factory.db" # la copie que Grafana lit
PROVISIONING = WORK_DIR / "provisioning"
DASHBOARDS = WORK_DIR / "dashboards"
# Epingles, comme pi (annexe) : une version verifiee, pas « latest ».
# 12.4 est la branche en support de securite la plus longue au moment d'ecrire ;
# le greffon SQLite y est teste de bout en bout. Surchargeable par .env.
GRAFANA_IMAGE = os.environ.get("FACTORY_GRAFANA_IMAGE", "docker.io/grafana/grafana:12.4.10")
SQLITE_PLUGIN = "frser-sqlite-datasource@4.0.6"
CONTAINER = "plume-grafana"
VOLUME = "plume-grafana-data" # l'etat propre de Grafana (greffon, sessions)
DATASOURCE_UID = "plume-factory-db"
DASHBOARD_UID = "plume-factory"
# Dans le conteneur : trois montages en LECTURE SEULE.
IN_DB = "/var/lib/factory/db"
IN_DASHBOARDS = "/var/lib/factory/dashboards"
IN_PROVISIONING = "/etc/grafana/provisioning"
# Le filtre de periode de Grafana, en SQL : started_at est l'ISO du tracer
# (millisecondes, decalage +00:00), que strftime lit tel quel. $__from et
# $__to arrivent en millisecondes.
IN_RANGE = "CAST(strftime('%s', {col}) AS INTEGER) * 1000 BETWEEN $__from AND $__to"
SECONDS = "ROUND((julianday(ended_at) - julianday(started_at)) * 86400, 1)"
# Le depart d'une phase, en secondes depuis le debut du run : l'abscisse de la
# cascade. Une barre transparente de cette longueur, la duree empilee dessus,
# et les phases se lisent comme une trace — sans dependre de la periode choisie.
OFFSET = ("ROUND((julianday(started_at) - julianday((SELECT MIN(started_at) FROM phases"
" WHERE adw_id = '${adw_id}'))) * 86400, 1)")
# La colle (phases code, quelques centiemes de seconde) remplit les couloirs de
# lignes vides : on n'y garde que les agents et ce qui dure au moins une demi-seconde.
VISIBLE = "(kind = 'agent' OR " + SECONDS + " >= 0.5)"
# ── le tableau de bord : une ligne par panneau ──────────────────────────────
# (titre, type de panneau, requete, colonnes de temps, largeur, hauteur, options)
# Ajouter un panneau = ajouter une ligne ; la gate verifie sa requete a sec.
PANELS = [
("Runs sur la période", "stat",
"SELECT SUM(status='success') AS verts, SUM(status='fail') AS rouges,"
" SUM(status='running') AS en_cours FROM runs WHERE " + IN_RANGE.format(col="started_at"),
[], 8, 5, {"colors": {"verts": "green", "rouges": "red", "en_cours": "yellow"}}),
("Dollars et jetons sur la période", "stat",
"SELECT ROUND(COALESCE(SUM(cost_usd), 0), 2) AS usd, COALESCE(SUM(total_tokens), 0) AS jetons"
" FROM runs WHERE " + IN_RANGE.format(col="started_at"),
[], 8, 5, {"units": {"usd": "currencyUSD", "jetons": "short"}}),
("Coût par run dans le temps", "timeseries",
"SELECT CAST(strftime('%s', started_at) AS INTEGER) AS time, cost_usd AS usd"
" FROM runs WHERE " + IN_RANGE.format(col="started_at") + " ORDER BY time",
["time"], 8, 5, {"units": {"usd": "currencyUSD"}, "bars": True}),
# Une seule serie par graphe : usd et un compteur sur le meme axe donnaient
# deux abscisses et trois barres par phase. Les comptes vivent dans les tables.
("Où va l'argent — coût cumulé par phase", "barchart",
"SELECT p.name AS phase, ROUND(SUM(p.cost_usd), 3) AS usd"
" FROM phases p JOIN runs r ON r.adw_id = p.adw_id"
" WHERE " + IN_RANGE.format(col="r.started_at")
+ " GROUP BY p.name HAVING SUM(p.cost_usd) > 0 ORDER BY usd DESC",
[], 12, 8, {"units": {"usd": "currencyUSD"}}),
("Les derniers runs", "table",
"SELECT adw_id, adw_name, status, ROUND(cost_usd, 3) AS usd, total_tokens AS jetons,"
" " + SECONDS + " AS secondes, started_at FROM runs WHERE " + IN_RANGE.format(col="started_at")
+ " ORDER BY started_at DESC LIMIT 50",
[], 12, 8, {"status": "status", "units": {"usd": "currencyUSD", "secondes": "s"}}),
# Ce qu'on a demande, en toutes lettres : le tableau de bord repond « ou va
# l'argent », il doit d'abord dire de quelle demande on parle.
("Run $adw_id — la demande", "table",
"SELECT request AS demande FROM runs WHERE adw_id = '${adw_id}'",
[], 24, 6, {"wrap": ["demande"]}),
# Les couloirs du chapitre 19, sur une horloge : une voie par phase visible,
# la barre posee a son heure. « stack » nomme la barre d'elan, empilee SOUS
# la duree et rendue invisible — seule la duree se voit, a sa place sur l'axe.
("Run $adw_id — les couloirs : la trace du run", "barchart",
"SELECT printf('%02d %s · %.0f s', seq, name, " + SECONDS + ") AS phase,"
" " + OFFSET + " AS depart, " + SECONDS + " AS duree FROM phases"
" WHERE adw_id = '${adw_id}' AND " + VISIBLE + " ORDER BY seq",
[], 24, 8, {"units": {"depart": "s", "duree": "s"}, "stack": "depart",
"colors": {"duree": "blue"}}),
("Run $adw_id — les phases", "table",
"SELECT seq, name, kind, status, attempt, ROUND(cost_usd, 4) AS usd, tokens,"
" " + SECONDS + " AS secondes, error FROM phases WHERE adw_id = '${adw_id}' ORDER BY seq",
[], 24, 9, {"status": "status", "units": {"usd": "currencyUSD", "secondes": "s"}}),
# Le journal brut du chapitre 18 : c'est la que la demande, les tentatives et
# (des que le runner les trace) les prompts de chaque phase se lisent en clair.
("Run $adw_id — le journal des événements", "table",
"SELECT ts AS quand, type, name AS phase, payload_json AS détails"
" FROM events WHERE adw_id = '${adw_id}' ORDER BY ts",
[], 24, 10, {"wrap": ["détails"]}),
]
# La variable du tableau de bord : le run a detailler (le plus recent par defaut).
RUN_VARIABLE_QUERY = ("SELECT adw_id AS __value, adw_id || ' · ' || adw_name || ' · ' || status"
" AS __text FROM runs ORDER BY started_at DESC LIMIT 50")
STATUS_COLORS = {"success": "green", "fail": "red", "running": "yellow"}
# ── l'instantane ─────────────────────────────────────────────────────────────
def snapshot(src: Path = DB_PATH, dst: Path = SNAPSHOT) -> int:
"""Copie coherente du journal, en mode journal classique, remplacee atomiquement.
L'API de sauvegarde lit la base page a page sous verrou de lecture : l'usine
peut ecrire pendant ce temps, la copie est coherente. Rend le nombre de runs.
"""
if not Path(src).is_file():
sys.exit("aucun journal — lancez un ADW, puis revenez")
dst.parent.mkdir(parents=True, exist_ok=True)
tmp = dst.with_name(dst.name + ".tmp")
source = sqlite3.connect(src)
source.execute("PRAGMA busy_timeout=5000;")
copy = sqlite3.connect(tmp)
with copy:
source.backup(copy)
copy.execute("PRAGMA journal_mode=DELETE;") # plus de WAL : lisible sans fichier -shm
runs = copy.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
copy.close()
source.close()
for stray in (tmp.with_name(tmp.name + "-wal"), tmp.with_name(tmp.name + "-shm")):
stray.unlink(missing_ok=True)
os.replace(tmp, dst) # atomique : jamais de fichier a moitie ecrit
os.chmod(dst, 0o644) # lisible par l'utilisateur du conteneur (472)
os.chmod(dst.parent, 0o755)
return runs
# ── le tableau de bord, rendu depuis PANELS ─────────────────────────────────
def _panel(index: int, spec: tuple, x: int, y: int) -> dict:
title, kind, sql, time_columns, width, height, options = spec
overrides = []
for column, unit in options.get("units", {}).items():
overrides.append({"matcher": {"id": "byName", "options": column},
"properties": [{"id": "unit", "value": unit}]})
for column, color in options.get("colors", {}).items():
overrides.append({"matcher": {"id": "byName", "options": column},
"properties": [{"id": "color", "value": {"mode": "fixed", "fixedColor": color}}]})
for column in options.get("wrap", []): # le texte long revient a la ligne
overrides.append({"matcher": {"id": "byName", "options": column},
"properties": [{"id": "custom.cellOptions",
"value": {"type": "auto", "wrapText": True}}]})
if "status" in options: # la colonne verdict, coloree comme les couloirs
overrides.append({"matcher": {"id": "byName", "options": options["status"]},
"properties": [
{"id": "custom.cellOptions", "value": {"type": "color-text"}},
{"id": "mappings", "value": [{"type": "value", "options": {
status: {"color": color, "index": i}
for i, (status, color) in enumerate(STATUS_COLORS.items())}}]}]})
panel = {
"id": index, "type": kind, "title": title,
"gridPos": {"x": x, "y": y, "w": width, "h": height},
"datasource": {"type": "frser-sqlite-datasource", "uid": DATASOURCE_UID},
"targets": [{"refId": "A", "queryType": "table", "rawQueryText": sql,
"queryText": sql, "timeColumns": time_columns}],
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}}, "overrides": overrides},
"options": {},
}
if kind == "stat":
panel["options"] = {"colorMode": "value", "graphMode": "none", "textMode": "value_and_name",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False}}
elif kind == "timeseries" and options.get("bars"):
panel["fieldConfig"]["defaults"]["custom"] = {"drawStyle": "bars", "fillOpacity": 80,
"lineWidth": 1, "barAlignment": 0}
panel["options"] = {"legend": {"displayMode": "list", "placement": "bottom"},
"tooltip": {"mode": "multi"}}
elif kind == "barchart":
panel["options"] = {"orientation": "horizontal", "showValue": "auto", "xField": "phase",
"legend": {"displayMode": "list", "placement": "bottom"}}
if options.get("stack"): # la trace : elan + duree empiles
panel["options"]["stacking"] = "normal" # option du panneau, pas du champ
panel["options"]["showValue"] = "never" # la duree est deja dans le libelle
panel["options"]["legend"] = {"showLegend": False}
panel["options"]["barWidth"] = 0.55 # des barres, pas des blocs
panel["fieldConfig"]["defaults"]["custom"] = {"fillOpacity": 90, "lineWidth": 0}
# La barre d'elan occupe la place et ne se peint pas : une couleur
# transparente ne suffit pas, l'opacite de remplissage la rattrape.
panel["fieldConfig"]["overrides"].append(
{"matcher": {"id": "byName", "options": options["stack"]},
"properties": [{"id": "custom.fillOpacity", "value": 0}]})
elif kind == "bargauge":
panel["options"] = {"orientation": "horizontal", "displayMode": "gradient",
"showUnfilled": True, "namePlacement": "left",
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "/^secondes$/",
"values": True}}
panel["fieldConfig"]["defaults"]["color"] = {"mode": "continuous-GrYlRd"}
elif kind == "table":
panel["options"] = {"showHeader": True,
"cellHeight": "lg" if options.get("wrap") else "sm"}
panel["fieldConfig"]["defaults"]["custom"] = {"align": "auto", "cellOptions": {"type": "auto"}}
return panel
def render_dashboard() -> dict:
"""PANELS -> le modele JSON de Grafana. Placement : de gauche a droite, 24 colonnes."""
panels, x, y, row_height = [], 0, 0, 0
for index, spec in enumerate(PANELS, start=1):
width, height = spec[4], spec[5]
if x + width > 24:
x, y, row_height = 0, y + row_height, 0
panels.append(_panel(index, spec, x, y))
x, row_height = x + width, max(row_height, height)
return {
"uid": DASHBOARD_UID, "title": "Plume Factory — la salle de contrôle",
"tags": ["plume-factory"], "timezone": "browser", "editable": True,
# La version doit MONTER a chaque rendu : le fournisseur autorise les
# retouches dans l'interface (allowUiUpdates), et n'ecrase le tableau de
# bord que si le fichier annonce une version plus haute. Version figee =
# panneau ajoute jamais affiche. L'horloge suffit : le code gagne au prochain up.
"schemaVersion": 39, "version": int(time.time()), "refresh": "10s",
"time": {"from": "now-7d", "to": "now"},
"templating": {"list": [{
"name": "adw_id", "label": "Run", "type": "query", "refresh": 2,
"datasource": {"type": "frser-sqlite-datasource", "uid": DATASOURCE_UID},
"query": RUN_VARIABLE_QUERY, "definition": RUN_VARIABLE_QUERY,
"sort": 0, "includeAll": False, "multi": False,
}]},
"panels": panels,
}
# ── le provisioning : deux fichiers YAML ecrits par le code ────────────────
def write_provisioning() -> None:
(PROVISIONING / "datasources").mkdir(parents=True, exist_ok=True)
(PROVISIONING / "dashboards").mkdir(parents=True, exist_ok=True)
DASHBOARDS.mkdir(parents=True, exist_ok=True)
# mode=ro : jamais d'ecriture ; immutable=1 : aucun verrou, aucun fichier
# annexe — juste, puisque l'instantane est remplace, jamais modifie.
(PROVISIONING / "datasources" / "factory.yaml").write_text(
"apiVersion: 1\n"
"datasources:\n"
" - name: plume-factory\n"
f" uid: {DATASOURCE_UID}\n"
" type: frser-sqlite-datasource\n"
" access: proxy\n"
" isDefault: true\n"
" editable: false\n"
" jsonData:\n"
f" path: {IN_DB}/factory.db\n"
" pathPrefix: 'file:'\n"
" pathOptions: 'mode=ro&immutable=1'\n", encoding="utf-8")
(PROVISIONING / "dashboards" / "factory.yaml").write_text(
"apiVersion: 1\n"
"providers:\n"
" - name: plume-factory\n"
" type: file\n"
" disableDeletion: true\n"
" updateIntervalSeconds: 10\n" # au demarrage ; ensuite c'est `up` qui relance
" allowUiUpdates: true\n"
" options:\n"
f" path: {IN_DASHBOARDS}\n", encoding="utf-8")
(DASHBOARDS / "usine.json").write_text(
json.dumps(render_dashboard(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
for path in (PROVISIONING, DASHBOARDS):
for item in path.rglob("*"):
item.chmod(0o755 if item.is_dir() else 0o644)
# ── podman ───────────────────────────────────────────────────────────────────
def podman(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
executable = shutil.which("podman")
if executable is None:
sys.exit("podman introuvable — https://podman.io (macOS/Windows : podman machine init, "
"puis podman machine start)")
return subprocess.run([executable, *args], capture_output=True, text=True,
encoding="utf-8", errors="replace", check=check)
def mount(host: Path, guest: str) -> str:
"""Un montage en lecture seule. `Z` : etiquette SELinux quand il y en a une, ignore sinon."""
return f"{Path(host).resolve()}:{guest}:ro,Z"
def running() -> bool:
if shutil.which("podman") is None: # status reste lisible sans Podman : la vitre est absente
return False
proc = podman("container", "inspect", "-f", "{{.State.Running}}", CONTAINER, check=False)
return proc.returncode == 0 and proc.stdout.strip() == "true"
def health(port: int) -> str:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=3) as res:
return json.loads(res.read()).get("database", "?")
except (urllib.error.URLError, OSError, ValueError):
return "absent"
def reload_dashboards(port: int) -> bool:
"""Demander au fournisseur de relire le dossier — il ne le fait pas seul.
Grafana 12 ne repasse plus sur `provisioning/dashboards` en boucle :
sans cet appel, le JSON regenere reste sur le disque et la vitre continue
d'afficher les panneaux de la veille. Mot de passe par defaut : la vitre
n'ecoute que sur 127.0.0.1 et sert un instantane en lecture seule.
"""
credentials = base64.b64encode(b"admin:admin").decode()
request = urllib.request.Request(
f"http://127.0.0.1:{port}/api/admin/provisioning/dashboards/reload",
method="POST", headers={"Authorization": f"Basic {credentials}"})
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status == 200
except (urllib.error.URLError, OSError):
return False
def up(port: int) -> int:
runs = snapshot()
write_provisioning()
if running():
relu = "tableau de bord relu" if reload_dashboards(port) else "relecture refusee"
print(f"vitre : deja montee, {relu} —"
f" http://127.0.0.1:{port}/d/{DASHBOARD_UID} ({runs} runs)")
return 0
podman("rm", "-f", CONTAINER, check=False) # un conteneur arrete d'un up precedent
podman("run", "-d", "--name", CONTAINER,
"-p", f"127.0.0.1:{port}:3000", # la vitre n'ecoute que sur votre poste
"-v", f"{VOLUME}:/var/lib/grafana",
"-v", mount(PROVISIONING, IN_PROVISIONING),
"-v", mount(DASHBOARDS, IN_DASHBOARDS),
"-v", mount(SNAPSHOT.parent, IN_DB),
"-e", f"GF_PLUGINS_PREINSTALL_SYNC={SQLITE_PLUGIN}", # AVANT le provisioning
"-e", "GF_PLUGINS_PREINSTALL_AUTO_UPDATE=false",
"-e", "GF_AUTH_ANONYMOUS_ENABLED=true", # aucun mot de passe a saisir…
"-e", "GF_AUTH_ANONYMOUS_ORG_ROLE=Admin", # …sur un port local, en lecture seule
"-e", "GF_ANALYTICS_REPORTING_ENABLED=false", # la vitre ne telephone pas
"-e", "GF_ANALYTICS_CHECK_FOR_UPDATES=false",
"-e", "GF_NEWS_NEWS_FEED_ENABLED=false",
GRAFANA_IMAGE)
print(f"vitre : {CONTAINER} demarre ({GRAFANA_IMAGE}) — premier demarrage : le greffon SQLite"
" s'installe, comptez une minute")
for _ in range(90):
if health(port) == "ok":
print(f"grafana : pret — http://127.0.0.1:{port}/d/{DASHBOARD_UID} ({runs} runs dans la vitre)")
print("suite : uv run adws/obs_grafana.py watch (rafraichit l'instantane pendant un run)")
return 0
time.sleep(2)
print(f"grafana : pas encore pret — podman logs {CONTAINER}", file=sys.stderr)
return 1
def watch(every: int) -> int:
print(f"watch : instantane toutes les {every} s — Ctrl-C pour sortir (la vitre reste montee)")
try:
while True:
runs = snapshot()
print(f" {time.strftime('%H:%M:%S')} {runs} runs", flush=True)
time.sleep(every)
except KeyboardInterrupt:
return 0
def status(port: int) -> int:
print(f"conteneur : {'en marche' if running() else 'absent'} ({CONTAINER})")
print(f"grafana : {health(port)} — http://127.0.0.1:{port}/d/{DASHBOARD_UID}")
if SNAPSHOT.is_file():
age = int(time.time() - SNAPSHOT.stat().st_mtime)
print(f"instantane: {SNAPSHOT} — il y a {age} s")
else:
print("instantane: aucun — lancez up ou sync")
return 0
def down(purge: bool) -> int:
podman("rm", "-f", CONTAINER, check=False)
print(f"vitre : {CONTAINER} arrete et supprime")
if purge:
podman("volume", "rm", "-f", VOLUME, check=False)
shutil.rmtree(WORK_DIR, ignore_errors=True)
print(f"purge : volume {VOLUME} et {WORK_DIR} supprimes")
return 0
# ── la gate : le tableau de bord verifie a sec ──────────────────────────────
def _dry_sql(sql: str, adw_id: str) -> str:
"""Les variables de Grafana remplacees par des valeurs de test."""
return (sql.replace("$__from", "0").replace("$__to", "9999999999999")
.replace("${adw_id}", adw_id).replace("$adw_id", adw_id))
def selftest() -> int:
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
# 1. Un journal WAL comme celui du chapitre 18, avec deux runs.
db = tmp / "factory.db"
conn = sqlite3.connect(db)
conn.execute("PRAGMA journal_mode=WAL;")
conn.executescript("""
CREATE TABLE runs (adw_id TEXT PRIMARY KEY, adw_name TEXT, request TEXT,
status TEXT DEFAULT 'fail', started_at TEXT, ended_at TEXT,
cost_usd REAL DEFAULT 0, total_tokens INTEGER DEFAULT 0);
CREATE TABLE phases (phase_id TEXT PRIMARY KEY, adw_id TEXT, seq INTEGER, name TEXT,
kind TEXT, status TEXT DEFAULT 'fail', attempt INTEGER DEFAULT 0,
retries INTEGER DEFAULT 0, error TEXT, started_at TEXT, ended_at TEXT,
cost_usd REAL DEFAULT 0, tokens INTEGER DEFAULT 0);
CREATE TABLE events (event_id TEXT PRIMARY KEY, adw_id TEXT, phase_id TEXT,
type TEXT, name TEXT, payload_json TEXT, ts TEXT);
INSERT INTO runs VALUES ('r1','adw_sdlc','ajoute un compteur','success',
'2026-09-07T10:00:00.000+00:00','2026-09-07T10:09:30.000+00:00',0.47,181000);
INSERT INTO runs VALUES ('r2','adw_build','corrige le titre','fail',
'2026-09-07T11:00:00.000+00:00','2026-09-07T11:02:10.000+00:00',0.12,40000);
INSERT INTO phases VALUES ('r1-01','r1',1,'scout','agent','success',1,0,'',
'2026-09-07T10:00:00.000+00:00','2026-09-07T10:00:40.000+00:00',0.02,9000);
INSERT INTO phases VALUES ('r1-02','r1',2,'build','agent','success',2,1,'',
'2026-09-07T10:00:40.000+00:00','2026-09-07T10:08:00.000+00:00',0.40,160000);
INSERT INTO phases VALUES ('r1-03','r1',3,'test','code','success',1,0,'',
'2026-09-07T10:08:00.000+00:00','2026-09-07T10:08:05.000+00:00',0,0);
INSERT INTO events VALUES ('e1','r1',NULL,'run_start','adw_sdlc',
'{"request": "ajoute un compteur"}','2026-09-07T10:00:00.000+00:00');
INSERT INTO events VALUES ('e2','r1','r1-02','phase_start','build',
'{"seq": 2, "kind": "agent"}','2026-09-07T10:00:40.000+00:00');
INSERT INTO events VALUES ('e3','r1','r1-02','phase_ok','build',
'{"attempt": 2, "cost_usd": 0.4}','2026-09-07T10:08:00.000+00:00');
""")
conn.commit()
# 2. L'instantane : coherent, complet, en mode journal classique.
target = tmp / "grafana" / "db" / "factory.db"
runs = snapshot(db, target)
copy = sqlite3.connect(f"file:{target}?mode=ro&immutable=1", uri=True)
mode = copy.execute("PRAGMA journal_mode;").fetchone()[0]
assert runs == 2 and mode == "delete", (runs, mode)
assert not (target.parent / "factory.db-wal").exists()
# 3. Chaque requete du tableau de bord — et celle de la variable — tourne.
dashboard = render_dashboard()
checked = 0
for panel in dashboard["panels"]:
sql = _dry_sql(panel["targets"][0]["rawQueryText"], "r1")
rows = copy.execute(sql).fetchall()
assert rows, f"panneau sans ligne : {panel['title']}"
checked += 1
choices = copy.execute(RUN_VARIABLE_QUERY).fetchall()
assert [c[0] for c in choices] == ["r2", "r1"], choices
# 4. Quelques verdicts precis : la vitre dit la meme chose que le journal.
# Les panneaux se designent par leur titre : ajouter une ligne a PANELS
# ne doit pas casser une assertion qui comptait sur un rang.
def query(title_starts_with: str) -> str:
return _dry_sql(next(p[2] for p in PANELS
if p[0].startswith(title_starts_with)), "r1")
verts, rouges, en_cours = copy.execute(query("Runs sur la période")).fetchone()
assert (verts, rouges, en_cours) == (1, 1, 0)
argent = copy.execute(query("Où va l'argent")).fetchall()
assert [row[0] for row in argent] == ["build", "scout"], argent # 'test' a 0 $ : dehors
assert abs(argent[0][1] - 0.40) < 1e-9, argent
couloirs = copy.execute(query("Run $adw_id — les couloirs")).fetchall()
assert couloirs == [("01 scout · 40 s", 0.0, 40.0), ("02 build · 440 s", 40.0, 440.0),
("03 test · 5 s", 480.0, 5.0)], couloirs
assert copy.execute(query("Run $adw_id — la demande")).fetchone()[0] == "ajoute un compteur"
copy.close()
# 5. Le placement tient dans les 24 colonnes de Grafana.
assert all(p["gridPos"]["x"] + p["gridPos"]["w"] <= 24 for p in dashboard["panels"])
conn.close()
print(f"obs_grafana OK — instantane coherent hors WAL, {checked} panneaux et la variable"
f" verifies a sec, {len(PANELS)} requetes vertes, image {GRAFANA_IMAGE}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--selftest", action="store_true")
sub = parser.add_subparsers(dest="cmd")
for name in ("up", "status"):
sub.add_parser(name).add_argument("--port", type=int, default=3000)
sub.add_parser("sync")
sub.add_parser("watch").add_argument("--every", type=int, default=5)
sub.add_parser("down").add_argument("--purge", action="store_true")
args = parser.parse_args()
if args.selftest:
return selftest()
if args.cmd == "up":
return up(args.port)
if args.cmd == "sync":
print(f"sync : {snapshot()} runs dans l'instantane")
return 0
if args.cmd == "watch":
return watch(args.every)
if args.cmd == "status":
return status(args.port)
if args.cmd == "down":
return down(args.purge)
parser.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(main())
Pièce — just/obs.just
Cette version remplace celle du chapitre 19. Une recette s’ajoute, grafana, qui passe ses
arguments à la pièce, et rien d’autre ne bouge : runs, lanes, run, tail tournent
telles quelles, et le justfile du chapitre 19 monte déjà le module.
# just/obs.just — la salle de controle : lire les traces de l'usine.
# Un module n'herite de rien : working-directory se redeclare ici (ch. 5).
set working-directory := '..'
set positional-arguments
# liste les commandes de la salle de controle
default:
@just --list obs
# les dix derniers runs : verdict, cout, date
runs:
uv run adws/obs_lanes.py --runs
# les couloirs de nage du dernier run
lanes:
uv run adws/obs_lanes.py
# les couloirs d'un run precis : just obs run <adw_id>
run ADW_ID:
uv run adws/obs_lanes.py --run {{ ADW_ID }}
# le grain fin : les derniers evenements d'un run : just obs tail <adw_id>
tail ADW_ID:
uv run adws/obs_lanes.py --tail {{ ADW_ID }}
# la vitre web (ch. 20bis) : just obs grafana up | sync | watch | status | down [--purge]
grafana *ARGS:
uv run adws/obs_grafana.py "$@"
La gate du TP
Depuis la racine de plume-factory. Une commande par ligne, identiques sous bash et PowerShell.
Les trois premières ne demandent ni conteneur ni réseau. La quatrième exige Podman et monte la
vitre, la dernière la démonte.
uv run adws/obs_grafana.py --selftest
uv run adws/obs_grafana.py sync
just obs grafana status
just obs grafana up
just obs grafana down
Résultat attendu : obs_grafana OK — instantane coherent hors WAL, 9 panneaux et la variable verifies a sec, 9 requetes vertes, image docker.io/grafana/grafana:12.4.10. sync affiche le
nombre de runs de votre journal (sync : 12 runs dans l'instantane, et si vous n’avez encore
aucun run, lancez d’abord just obs lanes du chapitre 19 ou n’importe quel ADW). status montre
conteneur : absent, grafana : absent et l’âge de l’instantané. up imprime grafana : pret — http://127.0.0.1:3000/d/plume-factory (12 runs dans la vitre), avec au premier démarrage une
minute et ~300 Mo, puis quelques secondes. Sur une vitre déjà montée, up répond vitre : deja montee, tableau de bord relu. Ouvrez l’adresse : les neuf panneaux, la période « 7 derniers
jours », la liste Run avec votre dernier run en tête. Choisissez un run rouge, la phase fautive
s’affiche en rouge avec son message d’erreur, et le journal du bas donne son payload. Pendant un run, just obs grafana watch fait apparaître les phases au fil de l’eau. Coût : zéro token, la vitre ne montre
que des runs déjà payés. Si up répond podman introuvable, suivez la section Commande de la
fiche. Si grafana : pas encore pret, podman logs plume-grafana dit ce qui bloque, presque
toujours le téléchargement du greffon derrière un proxy.