À un moment, j'ai eu vingt-six automatisations en fonctionnement : des publications, des
sauvegardes, des synchronisations, des vérifications de certificats, des collectes.

Vingt-six choses qui peuvent tomber en silence, et aucune raison de croire qu'elles vont bien.
Ma première idée a été de demander à un modèle de langage de résumer l'état de chaque job.
C'était une très mauvaise idée : vingt-six résumés toutes les deux minutes, c'est de l'argent
brûlé pour reformuler des codes de sortie.

Aujourd'hui, le tableau de bord tourne en local, ne fait aucun appel payant, et rafraîchit les
vingt-six jobs en 9 à 11 secondes. Voici comment il est fait, et surtout ce qu'il ne sait pas
faire.

Ce qu'un job laisse derrière lui

Un job ne se surveille pas en l'interrogeant : on ne demande pas à une tâche « est-ce que tu
vas bien ? ». On lit ce qu'elle a laissé.

Quatre traces suffisent, et elles existent presque toujours déjà :

  • l'âge d'un heartbeat — un fichier écrit à la fin d'une exécution réussie ;
  • le code de sortie de la dernière exécution, quand le scheduler le conserve ;
  • le nombre d'objets traités — trois lignes ajoutées en base, deux fichiers déplacés ;
  • le journal, pour la dernière ligne d'erreur.

Le tableau de bord n'invente rien. Il lit ces traces, les compare à une règle, et affiche une
couleur. Un job vert n'est pas un job qui fonctionne : c'est un job dont les traces ressemblent
à celles d'un job qui fonctionne. La nuance compte, j'y reviens plus bas.

Le registre : une ligne par job

Tout tient dans un dictionnaire. Pas de table, pas de configuration YAML à parser, pas de
fichier par job.

JOBS = { "publication-devto": { "heartbeat": "~/.local/state/devto-last-success", "seuil_h": 30, }, "sauvegarde-quotidienne": { "heartbeat": "~/.local/state/backup-last-success", "seuil_h": 26, }, "veille-certificats": { "tache": "veille-ssl.timer", "seuil_h": 25, }, "compteur-missions": { "sqlite": ("~/pilotage/missions.db", "SELECT COUNT(*) FROM missions WHERE date(maj_le) = date('now')"), "seuil": 1, }, } 

Trois types de sonde, et c'est tout : un fichier horodaté, un timer systemd, une requête SQL.
Chaque job déclare son type et son seuil. Le collecteur ne connaît aucun job en particulier —
ajouter une automatisation, c'est ajouter une entrée.

Cette propriété est la plus importante du système : le tableau de bord ne grossit pas quand le
nombre de jobs grossit. Il reste le même code, avec un dictionnaire plus long.

Le cycle, en 9 à 11 secondes

Chaque sonde est une fonction courte qui renvoie un état et un message.

import os, sqlite3, subprocess, time from pathlib import Path def sonde_heartbeat(spec, maintenant): p = Path(os.path.expanduser(spec["heartbeat"])) if not p.exists(): return "rouge", "aucun heartbeat" age_h = (maintenant - p.stat().st_mtime) / 3600 if age_h > spec["seuil_h"]: return "rouge", f"muet depuis {age_h:.0f} h" return "vert", f"il y a {age_h:.1f} h" def sonde_timer(spec, maintenant): r = subprocess.run( ["systemctl", "--user", "is-active", spec["tache"]], capture_output=True, text=True, timeout=3, ) etat = r.stdout.strip() return ("vert" if etat == "active" else "rouge"), etat def sonde_sqlite(spec, maintenant): chemin, requete = spec["sqlite"] with sqlite3.connect(os.path.expanduser(chemin), timeout=2) as con: (n,) = con.execute(requete).fetchone() return ("vert" if n >= spec["seuil"] else "rouge"), f"{n} lignes" 

Le collecteur les enchaîne, mesure le temps, et écrit un JSON.

debut = time.monotonic() resultats = {} for nom, spec in JOBS.items(): try: if "heartbeat" in spec: etat, msg = sonde_heartbeat(spec, time.time()) elif "tache" in spec: etat, msg = sonde_timer(spec, time.time()) else: etat, msg = sonde_sqlite(spec, time.time()) except Exception as e: # une sonde cassée ne casse pas l'affichage etat, msg = "gris", f"sonde en erreur: {type(e).__name__}" resultats[nom] = {"etat": etat, "message": msg} resultats["_cycle"] = {"secondes": round(time.monotonic() - debut, 1)} 

