console : une vue A traiter rassemble ce que chaque console sait deja

Route en lecture /api/a-traiter et vue sous Veille, par gravite (a prendre, a savoir,
verifie). Locataire : genome face a la forge, face reseau, fiche du site, plan face a
l'inventaire, machines prevues. Site : genome, sa carte et chaque depot de locataire
face a la forge, face, index et fiche de chaque locataire. Les deux : joignabilite et
prerequis. Le niveau de la forge se lit par git ls-remote, sans rien ecrire.

94 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-11 19:12:27 -04:00
parent 0d5ec924cc
commit aa5e4e0897
4 changed files with 462 additions and 5 deletions

View file

@ -1,5 +1,42 @@
# CHANGELOG — Set-OPS
## 2026-10-11 (131) — Chaque console dit ce qui l'attend, à un seul endroit
**94 preuves.** `make verifier` passe. `test_portee_console.py` compte deux cas de plus, dont
une lecture du niveau de la forge sur de vrais dépôts git jetables ; ce test échoue quand la
lecture ne reconnaît plus un retard. Le banc de rendu dessine la nouvelle vue sur la vraie
réponse de sa route. Les deux consoles ont été regardées en capture, avec deux éléments
factices injectés par le banc de capture pour voir une veille chargée.
### Ce qu'il fallait aller chercher vue par vue
Un runner en retard sur sa forge, une face à republier, une fiche périmée, un plan modifié
sans « Appliquer le plan », une machine injoignable : la console savait déjà le mesurer, mais
chaque fait vivait dans sa vue, ou seulement dans une garde de `make`. La vue « À traiter »,
en tête du groupe Veille, les rassemble par gravité : à prendre, à savoir, vérifié. Chaque
élément dit sa source, ce qu'il touche, et le geste qui le traite (un assistant, une vue, ou
la commande à lancer depuis le poste). Ce qui a été vérifié sans rien à faire reste visible,
replié : un écran vide ne dirait pas si l'on a regardé. Le nombre d'éléments à prendre
s'affiche dans la navigation dès le chargement.
- **Locataire** : le moteur et son dépôt face à la forge ; sa face réseau ; la fiche du
site ; le plan face à l'inventaire ; les machines prévues mais pas encore actives.
- **Site** : le moteur, sa carte et chaque dépôt de locataire face à la forge ; pour chaque
locataire, la face publiée, l'index attribué et la fiche à redéposer.
- **Les deux** : la joignabilité des machines actives et les prérequis en attente, que la page
mesurait déjà.
### Le niveau de la forge se lit sans rien écrire
`verifier_genome_a_jour.py` fait un `git fetch` avant de juger, juste avant un déploiement. Une
page qui ferait de même écrirait dans le dépôt à chaque affichage. La veille lit la tête de la
forge par `git ls-remote`, et juge sur les objets déjà présents : un commit que la forge porte
et que le dépôt n'a pas encore tiré compte comme un retard.
**Limite.** Pas encore ici : les demandes entre locataire et site (3b : un dépôt privé
`demandes-<locataire>` sur la forge du site, écrit dans la forge, lu par la console), la
supervision, les sauvegardes et les échéances. La page le dit en bas de la vue.
## 2026-10-11 (130) — La clé du runner du site ne transite plus par la face du locataire
**94 preuves.** `make verifier` passe. `test_contexte.py` et `test_inventory_host.py` comptent

View file

@ -662,6 +662,9 @@ class Console:
def locataires_heberges(self) -> dict | None:
return None
def a_traiter(self) -> list | None:
return None
# --- LE TRONC : UN SEUL ENDROIT OU LA CHARGE SE COMPOSE ---------------------------
def contexte(self) -> dict:
@ -680,6 +683,7 @@ class Console:
"contrat": {
"lien_avec_le_site": type(self).lien_avec_le_site is not Console.lien_avec_le_site,
"locataires_heberges": type(self).locataires_heberges is not Console.locataires_heberges,
"a_traiter": type(self).a_traiter is not Console.a_traiter,
},
}
@ -771,6 +775,9 @@ class ConsoleLocataire(Console):
def lien_avec_le_site(self):
return lien_du_locataire(self._instance.resolve())
def a_traiter(self):
return veille_locataire(self._instance.resolve())
def pouvoirs(self):
return {"editer_le_plan": True, "configurer": self._a_voute,
"materialiser": False, "fabric": False}
@ -828,6 +835,9 @@ class ConsoleSite(Console):
def locataires_heberges(self):
return locataires_du_site(self._depot)
def a_traiter(self):
return veille_site(self._depot)
def serveurs(self, data):
"""Les serveurs DECLARES du site, dans la forme que la page sait lire.
@ -908,6 +918,16 @@ class ConsolePoste(ConsoleLocataire):
# Le poste monte la carte du site : il voit les deux cotes du contrat.
return locataires_du_site(self._depot)
def a_traiter(self):
# Les deux veilles ; le moteur n'est compte qu'une fois.
vus, out = set(), []
for e in veille_locataire(self._instance.resolve()) + veille_site(self._depot):
cle = (e["source"], e["titre"], e["detail"])
if cle not in vus:
vus.add(cle)
out.append(e)
return out
def pouvoirs(self):
p = super().pouvoirs()
p["materialiser"] = self._a_voute_site
@ -1093,6 +1113,193 @@ def locataires_du_site(depot: Path) -> dict:
"absents": site.locataires_absents()}
# --- A TRAITER (lot 3a, 2026-10-11) -----------------------------------------------------
#
# Ce que chaque console sait DEJA, rassemble au meme endroit, du plus pressant au moins
# pressant. Ni secret ni forge : les demandes (3b) et la supervision viendront ensuite.
# Chaque element dit sa SOURCE, sa GRAVITE (agir / savoir / ok), ce qu'il touche, et le
# geste qui le traite. Les verifications sans rien a faire sont rendues aussi, en « ok » :
# un ecran vide ne dit pas si l'on a regarde.
def _git_lecture(depot: Path, *args: str, delai: int = 8) -> tuple[int, str]:
try:
r = subprocess.run(["git", "-C", str(depot), *args], capture_output=True, text=True,
timeout=delai)
return r.returncode, (r.stdout or r.stderr).strip()
except (OSError, subprocess.TimeoutExpired) as exc:
return 124, str(exc)
def etat_forge(depot: Path) -> dict:
"""Ce depot local est-il au niveau de sa forge — SANS `git fetch`.
`verifier_genome_a_jour.py` fetche avant de juger : juste avant un deploiement. Une page
qui fetcherait a chaque affichage ecrirait dans le depot a chaque lecture. `ls-remote`
lit la tete de la forge, et le reste se juge sur les objets deja presents.
"""
if not (depot / ".git").exists():
return {"verdict": "inconnu", "detail": "pas un dépôt git"}
rc, amont = _git_lecture(depot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
if rc != 0 or "/" not in amont:
return {"verdict": "inconnu", "detail": "aucune branche de suivi"}
distant, _, branche = amont.partition("/")
rc, local = _git_lecture(depot, "rev-parse", "HEAD")
rc2, sortie = _git_lecture(depot, "ls-remote", distant, f"refs/heads/{branche}")
if rc2 != 0 or not sortie.split():
return {"verdict": "inconnu", "detail": "forge injoignable", "local": local[:7]}
forge = sortie.split()[0]
base = {"local": local[:7], "forge": forge[:7]}
if forge == local:
return {"verdict": "a-jour", "detail": amont, **base}
if _git_lecture(depot, "cat-file", "-e", f"{forge}^{{commit}}")[0] != 0:
return {"verdict": "retard", "detail": f"la forge porte {forge[:7]}, pas encore tiré ici", **base}
if _git_lecture(depot, "merge-base", "--is-ancestor", forge, "HEAD")[0] == 0:
n = _git_lecture(depot, "rev-list", "--count", f"{forge}..HEAD")[1]
return {"verdict": "avance", "detail": f"{n} commit(s) devant la forge", **base}
if _git_lecture(depot, "merge-base", "--is-ancestor", "HEAD", forge)[0] == 0:
n = _git_lecture(depot, "rev-list", "--count", f"HEAD..{forge}")[1]
return {"verdict": "retard", "detail": f"{n} commit(s) derrière la forge", **base}
return {"verdict": "diverge", "detail": "l'historique local et celui de la forge ont divergé", **base}
def _element(source: str, gravite: str, titre: str, impact: str = "", detail: str = "",
geste: dict | None = None) -> dict:
return {"source": source, "gravite": gravite, "titre": titre, "impact": impact,
"detail": detail, "geste": geste}
def _veille_genome(depots: list[tuple[str, Path, str]], impact_retard: str) -> list[dict]:
"""`depots` : (nom, chemin, commande qui le remet au niveau, lancee depuis le poste)."""
out = []
for nom, depot, commande in depots:
e = etat_forge(depot)
v = e["verdict"]
detail = f"{depot.name} · ici {e.get('local', '?')} · forge {e.get('forge', '?')} · {e['detail']}"
if v == "retard":
out.append(_element("GÉNOME", "agir", f"{nom} : en retard sur la forge", impact_retard,
detail, {"libelle": "Depuis le poste : le runner ne tire que ce qu'on lui fait tirer.",
"commande": commande}))
elif v == "diverge":
out.append(_element("GÉNOME", "agir", f"{nom} : a divergé de la forge",
"Le runner ne suit plus la forge ; c'est à un humain de trancher.", detail))
elif v == "avance":
out.append(_element("GÉNOME", "savoir", f"{nom} : en avance sur la forge",
"Normal sur le poste d'un mainteneur qui n'a pas encore publié.", detail,
{"libelle": "Publier", "commande": "make publier"}))
elif v == "inconnu":
out.append(_element("GÉNOME", "savoir", f"{nom} : niveau de la forge inconnu",
"La comparaison n'a pas pu se faire ; rien n'est affirmé.", detail))
else:
out.append(_element("GÉNOME", "ok", f"{nom} : au niveau de la forge", "", detail))
return out
def veille_locataire(instance: Path) -> list[dict]:
"""CE QUI ATTEND LE MAINTENEUR : son genome, sa face, la fiche du site, son plan."""
import contexte as _ctx
lien = lien_du_locataire(instance)
loc = _ctx.Locataire(instance)
rejouer = f"SETOPS_INSTANCE=../{loc.nom} make appliquer GROUPE=serveur_ops"
out = _veille_genome([("Le moteur", RACINE, rejouer), (f"Le dépôt {loc.nom}", loc.depot, rejouer)],
"Un déploiement d'ici poserait un état dépassé ; il le refuse "
"(`verifier_genome_a_jour`) tant que le runner n'a pas tiré.")
F, Fi = lien["face"], lien["fiche"]
face_geste = {"libelle": "Assistant : matérialiser (republier la face)", "runbook": "locataire-materialiser"}
if not F["publiee"]:
out.append(_element("FACE", "agir", "Face réseau jamais publiée",
"Le site ne peut rien créer ni ouvrir pour ce locataire.",
"make face-reseau-publier, puis commiter face-reseau.yml", face_geste))
elif F["ecarts"]:
out.append(_element("FACE", "agir", "Face réseau à republier",
"Le site lit une face qui ne dit plus ce que le plan demande.",
" · ".join(F["ecarts"]), face_geste))
elif (F["trace"] or {}).get("modifie"):
out.append(_element("FACE", "agir", "Face réseau modifiée, pas encore commitée",
"Le site lit le commit : il ne voit pas encore ce changement.",
"commiter face-reseau.yml puis `make publier`"))
else:
out.append(_element("FACE", "ok", "Face réseau publiée et à jour", "",
f"commit {(F['trace'] or {}).get('commit')}", {"libelle": "Lien avec le site", "vue": "lien"}))
if not Fi["presente"]:
out.append(_element("FICHE", "agir", "Aucune fiche du site",
"L'inventaire se génère d'après elle ; c'est au site de la déposer.",
"make fiches-site-deposer (chez le site)"))
elif Fi["jugeable"] and Fi["ecarts"]:
out.append(_element("FICHE", "agir", "Fiche du site périmée",
"L'inventaire se génère sur d'anciennes valeurs du site.",
" · ".join(Fi["ecarts"]), {"libelle": "Lien avec le site", "vue": "lien"}))
elif Fi["jugeable"]:
out.append(_element("FICHE", "ok", "Fiche du site conforme", "", f"commit {(Fi['trace'] or {}).get('commit')}"))
else:
out.append(_element("FICHE", "savoir", "Fiche du site lue, non jugeable d'ici",
"Seuls le site ou le poste du mainteneur peuvent dire si elle est à jour.",
f"commit {(Fi['trace'] or {}).get('commit')}"))
# LE PLAN FACE A L'INVENTAIRE : un serveur declare mais absent, ou dont les valeurs
# derivees ont bouge, attend « Appliquer le plan ».
try:
srv = serveurs_reconcilies(charger_yaml(loc.inventaire()))
except Exception as exc:
srv = []
out.append(_element("PLAN", "savoir", "Plan non comparé à l'inventaire", "", str(exc)))
absents = [s["nom"] for s in srv if s.get("statut") == "absent"]
diverg = [s["nom"] for s in srv if s.get("statut") == "divergence"]
if absents or diverg:
out.append(_element("PLAN", "agir", "Le plan a changé depuis le dernier « Appliquer le plan »",
"L'inventaire, la face et les déploiements suivent encore l'ancien plan.",
" · ".join(([f"absents de l'inventaire : {', '.join(absents)}"] if absents else [])
+ ([f"valeurs dérivées changées : {', '.join(diverg)}"] if diverg else [])),
{"libelle": "Parc", "vue": "serveurs"}))
elif srv:
out.append(_element("PLAN", "ok", "Plan et inventaire concordent", "", f"{len(srv)} serveurs"))
prevues = (F.get("resume") or {}).get("planifiees") or []
if prevues:
out.append(_element("MACHINES", "savoir", f"{len(prevues)} machine(s) prévues, pas encore actives",
"Le site ne les crée que sur demande.", ", ".join(prevues)))
return out
def veille_site(depot: Path) -> list[dict]:
"""CE QUI ATTEND L'HEBERGEUR : son genome, et ce que ses locataires lui ont publie."""
charge = locataires_du_site(depot)
import contexte as _ctx
site = _ctx.Site(depot)
rejouer = "make site-appliquer GROUPE=serveur_ops ARGS=\"--limit 'serveur_ops:&hotes_actifs'\""
depots = [("Le moteur", RACINE, rejouer), (f"Le dépôt {site.nom}", site.depot, rejouer)]
# Les depots des locataires arrivent sur le runner du site par `make publier`.
depots += [(f"Le dépôt {l.nom}", l.depot, "make publier") for l in site.locataires()]
out = _veille_genome(depots, "Le site lirait un moteur, une carte ou une face dépassés : "
"`make publier` et le rôle serveur_ops le remettent au niveau.")
for nom in charge["absents"]:
out.append(_element(nom, "agir", "Locataire déclaré sans dépôt ici",
"Le site ne peut rien lire de lui : ni face, ni fiche.",
"underlay.yml le déclare ; son dépôt manque sur ce runner"))
for l in charge["locataires"]:
R = (l["face"] or {}).get("resume")
if not l["face"]["publiee"]:
out.append(_element(l["nom"], "agir", "Aucune face réseau publiée",
"Le site ne sait rien créer ni ouvrir pour lui.", "",
{"libelle": "Locataires hébergés", "vue": "locataires"}))
continue
if R and R.get("index") != l["index_attribue"]:
out.append(_element(l["nom"], "agir", "Index publié différent de l'index attribué",
"Son adressage ne correspond plus à ce que le site lui a donné.",
f"attribué {l['index_attribue']} · publié {R.get('index')}",
{"libelle": "Locataires hébergés", "vue": "locataires"}))
if l["relation"] == "actif" and l["fiche"]["ecarts"]:
out.append(_element(l["nom"], "agir", "Sa fiche est à redéposer",
"Son inventaire se génère sur d'anciennes valeurs du site.",
" · ".join(l["fiche"]["ecarts"]),
{"libelle": "Assistant : tenir un site en état", "runbook": "site-tenir"}))
elif l["relation"] == "actif":
out.append(_element(l["nom"], "ok", "Face lue, fiche à jour", "",
f"face {(l['face']['trace'] or {}).get('commit')} · fiche {(l['fiche']['trace'] or {}).get('commit')}"))
if R and R.get("planifiees"):
out.append(_element(l["nom"], "savoir", f"{len(R['planifiees'])} machine(s) prévues, pas encore actives",
"À créer seulement sur demande de son mainteneur (creer-vm).",
", ".join(R["planifiees"])))
return out
def console(inventaire: Path | None = None) -> Console:
"""QUELLE console sert cette machine — derive de ce qu'elle peut OUVRIR.
@ -2389,6 +2596,35 @@ button.prod:hover:not(:disabled) { background: #8f2424; border-color: #c94a4a; }
.qui-fait { display: flex; flex-direction: column; gap: 10px; }
.qui-fait .qui { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 10px; font-size: 12.5px; color: var(--muted); line-height: 1.4; }
.qui-fait .qui b { color: var(--texte); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
/* --- LOT 3a (2026-10-11) : A TRAITER ------------------------------------------------
Une ligne par chose a regarder ; la barre de gauche dit la gravite (ambre = agir,
gris = a savoir, vert = verifie). Les verifications sans rien a faire restent visibles,
repliees : un ecran vide ne dirait pas si l'on a regarde. */
.veille-chiffres { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; }
.veille-chiffres .chiffre { min-width: 150px; }
.veille-filtres { display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 12px; }
.veille-filtres button { min-height: 30px; padding: 0 11px; font-size: 12.5px; border-radius: 15px; }
.veille-filtres button.on { background: color-mix(in srgb, var(--identite) 16%, var(--surface)); border-color: var(--identite); color: var(--texte); }
.veille-liste { background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon); overflow: hidden; margin-bottom: 14px; }
.veille-rang { display: grid; grid-template-columns: 4px minmax(0, 1fr) auto; gap: 12px; align-items: stretch; width: 100%; padding: 0 14px 0 0;
border: 0; border-bottom: 1px solid var(--ligne); border-radius: 0; background: transparent; color: var(--texte); font: inherit; text-align: left; cursor: pointer; box-shadow: none; }
.veille-rang:last-child { border-bottom: 0; }
.veille-rang:hover:not(:disabled) { background: var(--surface-2); border-color: var(--ligne); }
.veille-rang.selectionnee { background: color-mix(in srgb, var(--identite) 9%, var(--surface)); }
.veille-rang .barre { background: var(--faint); }
.veille-rang.g-agir .barre { background: var(--ambre); }
.veille-rang.g-ok .barre { background: var(--vert); }
.veille-rang .quoi { display: flex; flex-direction: column; gap: 3px; padding: 10px 0; min-width: 0; }
.veille-rang .ligne1 { display: flex; align-items: center; gap: 8px; min-width: 0; }
.veille-rang .src { font-size: 10.5px; font-weight: 800; letter-spacing: .07em; color: var(--faint); white-space: nowrap; }
.veille-rang .titre { font-size: 13.5px; font-weight: 650; }
.veille-rang .impact { font-size: 12.5px; color: var(--muted); }
.veille-rang .tech { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.veille-rang .etat { display: flex; align-items: center; }
.veille-ok > summary { cursor: pointer; font-size: 12.5px; color: var(--muted); padding: 4px 2px 8px; }
.commande { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: 7px; background: var(--surface-2); border: 1px solid var(--ligne);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.commande span { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
@media (max-width: 1000px) {
.cadre { flex-direction: column; }
#navigation { flex: none; border-right: 0; border-bottom: 1px solid var(--ligne); }
@ -2647,6 +2883,7 @@ let selMachine = null;
// LE CONTRAT AVEC L'AUTRE BOUT (lot 2) : en tete, parce que c'est ce qu'on regarde
// avant d'agir. Le poste du mainteneur monte les deux bouts, il voit les deux.
const veille = [];
if (aCote('a_traiter')) veille.push(['a-traiter', 'À traiter', n(compteAgir())]);
if (aCote('lien_avec_le_site')) veille.push(['lien', 'Lien avec le site', '']);
if (aCote('locataires_heberges')) veille.push(['locataires', 'Locataires hébergés',
locatairesCharge ? n((locatairesCharge.locataires || []).length) : '']);
@ -2721,7 +2958,7 @@ let selMachine = null;
}
async function charger() {
lienCharge = null; locatairesCharge = null; // « Recharger » relit aussi le contrat
lienCharge = null; locatairesCharge = null; aTraiterCharge = null; // « Recharger » relit aussi le contrat et la veille
try {
const ctxRep = await fetch('/api/contexte');
if (ctxRep.ok) appliquerContexte(await ctxRep.json());
@ -2753,6 +2990,7 @@ let selMachine = null;
dessiner();
message('Inventaire chargé.', 'ok');
sonder(); // état réel (joignabilité) en arrière-plan, non bloquant
prechargerATraiter(); // le compte « À traiter » de la navigation, en arrière-plan
}
async function sonder() {
@ -2832,6 +3070,7 @@ let selMachine = null;
function setVue(v) {
vuePrincipale = v;
if (v === 'lien') lienCharge = null;
if (v === 'a-traiter') aTraiterCharge = null;
if (v === 'locataires') locatairesCharge = null;
if (v === 'serveurs' && selServeur == null && serveurs.length) selServeur = 0;
if (v === 'applications' && selApp == null && applications.length) selApp = 0;
@ -2865,6 +3104,7 @@ let selMachine = null;
if (vuePrincipale === 'couches') { dessinerCouches(); return; }
if (vuePrincipale === 'reseau') { dessinerReseau(); return; }
if (vuePrincipale === 'assistants') { dessinerAssistants(); return; }
if (vuePrincipale === 'a-traiter') { dessinerATraiter(); return; }
if (vuePrincipale === 'lien') { dessinerLienSite(); return; }
if (vuePrincipale === 'locataires') { dessinerLocataires(); return; }
dessinerServeurs();
@ -4408,6 +4648,122 @@ let selMachine = null;
</div>`;
}
// A TRAITER (lot 3a, 2026-10-11). Ce que la console sait deja, au meme endroit :
// la veille du serveur (genome, face, fiche, plan, locataires) et ce que la page
// mesure elle-meme (joignabilite, prerequis). Pas encore les demandes ni la supervision.
let aTraiterCharge = null;
let selVeille = 0;
let filtreVeille = '';
const RANG_GRAVITE = {agir: 0, savoir: 1, ok: 2};
function veilleDeLaPage() {
const out = [];
const actives = hotes.filter(h => h.etat === 'actif');
if (!Object.keys(sondes).length) {
out.push({source: 'MACHINES', gravite: 'savoir', titre: 'Joignabilité pas encore mesurée',
impact: 'La sonde tourne en arrière-plan après le chargement.', detail: '', geste: null});
} else {
const muettes = actives.filter(h => sondes[h.nom] === false).map(h => h.nom);
out.push(muettes.length
? {source: 'MACHINES', gravite: 'agir', titre: `${muettes.length} machine(s) active(s) injoignable(s)`,
impact: 'Ni vérification ni déploiement ne passeront ; ce qu\'elles rendent est peut-être arrêté.',
detail: 'sonde SSH depuis cette console · ' + muettes.join(', '), geste: {libelle: 'Parc', vue: 'serveurs'}}
: {source: 'MACHINES', gravite: 'ok', titre: `${actives.length} machine(s) active(s) joignables`,
impact: '', detail: 'sonde SSH depuis cette console', geste: null});
}
const bloquees = hotes.filter(h => hoteBloque(h)).map(h => h.nom);
if (bloquees.length) out.push({source: 'PRÉREQUIS', gravite: 'savoir', titre: `${bloquees.length} machine(s) attendent un prérequis`,
impact: 'Un groupe qu\'elles portent en suppose un autre, pas encore actif.', detail: bloquees.join(', '),
geste: {libelle: 'Parc', vue: 'serveurs'}});
return out;
}
function elementsVeille() {
const tous = ((aTraiterCharge && aTraiterCharge.elements) || []).concat(veilleDeLaPage());
return tous.map((e, i) => ({...e, _i: i}))
.sort((a, b) => (RANG_GRAVITE[a.gravite] ?? 1) - (RANG_GRAVITE[b.gravite] ?? 1) || a._i - b._i);
}
function compteAgir() {
return aTraiterCharge ? elementsVeille().filter(e => e.gravite === 'agir').length : '';
}
async function prechargerATraiter() {
if (!aCote('a_traiter') || aTraiterCharge) return;
try { aTraiterCharge = await lireContrat('/api/a-traiter'); dessinerNavigation(); } catch (e) { /* la vue le dira */ }
}
function etiquetteGravite(g) {
return {agir: '<span class="etiq e-div">à prendre</span>', savoir: '<span class="etiq e-recon">à savoir</span>',
ok: '<span class="etiq e-actif">vérifié</span>'}[g] || '';
}
function lancerGeste(i) {
const e = elementsVeille()[i];
if (!e || !e.geste) return;
if (e.geste.runbook) ouvrirAssistant(e.geste.runbook);
else if (e.geste.vue) setVue(e.geste.vue);
}
function copierCommande(texte) {
try { navigator.clipboard.writeText(texte); message('Commande copiée.', 'ok'); } catch (e) { /* rien */ }
}
async function dessinerATraiter() {
const cible = document.getElementById('grilles');
if (!aTraiterCharge) {
cible.innerHTML = '<div class="vide" style="padding:14px">Lecture de la veille : génome, contrat, plan…</div>';
try { aTraiterCharge = await lireContrat('/api/a-traiter'); }
catch (e) { cible.innerHTML = '<div class="vide" style="padding:14px">' + echapper(e.message) + '</div>'; return; }
}
const tous = elementsVeille();
const sources = [...new Set(tous.filter(e => e.gravite !== 'ok').map(e => e.source))];
if (filtreVeille && !sources.includes(filtreVeille)) filtreVeille = '';
const vus = tous.filter(e => !filtreVeille || e.source === filtreVeille);
const aFaire = vus.filter(e => e.gravite !== 'ok'), faits = vus.filter(e => e.gravite === 'ok');
if (selVeille >= tous.length) selVeille = 0;
const n = (g) => tous.filter(e => e.gravite === g).length;
const sousTitre = contexte && contexte.portee === 'site'
? 'Le génome, ce que les locataires ont publié, et les machines du site — du plus pressant au moins pressant.'
: 'Le génome, le contrat avec le site, le plan et les machines — du plus pressant au moins pressant.';
const rang = (e) => `<button type="button" class="veille-rang g-${e.gravite} ${tous.indexOf(e) === selVeille ? 'selectionnee' : ''}" onclick="choisirVeille(${tous.indexOf(e)})">
<span class="barre"></span>
<span class="quoi"><span class="ligne1"><span class="src">${echapper(e.source)}</span><span class="titre">${echapper(e.titre)}</span></span>
${e.impact ? `<span class="impact">${echapper(e.impact)}</span>` : ''}
${e.detail ? `<span class="tech" title="${echapper(e.detail)}">${echapper(e.detail)}</span>` : ''}</span>
<span class="etat">${etiquetteGravite(e.gravite)}</span></button>`;
cible.innerHTML = `<div class="vue-tete"><div style="display:flex;flex-direction:column;gap:2px">
<h2 class="vue-titre">À traiter</h2><span class="vue-sous">${echapper(sousTitre)}</span></div></div>
<div class="veille-chiffres">
<div class="chiffre"><span class="v">${n('agir')}</span><span class="l">à prendre</span></div>
<div class="chiffre"><span class="v">${n('savoir')}</span><span class="l">à savoir</span></div>
<div class="chiffre"><span class="v">${n('ok')}</span><span class="l">vérifiés, rien à faire</span></div>
</div>
${sources.length > 1 ? `<div class="veille-filtres"><button type="button" class="fantome ${filtreVeille ? '' : 'on'}" onclick="filtrerVeille('')">Tout</button>`
+ sources.map(s => `<button type="button" class="fantome ${filtreVeille === s ? 'on' : ''}" onclick="filtrerVeille('${echapper(s)}')">${echapper(s)}</button>`).join('') + '</div>' : ''}
${aFaire.length ? `<div class="veille-liste">${aFaire.map(rang).join('')}</div>`
: '<div class="vide" style="padding:14px">Rien à prendre ni à savoir' + (filtreVeille ? ' pour ce filtre' : '') + '.</div>'}
${faits.length ? `<details class="veille-ok"${aFaire.length ? '' : ' open'}><summary>Vérifié, rien à faire (${faits.length})</summary>
<div class="veille-liste">${faits.map(rang).join('')}</div></details>` : ''}
<div class="note-contrat">Pas encore ici : les demandes entre locataire et site (par la forge), la supervision, les sauvegardes et les échéances.</div>`;
dessinerNavigation();
dessinerDetail();
}
function choisirVeille(i) { selVeille = i; dessinerATraiter(); }
function filtrerVeille(s) { filtreVeille = s; dessinerATraiter(); }
function inspecteurVeille() {
const cible = document.getElementById('detail');
const e = aTraiterCharge ? elementsVeille()[selVeille] : null;
if (!e) { cible.innerHTML = '<div class="detail"><div class="vide"><b>Rien de choisi</b>Choisis une ligne à gauche.</div></div>'; return; }
const g = e.geste;
const geste = !g ? '' : g.commande
? `<div class="commande"><span>${echapper(g.commande)}</span><button type="button" class="fantome" onclick="copierCommande(${echapper(JSON.stringify(g.commande))})">Copier</button></div>
<div class="gestes-note">${echapper(g.libelle)}</div>`
: `<div class="geste"><span class="quoi"><b>${echapper(g.libelle)}</b></span><button type="button" class="primaire" onclick="lancerGeste(${selVeille})">Ouvrir</button></div>`;
cible.innerHTML = `<div class="detail">
<div class="section-fiche"><div class="section-tete">${echapper(e.source)}</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px"><b style="font-size:15px">${echapper(e.titre)}</b>${etiquetteGravite(e.gravite)}</div>
${e.impact ? `<div class="hint-dep">${echapper(e.impact)}</div>` : ''}
${e.detail ? `<div class="note-contrat" style="margin-top:8px;font-family:ui-monospace,monospace;overflow-wrap:anywhere">${echapper(e.detail)}</div>` : ''}</div>
${geste ? `<div class="gestes"><div class="gestes-tete"><span class="section-tete" style="margin:0">Le geste</span></div>${geste}</div>` : ''}
</div>`;
}
function dessinerServeurs() {
// Une console qui n'edite aucun plan n'a pas de registre `serveurs` — et elle a
// pourtant des machines. On dessine CE QU'ELLE A.
@ -4618,6 +4974,7 @@ let selMachine = null;
// Le panneau de droite reflète la sélection de la VUE COURANTE.
if (vuePrincipale === 'flux' || vuePrincipale === 'couches' || vuePrincipale === 'reseau') return legendeLecture();
if (vuePrincipale === 'assistants') return inspecteurRunbook();
if (vuePrincipale === 'a-traiter') return inspecteurVeille();
if (vuePrincipale === 'lien') return inspecteurLien();
if (vuePrincipale === 'locataires') return inspecteurLocataire();
if (vuePrincipale === 'integrations') return legendeIntegrations();
@ -5590,6 +5947,17 @@ class Gestionnaire(BaseHTTPRequestHandler):
self.repondre(204, b"", "image/x-icon")
elif chemin == "/api/contexte":
self.repondre_json(200, contexte())
elif chemin == "/api/a-traiter":
_c = console(self.inventaire)
try:
elements = _c.a_traiter()
except Exception as exc:
self.repondre_json(500, {"erreur": f"{type(exc).__name__}: {exc}"})
return
if elements is None:
self.repondre_json(404, {"erreur": "Cette console ne tient aucune veille."})
else:
self.repondre_json(200, {"elements": elements})
elif chemin in ("/api/lien-site", "/api/locataires-heberges"):
# EN LECTURE, sans pouvoir requis : ces deux fichiers sont deja dans les
# depots que cette console monte. Une console qui n'a pas ce cote du

View file

@ -107,9 +107,9 @@ class TestPorteeDeLaConsole(unittest.TestCase):
poste monte les deux depots, il lit les deux. Le cote suit la BRANCHE, pas la
voute : lire ces deux fichiers ne demande aucun secret."""
attendus = {
(True, False): {"lien_avec_le_site": True, "locataires_heberges": False},
(False, True): {"lien_avec_le_site": False, "locataires_heberges": True},
(True, True): {"lien_avec_le_site": True, "locataires_heberges": True},
(True, False): {"lien_avec_le_site": True, "locataires_heberges": False, "a_traiter": True},
(False, True): {"lien_avec_le_site": False, "locataires_heberges": True, "a_traiter": True},
(True, True): {"lien_avec_le_site": True, "locataires_heberges": True, "a_traiter": True},
}
for (instance, fabric), contrat in attendus.items():
with self.subTest(instance=instance, fabric=fabric):
@ -141,6 +141,48 @@ class TestPorteeDeLaConsole(unittest.TestCase):
self.assertIsNone(l["fiche"]["ecarts"], "la fiche d'un autre site ne se juge pas ici")
self.assertEqual(l["index_attribue"], 18)
def _git(self, depot, *args):
import subprocess
subprocess.run(["git", "-C", str(depot), *args], check=True, capture_output=True,
env={"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t", "HOME": str(self.dossier), "PATH": "/usr/bin:/bin"})
def test_le_niveau_de_la_forge_se_lit_sans_fetch(self):
"""Lot 3a : « a jour », « en retard », « en avance », « inconnu », mesures sur de
vrais depots. En retard sans fetch : la forge porte un commit que le depot n'a pas
tire — et la lecture ne doit pas l'avoir tire pour autant."""
forge, ici, autre = self.dossier / "forge.git", self.dossier / "ici", self.dossier / "autre"
self._git(self.dossier, "init", "-q", "--bare", "-b", "main", str(forge))
self._git(self.dossier, "clone", "-q", str(forge), str(ici))
self._git(ici, "commit", "-q", "--allow-empty", "-m", "un")
self._git(ici, "push", "-q", "origin", "HEAD:main")
self._git(ici, "branch", "-q", "--set-upstream-to", "origin/main")
self.assertEqual(gui.etat_forge(ici)["verdict"], "a-jour")
self._git(self.dossier, "clone", "-q", str(forge), str(autre))
self._git(autre, "commit", "-q", "--allow-empty", "-m", "deux")
self._git(autre, "push", "-q", "origin", "HEAD:main")
e = gui.etat_forge(ici)
self.assertEqual(e["verdict"], "retard", e)
self.assertEqual(gui.etat_forge(ici)["verdict"], "retard", "la lecture n'a rien tire")
self._git(ici, "pull", "-q", "--ff-only")
self._git(ici, "commit", "-q", "--allow-empty", "-m", "trois")
self.assertEqual(gui.etat_forge(ici)["verdict"], "avance")
self._git(ici, "branch", "-q", "--unset-upstream")
self.assertEqual(gui.etat_forge(ici)["verdict"], "inconnu")
def test_la_veille_d_un_locataire_sans_fiche_le_dit(self):
"""Une veille qui ne voit rien doit le dire en « ok » ; une fiche absente, en « agir »."""
loc = self.dossier / "OPS-Essai"
(loc / "plan").mkdir(parents=True)
(loc / "plan" / "nomenclature.yml").write_text("index: 18\n", encoding="utf-8")
(loc / "face-reseau.yml").write_text(
"locataire: OPS-Essai\nhebergeur: SITE-Absent\nindex: 18\nmachines: {}\n", encoding="utf-8")
elements = gui.veille_locataire(loc)
fiche = [e for e in elements if e["source"] == "FICHE"]
self.assertEqual([e["gravite"] for e in fiche], ["agir"])
self.assertTrue(all(e["gravite"] in ("agir", "savoir", "ok") for e in elements))
self.assertTrue(any(e["source"] == "GÉNOME" for e in elements))
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -179,6 +179,9 @@ charger().then(async function () {
vuePrincipale = 'locataires'; locatairesCharge = null; await dessinerLocataires();
o.locataires = document.getElementById('grilles').innerHTML;
o.locDetail = document.getElementById('detail').innerHTML;
vuePrincipale = 'a-traiter'; aTraiterCharge = null; await dessinerATraiter();
o.veille = document.getElementById('grilles').innerHTML;
o.veilleDetail = document.getElementById('detail').innerHTML;
} catch (e) { o.erreurs.push(String((e && e.stack) || e)); }
console.log('__RESULTAT__' + JSON.stringify(o));
}).catch(function (e) {
@ -316,9 +319,11 @@ class TestRenduGui(unittest.TestCase):
self.skipTest("il faut un locataire et un site montes")
lien = g.lien_du_locataire(g.INSTANCE.resolve())
locs = g.locataires_du_site(g._depot_hebergeur())
veille = g.veille_locataire(g.INSTANCE.resolve())
payload = g.inventaire_api(g.INVENTAIRE_DEFAUT)
js = BANC + "\n".join(self.blocs) + APRES_CONTRAT
r = self._jouer(js, payload, {"/api/lien-site": lien, "/api/locataires-heberges": locs})
r = self._jouer(js, payload, {"/api/lien-site": lien, "/api/locataires-heberges": locs,
"/api/a-traiter": {"elements": veille}})
self.assertEqual(r["erreurs"], [], "une vue a leve :\n - " + "\n - ".join(r["erreurs"]))
self.assertIn("Ma face réseau", r["lien"])
if lien["face"]["trace"]["commit"]:
@ -328,6 +333,11 @@ class TestRenduGui(unittest.TestCase):
self.assertIn(l["nom"], r["locataires"])
if locs["locataires"]:
self.assertIn("Ce que je ne vois pas", r["locDetail"])
# LOT 3a : chaque element de la veille est dessine, « ok » compris (replie).
self.assertIn("À traiter", r["veille"])
for e in veille:
self.assertIn(e["titre"].replace("'", "&#39;"), r["veille"].replace("&#039;", "&#39;"))
self.assertIn(veille[0]["source"], r["veilleDetail"] + r["veille"])
def test_une_console_de_site_sert_son_propre_plan(self):
"""UN SITE A UN PLAN, ET ON LE LUI SERVAIT VIDE (2026-09-20).