Observabilité Chapitre 20bis / 42

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.db est 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 en mode=ro&immutable=1 (ni verrou ni fichier annexe, ce qui est juste puisque l’instantané est remplacé et jamais modifié), et le greffon impose query_only. Une vitre ne peut pas écrire dans le journal.
  • Local par construction. Le port est publié sur 127.0.0.1 seulement. 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 fautrienun collector ou un Prometheus deboutPodman
Litfactory.db en placefactory.db, exporte après coupun instantané de factory.db
Répond àquoi, où, quand — un run à la foistendances, à côté de votre infra d’équipetendances et détail, sur votre poste
Rafraîchissementà la commandeà l’exportsync ou watch
Source de véritéouinon — projectionnon — 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 lira factory.db en direct » est inexact. Une base WAL lue depuis un autre système réclame un fichier -shm partagé et des verrous que le montage ne garantit pas : au mieux attempt 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 à chaque up : effacez tout, relancez, la vitre revient à l’identique. Le fournisseur ne repasse pas sur le dossier tout seul (mesuré sur l’image épinglée, avec allowUiUpdates : le JSON change, la vitre ne bouge pas), donc up lui 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 Run est 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 $__from et $__to, en millisecondes : strftime('%s', started_at) * 1000 les 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 dans timeColumns est 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. --selftest fabrique 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, build en 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 à PANELS ne 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

PanneauTypeLa questionLit
Runs sur la périodestatoù en est-on — verts, rouges, en coursruns
Dollars et jetonsstatce que la période a coûtéruns
Coût par run dans le tempstimeseries (barres)la tendanceruns
Coût cumulé par phasebarchartoù va l’argent (les phases à 0 $ sortent)phases × runs
Les derniers runstableverdict, coût, jetons, duréeruns
Run choisi — la demandetablece qu’on a demandé, en toutes lettresruns
Run choisi — les couloirsbarchart empiléla trace : quand, et combien de tempsphases
Run choisi — les phasestabletentatives, erreurs, coût par phasephases
Run choisi — le journaltableles événements bruts, payloads comprisevents

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.


Quiz — teste tes connaissances
Observabilité 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.