Deux détails qui font la différence entre 10 secondes et 10 minutes :

  • un timeout sur chaque appel externe (3 secondes pour systemctl, 2 pour SQLite). Sans lui, un job bloqué bloque tout le cycle, et le tableau de bord affiche l'état d'il y a une heure en croyant afficher le présent ;
  • des sondes séquentielles, pas parallèles. Vingt-six processus simultanés coûtent plus en mémoire et en contexte que les dix secondes qu'on gagne. Sur un poste de travail, la séquentialité est le choix paresseux et correct.

Le JSON est écrit de façon atomique, sinon le navigateur lit un fichier à moitié rempli une
fois sur cent :

tmp = "/tmp/etat-jobs.json.tmp" Path(tmp).write_text(json.dumps(resultats, ensure_ascii=False)) os.replace(tmp, "/home/moi/etat-jobs.json") 

Le rendu : du HTML, du fetch, et rien d'autre

Pas de framework, pas d'étape de build, pas de serveur. Une page HTML de cent lignes avec une
boucle de rafraîchissement.

<table id="t"> <thead><tr><th>Job</th><th>État</th><th>Détail</th></tr></thead> <tbody></tbody> </table> <script> async function rafraichir() { const r = await fetch("etat-jobs.json?_=" + Date.now()); const data = await r.json(); const tbody = document.querySelector("#t tbody"); tbody.innerHTML = ""; for (const [nom, v] of Object.entries(data)) { if (nom.startsWith("_")) continue; const tr = document.createElement("tr"); tr.className = v.etat; tr.innerHTML = `<td>${nom}</td><td>${v.etat}</td><td>${v.message}</td>`; tbody.appendChild(tr); } document.querySelector("#cycle").textContent = data._cycle.secondes + " s"; } rafraichir(); setInterval(rafraichir, 120000); // toutes les 2 minutes </script> 

Le collecteur tourne lui aussi toutes les deux minutes, dans un timer systemd. La page ne
calcule rien : elle affiche un fichier. C'est ce qui rend le coût nul — ni requête à une API
distante, ni modèle, ni quota.

0 token, et ce que ça implique

Le compte est simple : vingt-six jobs, un cycle toutes les deux minutes, donc 720 cycles par
jour. Aucun de ces cycles n'appelle un service payant. Le coût marginal est une lecture de
fichier et un stat.

Mais l'absence de modèle a un prix, et il faut le dire clairement :

  • pas de détection d'anomalie. Le tableau de bord compare à un seuil que j'ai écrit. Il ne remarque pas qu'un job tourne deux fois plus longtemps que d'habitude, ni que son volume de sortie a chuté de moitié. Pour ça, il faudrait un historique, pas un instantané ;
  • pas de diagnostic. Il dit « muet depuis 31 h ». Il ne dit pas pourquoi. La cause est dans le journal, et c'est moi qui vais la lire ;
  • pas de priorisation. Vingt-six lignes rouges se valent à l'écran. Un modèle pourrait trier ; un humain qui connaît ses jobs le fait aussi bien en trois secondes.

Un tableau de bord à 0 token n'est pas un tableau de bord intelligent. C'est un miroir. Et un
miroir honnête vaut mieux qu'un analyste qui se trompe.

La leçon que m'a donnée le premier job vert

Un job est resté vert pendant onze jours alors qu'il ne publiait plus rien. Son heartbeat était
écrit au bon endroit, à la bonne heure, par un script qui sortait en 0… après avoir trouvé
zéro fichier à traiter.

« Code de sortie 0 » et « travail effectué » ne sont pas la même chose. Depuis, la sonde de ce
job ne regarde plus son heartbeat mais le nombre de lignes ajoutées dans son journal de
publication. Un job qui tourne pour rien est rouge, même s'il est content de lui.

C'est la seule sonde que j'ai réécrite, et c'est celle qui a le plus servi. Le reste du
système n'a pas changé : un dictionnaire, trois types de sondes, un JSON, une page qui
rafraîchit toutes les deux minutes.