From aa5e4e0897c39613f0e8647ce9f1449d674bcb20 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 11 Oct 2026 19:12:27 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 37 +++ scripts/inventory_gui.py | 370 ++++++++++++++++++++++++++- scripts/tests/test_portee_console.py | 48 +++- scripts/tests/test_rendu_gui.py | 12 +- 4 files changed, 462 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3232f2..cdfbfea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-` 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 diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 04537fe..6238934 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -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; `; } + // 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: 'à prendre', savoir: 'à savoir', + ok: 'vérifié'}[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 = '
Lecture de la veille : génome, contrat, plan…
'; + try { aTraiterCharge = await lireContrat('/api/a-traiter'); } + catch (e) { cible.innerHTML = '
' + echapper(e.message) + '
'; 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) => ``; + cible.innerHTML = `
+

À traiter

${echapper(sousTitre)}
+
+
${n('agir')}à prendre
+
${n('savoir')}à savoir
+
${n('ok')}vérifiés, rien à faire
+
+ ${sources.length > 1 ? `
` + + sources.map(s => ``).join('') + '
' : ''} + ${aFaire.length ? `
${aFaire.map(rang).join('')}
` + : '
Rien à prendre ni à savoir' + (filtreVeille ? ' pour ce filtre' : '') + '.
'} + ${faits.length ? `
Vérifié, rien à faire (${faits.length}) +
${faits.map(rang).join('')}
` : ''} +
Pas encore ici : les demandes entre locataire et site (par la forge), la supervision, les sauvegardes et les échéances.
`; + 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 = '
Rien de choisiChoisis une ligne à gauche.
'; return; } + const g = e.geste; + const geste = !g ? '' : g.commande + ? `
${echapper(g.commande)}
+
${echapper(g.libelle)}
` + : `
${echapper(g.libelle)}
`; + cible.innerHTML = `
+
${echapper(e.source)}
+
${echapper(e.titre)}${etiquetteGravite(e.gravite)}
+ ${e.impact ? `
${echapper(e.impact)}
` : ''} + ${e.detail ? `
${echapper(e.detail)}
` : ''}
+ ${geste ? `
Le geste
${geste}
` : ''} +
`; + } + 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 diff --git a/scripts/tests/test_portee_console.py b/scripts/tests/test_portee_console.py index 02076f1..0471cdd 100644 --- a/scripts/tests/test_portee_console.py +++ b/scripts/tests/test_portee_console.py @@ -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) diff --git a/scripts/tests/test_rendu_gui.py b/scripts/tests/test_rendu_gui.py index 5ab4ece..fe98c90 100644 --- a/scripts/tests/test_rendu_gui.py +++ b/scripts/tests/test_rendu_gui.py @@ -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("'", "'"), r["veille"].replace("'", "'")) + 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).