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:
parent
0d5ec924cc
commit
aa5e4e0897
4 changed files with 462 additions and 5 deletions
37
CHANGELOG.md
37
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-<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue