[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""Socle commun des formulaires de déploiement (QEMU/KVM et Proxmox VE).
|
|
|
|
|
|
|
|
|
|
Ce fichier existe pour une raison simple : les deux formulaires posent les
|
|
|
|
|
mêmes questions de fond — combien de processeurs, combien de mémoire, quel
|
|
|
|
|
disque, quelles machines, et montre-moi le plan avant de lancer. Seul CE QUI
|
|
|
|
|
VARIE reste dans les fichiers de formulaire : le catalogue et les options
|
|
|
|
|
propres à chaque hyperviseur.
|
|
|
|
|
|
|
|
|
|
Trois couches, du plus sûr au plus fragile :
|
|
|
|
|
|
|
|
|
|
1. La LOGIQUE PURE (lecture d'une taille, d'un entier, calcul du plan et des
|
|
|
|
|
totaux, assemblage de la spec). Aucune dépendance à Textual, donc
|
|
|
|
|
vérifiable sans terminal — c'est là que vivent les pièges déjà payés :
|
|
|
|
|
« 128G » qui rendait 0, une spec qui perdait une clé.
|
|
|
|
|
2. Le SOCLE VISUEL : les règles CSS que les deux formulaires partagent, et les
|
|
|
|
|
fabriques des sélecteurs « préréglages + saisie libre ».
|
|
|
|
|
3. La VUE DE PROGRESSION (`run_deploy_progress`), déjà générique : elle prend
|
|
|
|
|
des travaux « (id, nom, argv) » et rend « (nom, code, sortie, durée) ».
|
|
|
|
|
Elle ne sait rien de libvirt ni de qm, et sert donc les deux tels quels.
|
|
|
|
|
|
|
|
|
|
Compatibilité : `qemu_deploy_form` réexporte ce module, si bien que les
|
|
|
|
|
appelants historiques (`from script.todo.qemu_deploy_form import parse_ram`)
|
|
|
|
|
continuent de fonctionner.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
except Exception: # pragma: no cover - repli si i18n indisponible
|
|
|
|
|
|
|
|
|
|
def t(key: str) -> str:
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Logique pure — aucune dépendance à Textual, testable telle quelle
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
def entry_key(entry) -> tuple:
|
|
|
|
|
"""Identité stable d'une VM du plan, indépendante de son rang d'affichage :
|
|
|
|
|
surcharges et verrous y survivent quand la sélection change.
|
|
|
|
|
|
|
|
|
|
Le quatrième membre est le numéro d'EXEMPLAIRE. Sans lui, deux copies de
|
|
|
|
|
la même entrée de catalogue partageaient une identité : régler la première
|
|
|
|
|
réglait la seconde, et les verrous se marchaient dessus."""
|
|
|
|
|
return (
|
|
|
|
|
entry["distro"],
|
|
|
|
|
entry["version"],
|
|
|
|
|
entry["arch"],
|
|
|
|
|
entry.get("instance", 0),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def copy_name(base: str, instance: int) -> str:
|
|
|
|
|
"""Nom du n-ième exemplaire. Le premier garde le nom du catalogue, pour
|
|
|
|
|
que les déploiements d'avant gardent le leur."""
|
|
|
|
|
return base if not instance else f"{base}-{instance + 1}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def expand_copies(entries, copies):
|
|
|
|
|
"""[entrée] + {clé de base: exemplaires en plus} -> [entrée par VM].
|
|
|
|
|
|
|
|
|
|
Chaque exemplaire est une COPIE du dictionnaire, avec son numéro et son
|
|
|
|
|
nom : rien n'est partagé, donc régler l'un ne touche pas l'autre."""
|
|
|
|
|
out = []
|
|
|
|
|
for e in entries:
|
|
|
|
|
base = (e["distro"], e["version"], e["arch"])
|
|
|
|
|
for i in range(1 + max(0, (copies or {}).get(base, 0))):
|
|
|
|
|
item = dict(e)
|
|
|
|
|
item["instance"] = i
|
|
|
|
|
item["name"] = copy_name(e["name"], i)
|
|
|
|
|
out.append(item)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_disk(value):
|
|
|
|
|
"""« 60 », « 60G », « 1T », « 1,5T » -> « <n>G », ou None si invalide.
|
|
|
|
|
Même règle que `TODO._qemu_parse_disk` : tout le reste de la chaîne
|
|
|
|
|
raisonne en gigaoctets."""
|
|
|
|
|
txt = str(value).strip().upper().replace(",", ".")
|
|
|
|
|
factor = 1
|
|
|
|
|
if txt.endswith("T"):
|
|
|
|
|
factor, txt = 1024, txt[:-1]
|
|
|
|
|
elif txt.endswith("G"):
|
|
|
|
|
txt = txt[:-1]
|
|
|
|
|
try:
|
|
|
|
|
gigs = int(float(txt) * factor)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return None
|
|
|
|
|
return f"{gigs}G" if gigs > 0 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def disk_gb(value) -> int:
|
|
|
|
|
"""« 60G » -> 60 (best effort), pour les totaux."""
|
|
|
|
|
parsed = parse_disk(value)
|
|
|
|
|
return int(parsed[:-1]) if parsed else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def positive_int(value, fallback):
|
|
|
|
|
"""Entier strictement positif, sinon `fallback`.
|
|
|
|
|
|
|
|
|
|
Ces valeurs viennent de widgets : une liste déroulante sans choix rend un
|
|
|
|
|
sentinelle, une saisie libre rend du texte, éventuellement vide. Aucun des
|
|
|
|
|
deux ne doit atteindre les totaux, qui les additionnent."""
|
|
|
|
|
try:
|
|
|
|
|
number = int(value)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return fallback
|
|
|
|
|
return number if number > 0 else fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_ram(value):
|
|
|
|
|
"""« 2048 », « 128G », « 1,5G » -> mébioctets, ou 0 si invalide.
|
|
|
|
|
|
|
|
|
|
Les valeurs proposées s'affichent en G — « 2G », « 16G » — alors que la
|
|
|
|
|
saisie libre comptait en Mo. Taper « 128G », ce que l'affichage invite à
|
|
|
|
|
faire, rendait 0 : la surcharge était alors RETIRÉE et la VM revenait à la
|
|
|
|
|
valeur du catalogue, sans un mot. On accepte donc les deux écritures, un
|
|
|
|
|
nombre nu restant des mébioctets."""
|
|
|
|
|
txt = str(value or "").strip().upper().replace(",", ".")
|
|
|
|
|
factor = 1
|
|
|
|
|
if txt.endswith("GI"):
|
|
|
|
|
factor, txt = 1024, txt[:-2]
|
|
|
|
|
elif txt.endswith(("G", "T")):
|
|
|
|
|
factor = 1024 * (1024 if txt.endswith("T") else 1)
|
|
|
|
|
txt = txt[:-1]
|
|
|
|
|
elif txt.endswith(("M", "MI")):
|
|
|
|
|
txt = txt.rstrip("IM")
|
|
|
|
|
try:
|
|
|
|
|
mib = int(float(txt) * factor)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return 0
|
|
|
|
|
return mib if mib > 0 else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_profile(
|
|
|
|
|
entries, profile, base_vcpus, host_cpu, custom=None, desktop=""
|
|
|
|
|
):
|
|
|
|
|
"""Applique le profil de ressources aux entrées choisies.
|
|
|
|
|
|
|
|
|
|
Reproduit à l'identique `TODO._qemu_prompt_resources` : un multiplicateur
|
|
|
|
|
monte la RAM minimale du catalogue et les vCPU en se bornant aux cœurs de
|
|
|
|
|
l'hôte ; « custom » impose les mêmes valeurs à tout le parc, une valeur
|
|
|
|
|
absente gardant celle du catalogue."""
|
|
|
|
|
out = []
|
|
|
|
|
for e in entries:
|
|
|
|
|
if profile == "custom":
|
|
|
|
|
cus = custom or {}
|
|
|
|
|
ram = positive_int(cus.get("ram"), e["ram"])
|
|
|
|
|
disk = parse_disk(cus.get("disk")) or e["disk"]
|
|
|
|
|
vcpus = positive_int(cus.get("vcpus"), base_vcpus)
|
|
|
|
|
else:
|
|
|
|
|
mult = int(profile)
|
|
|
|
|
ram = e["ram"] * mult
|
|
|
|
|
disk = e["disk"]
|
|
|
|
|
vcpus = min(base_vcpus * mult, host_cpu)
|
|
|
|
|
out.append(
|
|
|
|
|
{
|
|
|
|
|
"name": e["name"],
|
|
|
|
|
"distro": e["distro"],
|
|
|
|
|
"version": e["version"],
|
|
|
|
|
"arch": e["arch"],
|
|
|
|
|
"ram": ram,
|
|
|
|
|
"disk": disk,
|
|
|
|
|
"vcpus": vcpus,
|
|
|
|
|
# Type de VM (« » = serveur). Il vit sur la VM et non sur la
|
|
|
|
|
# spec entière depuis qu'il se choisit machine par machine ;
|
|
|
|
|
# `desktop` n'est plus que le défaut commun.
|
|
|
|
|
"desktop": desktop,
|
|
|
|
|
# Branche ERPLibre. Même raison : « » signifie « celle du
|
|
|
|
|
# formulaire », et une surcharge la remplace pour cette VM.
|
|
|
|
|
"branch": "",
|
|
|
|
|
# Profil d'installation (« ERPLibre + Odoo 18 »). Même
|
|
|
|
|
# convention : « » = celui du formulaire.
|
|
|
|
|
"install_cmd": "",
|
|
|
|
|
# Libelle du profil, pour que le recapitulatif puisse dire
|
|
|
|
|
# « Odoo 18 » sans connaitre la liste des profils.
|
|
|
|
|
"install_label": "",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_overrides(vms, entries, overrides):
|
|
|
|
|
"""Réapplique les réglages par VM (nom, vCPU, RAM, disque) après un
|
|
|
|
|
recalcul du profil. `overrides` est indexé par `entry_key`."""
|
|
|
|
|
for vm, e in zip(vms, entries):
|
|
|
|
|
for field, value in (overrides.get(entry_key(e)) or {}).items():
|
|
|
|
|
vm[field] = value
|
|
|
|
|
return vms
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clean_hostname(value):
|
|
|
|
|
"""Nom d'hote valide (RFC 1123) tire de la saisie, ou None.
|
|
|
|
|
|
|
|
|
|
Le nom d'une VM devient son NOM D'HOTE : une majuscule ou un point de
|
|
|
|
|
trop et cloud-init l'ignore en silence, la machine reste « ubuntu ».
|
|
|
|
|
Mieux vaut refuser ici que le decouvrir sur une VM deja deployee."""
|
|
|
|
|
txt = str(value or "").strip().lower()
|
|
|
|
|
if not txt or len(txt) > 63:
|
|
|
|
|
return None
|
|
|
|
|
if not re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?", txt):
|
|
|
|
|
return None
|
|
|
|
|
return txt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def vm_name(base, desktop, suffixes):
|
|
|
|
|
"""Nom de VM, suffixé du bureau quand il y en a un.
|
|
|
|
|
|
|
|
|
|
Le nom sert de nom d'hôte ET de clé de collision : une VM graphique et sa
|
|
|
|
|
jumelle serveur doivent donc porter des noms différents, sinon la seconde
|
|
|
|
|
est signalée « existe déjà » et silencieusement ignorée. Idempotent, le
|
|
|
|
|
nom étant recalculé à chaque frappe."""
|
|
|
|
|
suffix = (suffixes or {}).get(desktop or "")
|
|
|
|
|
if not suffix or base.endswith(f"-{suffix}"):
|
|
|
|
|
return base
|
|
|
|
|
return f"{base}-{suffix}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_vms(
|
|
|
|
|
entries,
|
|
|
|
|
profile,
|
|
|
|
|
base_vcpus,
|
|
|
|
|
host_cpu,
|
|
|
|
|
custom,
|
|
|
|
|
overrides,
|
|
|
|
|
desktop="",
|
|
|
|
|
suffixes=None,
|
|
|
|
|
):
|
|
|
|
|
"""Catalogue choisi + profil + surcharges -> liste de VM de la spec."""
|
|
|
|
|
vms = apply_overrides(
|
|
|
|
|
apply_profile(entries, profile, base_vcpus, host_cpu, custom, desktop),
|
|
|
|
|
entries,
|
|
|
|
|
overrides,
|
|
|
|
|
)
|
|
|
|
|
# APRÈS les surcharges : c'est là seulement que le type de chaque VM est
|
|
|
|
|
# connu, puisqu'il se choisit machine par machine.
|
|
|
|
|
for vm, e in zip(vms, entries):
|
|
|
|
|
if (overrides or {}).get(entry_key(e), {}).get("name"):
|
|
|
|
|
# Nom donne a la main : il gagne, sans suffixe ajoute. Y coller
|
|
|
|
|
# « -gnome » reviendrait a corriger l'utilisateur.
|
|
|
|
|
continue
|
|
|
|
|
vm["name"] = vm_name(vm["name"], vm.get("desktop"), suffixes)
|
|
|
|
|
return vms
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def libvirt_orphan(name) -> bool:
|
|
|
|
|
"""Un disque qcow2 sans domaine, sur CETTE machine.
|
|
|
|
|
|
|
|
|
|
Propre à QEMU/KVM : un Proxmox distant n'a pas ce répertoire, et ses
|
|
|
|
|
disques vivent dans un stockage que seul l'hôte connaît.
|
|
|
|
|
"""
|
|
|
|
|
return os.path.exists(f"/var/lib/libvirt/images/{name}.qcow2")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def vm_status(name, domains, orphelin=None):
|
|
|
|
|
"""État d'un nom face à l'existant : ('new'|'exists'|'orphan', message).
|
|
|
|
|
|
|
|
|
|
Les deux collisions n'ont pas la même gravité — une VM définie est
|
|
|
|
|
ignorée, un qcow2 resté seul fait échouer deploy_qemu, qui refuse
|
|
|
|
|
d'écraser sans --force.
|
|
|
|
|
|
|
|
|
|
`orphelin` dit comment reconnaître un disque resté seul. Le défaut regarde
|
|
|
|
|
le répertoire de libvirt ; un hyperviseur distant passe le sien, ou
|
|
|
|
|
`lambda _n: False` s'il n'a pas de disque orphelin à craindre."""
|
|
|
|
|
if name in domains:
|
|
|
|
|
return "exists", t("exists - skipped")
|
|
|
|
|
if (orphelin or libvirt_orphan)(name):
|
|
|
|
|
return "orphan", t("orphan disk - will FAIL")
|
|
|
|
|
return "new", ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def plan_rows(vms, domains, extra_disk_gb=0, orphelin=None):
|
|
|
|
|
"""Lignes du tableau du plan : une par VM, avec son état."""
|
|
|
|
|
rows = []
|
|
|
|
|
for vm in vms:
|
|
|
|
|
state, note = vm_status(vm["name"], domains, orphelin)
|
|
|
|
|
rows.append(
|
|
|
|
|
{
|
|
|
|
|
"vm": vm,
|
|
|
|
|
"state": state,
|
|
|
|
|
"note": note,
|
|
|
|
|
"disk_gb": disk_gb(vm["disk"]) + extra_disk_gb,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return rows
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 05:38:28 -04:00
|
|
|
# Les branches qu'on veut voir en premier. « ls-remote » les rend par ordre
|
|
|
|
|
# alphabétique : la liste commençait donc par « dependabot/pip/aiobotocore »,
|
|
|
|
|
# et c'est CELLE-LÀ qui était proposée par défaut — rapporté.
|
|
|
|
|
BRANCHES_EN_TETE = ("develop", "master")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def branch_default(branches, courante=""):
|
|
|
|
|
"""La branche à proposer : celle du DÉPÔT d'abord.
|
|
|
|
|
|
|
|
|
|
Déployer avec la branche qu'on a sous les yeux est le cas courant ; à
|
|
|
|
|
défaut « develop », puis « master ». Le premier de la liste alphabétique
|
|
|
|
|
n'a aucune raison d'être un bon défaut, et c'était une branche de
|
|
|
|
|
dependabot.
|
|
|
|
|
"""
|
|
|
|
|
liste = list(branches or ())
|
|
|
|
|
if not liste:
|
|
|
|
|
return ""
|
|
|
|
|
for candidate in (courante,) + BRANCHES_EN_TETE:
|
|
|
|
|
if candidate and candidate in liste:
|
|
|
|
|
return candidate
|
|
|
|
|
return liste[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def branch_order(branches, courante=""):
|
|
|
|
|
"""La liste réordonnée : la branche du dépôt, develop, master, puis le
|
|
|
|
|
reste, et les branches de robot à la fin.
|
|
|
|
|
|
|
|
|
|
Une liste de soixante entrées qui commence par six « dependabot/pip/… »
|
|
|
|
|
demande de dérouler pour trouver ce qu'on cherche.
|
|
|
|
|
"""
|
|
|
|
|
liste = list(branches or ())
|
|
|
|
|
# Dédoublonnée : la branche du dépôt EST souvent « develop », et la voir
|
|
|
|
|
# deux fois de suite ferait douter de la liste.
|
|
|
|
|
tete, vues = [], set()
|
|
|
|
|
for b in (courante,) + BRANCHES_EN_TETE:
|
|
|
|
|
if b and b in liste and b not in vues:
|
|
|
|
|
tete.append(b)
|
|
|
|
|
vues.add(b)
|
|
|
|
|
milieu, robots = [], []
|
|
|
|
|
for b in liste:
|
|
|
|
|
if b in vues:
|
|
|
|
|
continue
|
|
|
|
|
vues.add(b)
|
|
|
|
|
(robots if b.startswith("dependabot/") else milieu).append(b)
|
|
|
|
|
return tete + milieu + robots
|
|
|
|
|
|
|
|
|
|
|
[ADD] todo : dire la place qui reste sous le plan de déploiement
La ligne de totaux annonçait « ~126 G » sans dire sur quoi : la demande
seule ne dit pas si ça rentre, et on l'apprenait au déploiement. Elle dit
maintenant la demande, ce qui reste et la capacité — « ~126 G / 20 G libres
sur 270 G » — et prévient dès que le plan dépasse. Les trois limites (RAM,
disque, cœurs) s'affichent ensemble : n'en montrer qu'une cachait les
autres. Sur Proxmox, la place vient du stockage choisi, que « pvesm status »
donnait déjà.
Deux défauts trouvés en le faisant : la marque de génération, exigée de tous
les widgets, faisait taire chaque réglage commun de l'écran Proxmox, et
« [x1] » disparaissait, lu comme une balise Rich.
--- EN ---
The totals line said "~126 G" without saying out of what: the demand alone
does not tell whether it fits, and one found out at deploy time. It now says
the demand, what is left and the capacity — "~126 G / 20 G free of 270 G" —
and warns as soon as the plan exceeds it. The three limits (RAM, disk,
cores) show together: showing only one hid the others. On Proxmox the room
comes from the chosen storage, which "pvesm status" already gave.
Two defects found on the way: the generation mark, required of every widget,
silenced each common setting on the Proxmox screen, and "[x1]" vanished,
read as a Rich tag.
Assisted-by: Claude Opus 5
2026-08-24 01:11:09 -04:00
|
|
|
def gib(nbytes) -> int:
|
|
|
|
|
"""Octets -> Gio entiers. Le plan compte en Go partout ailleurs : mêler
|
|
|
|
|
des unités sur la même ligne de totaux la rendrait illisible."""
|
|
|
|
|
try:
|
|
|
|
|
return int(nbytes) // (1 << 30)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def disk_note(plan_gb, free_gb, total_gb=0) -> str:
|
|
|
|
|
"""« ~50 G / 20 G libres sur 270 G » — la demande, le reste, la capacité.
|
|
|
|
|
|
|
|
|
|
La demande seule ne dit pas si ça rentre : c'est le rapprochement qui
|
|
|
|
|
décide, et c'est pourquoi la place s'affiche là même où la demande était
|
|
|
|
|
déjà écrite. Les deux nombres ne sont pas redondants — 20 Go libres sur
|
|
|
|
|
270 se lit autrement que 20 sur 24. Sans mesure (0), on n'invente rien :
|
|
|
|
|
la demande s'affiche seule.
|
|
|
|
|
"""
|
|
|
|
|
if not free_gb:
|
|
|
|
|
return f"~{plan_gb} G"
|
|
|
|
|
if not total_gb:
|
|
|
|
|
return f"~{plan_gb} G / {free_gb} G {t('free')}"
|
|
|
|
|
return f"~{plan_gb} G / {free_gb} G {t('free of')} {total_gb} G"
|
|
|
|
|
|
|
|
|
|
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
def plan_totals(rows):
|
|
|
|
|
"""Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de
|
|
|
|
|
neuf) : (nb, vcpus, ram_mo, disque_go)."""
|
|
|
|
|
fresh = [r for r in rows if r["state"] != "exists"]
|
|
|
|
|
return (
|
|
|
|
|
len(fresh),
|
|
|
|
|
sum(r["vm"]["vcpus"] for r in fresh),
|
|
|
|
|
sum(r["vm"]["ram"] for r in fresh),
|
|
|
|
|
sum(r["disk_gb"] for r in fresh),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_spec(vms, domains, form):
|
|
|
|
|
"""Assemble la spec finale, dans la forme exacte que produit la CLI."""
|
|
|
|
|
known = set(domains)
|
|
|
|
|
return {
|
|
|
|
|
"res_label": form["res_label"],
|
|
|
|
|
"vms": [vm for vm in vms if vm["name"] not in known],
|
|
|
|
|
"existing": [vm["name"] for vm in vms if vm["name"] in known],
|
|
|
|
|
"ssh_key": form["ssh_key"],
|
|
|
|
|
"timezone": form.get("timezone", ""),
|
|
|
|
|
"desktop": form.get("desktop", ""),
|
|
|
|
|
"vm_tools": tuple(form.get("vm_tools") or ()),
|
|
|
|
|
"python_provider": form.get("python_provider", ""),
|
|
|
|
|
"app_store": form.get("app_store", "deb"),
|
|
|
|
|
"install": form["install"],
|
|
|
|
|
# Au NIVEAU DU DÉPLOIEMENT, pas de l'installation : une VM sans
|
|
|
|
|
# ERPLibre se suit aussi (cloud-init, puis relevé système). Absent de
|
|
|
|
|
# cette assemblée, le choix du formulaire n'atteignait jamais la spec.
|
|
|
|
|
"monitor": form.get("monitor", True),
|
[ADD] qemu deploy : case 3D à la création, même sans écran virtuel
Une VM sans console peut vouloir un virtio-gpu accéléré — rendu hors
écran, ou émulateur qui tourne dedans — et « auto » ne l'accorde jamais :
il s'abstient sans écran, pour ne pas poser un périphérique vidéo que
personne n'a demandé. La case envoie donc « --gpu on », qui l'accorde
désormais. « --graphics none » est alors écarté : il dit « aucun
affichage », et egl-headless EST un affichage. Le repli le rend quand la
3D tombe, sinon la VM repartirait sur le défaut de virt-install.
Vérifié : 8 tests neufs et 2 sur le repli, rougis par trois mutations.
Le rendu de la case dans le terminal plein écran n'est pas testé.
--- EN ---
A VM without a console may want an accelerated virtio-gpu — offscreen
rendering, or an emulator running inside — and « auto » never grants it:
it abstains without a screen, so as not to add a video device nobody
asked for. The box therefore sends « --gpu on », which now grants it.
« --graphics none » is then dropped: it means « no display », and
egl-headless IS a display. The fallback gives it back when 3D fails,
otherwise the VM would restart on virt-install's default.
Checked: 8 new tests and 2 on the fallback, turned red by three
mutations. The box's rendering in the full-screen terminal is not tested.
Assisted-by: Claude Opus 5
2026-09-03 00:50:48 -04:00
|
|
|
# Au niveau du déploiement : la 3D est une propriété du matériel de la
|
|
|
|
|
# VM, pas de ce qu'on installe dedans.
|
|
|
|
|
"gpu3d": form.get("gpu3d", False),
|
[ADD] qemu deploy : poser rtk, starship et un agent dans la VM
Une case de plus au catalogue des outils, donc une case dans les deux
écrans sans y toucher. Cochée, elle découvre le choix de l'agent — Claude
Code ou opencode — et l'identité git, pré-remplie avec celle de l'hôte :
c'est ce que la VM reçoit déjà, et un champ vide la ferait croire absente.
Ce qui est saisi prime, champ par champ.
Phase « before », où chaque outil se garde lui-même. Chaque pose est aussi
privée d'entrée standard et bornée dans le temps : « || true » couvre
l'échec, pas l'ATTENTE, et un installateur amont qui pose une question
resterait pendu sur un SSH sans terminal — d'où « -y » pour starship.
--- EN ---
One more entry in the tool catalogue, hence one more box on both screens
for free. Ticked, it reveals the agent choice — Claude Code or opencode —
and the git identity, prefilled from the host: that is what the VM
already gets, and an empty field would suggest none. What is typed wins,
field by field.
Phase « before », where each tool guards itself. Every install is also
denied stdin and time-bounded: « || true » covers failure, not WAITING,
and an upstream installer asking a question would hang on a terminal-less
SSH — hence « -y » for starship.
Assisted-by: Claude Opus 5
2026-09-03 04:12:14 -04:00
|
|
|
# Au niveau du déploiement : l'agent choisi et l'identité git valent
|
|
|
|
|
# pour tout le parc, comme le fuseau ou le magasin d'applications.
|
|
|
|
|
"ai_agent": form.get("ai_agent", ""),
|
|
|
|
|
"git_name": form.get("git_name", ""),
|
|
|
|
|
"git_email": form.get("git_email", ""),
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
"add_ssh_config": form["add_ssh_config"],
|
|
|
|
|
"parallelism": form["parallelism"],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fmt_dur(secs) -> str:
|
|
|
|
|
mm, ss = divmod(int(secs), 60)
|
|
|
|
|
if mm >= 60:
|
|
|
|
|
return f"{mm // 60}h{mm % 60:02d}"
|
|
|
|
|
return f"{mm}m{ss:02d}" if mm else f"{ss}s"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Au-delà, un OSC 52 est tronqué par certains terminaux (xterm notamment).
|
|
|
|
|
# On copie alors la FIN du log — la partie qui porte l'erreur.
|
|
|
|
|
CLIP_LIMIT = 100_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clip_payload(text, limit=CLIP_LIMIT):
|
|
|
|
|
"""(texte_à_copier, tronqué?) — on garde la fin, pas le début."""
|
|
|
|
|
if len(text) <= limit:
|
|
|
|
|
return text, False
|
|
|
|
|
return text[-limit:], True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Socle visuel — ce que les deux formulaires partagent
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# Sentinelle du dernier choix d'une liste de ressources : il ne porte pas de
|
|
|
|
|
# valeur, il révèle la saisie libre placée juste dessous. Partagée pour que les
|
|
|
|
|
# deux formulaires se comportent pareil devant « autre… ».
|
|
|
|
|
FREE = "__free__"
|
|
|
|
|
|
|
|
|
|
# Règles communes : la mise en page (panneau de champs à gauche, plan à droite,
|
|
|
|
|
# totaux dessous) et les fenêtres modales. Chaque formulaire y ajoute ses
|
|
|
|
|
# propres règles — celles qui nomment SES widgets.
|
|
|
|
|
#
|
|
|
|
|
# Extrait du formulaire QEMU, où elles étaient mêlées à ses spécificités. Le
|
|
|
|
|
# rendu du formulaire QEMU a été comparé caractère par caractère avant/après
|
|
|
|
|
# l'extraction : identique.
|
|
|
|
|
CSS_BASE = """
|
|
|
|
|
#body { height: 1fr; }
|
|
|
|
|
#fields { width: 62; border: solid $accent; overflow-y: auto; }
|
|
|
|
|
#right { width: 1fr; }
|
|
|
|
|
#plan {
|
|
|
|
|
height: 1fr; border: solid $accent;
|
|
|
|
|
overflow-x: auto; scrollbar-size-horizontal: 1;
|
|
|
|
|
}
|
|
|
|
|
#totals { height: auto; color: $text-muted; padding: 0 1; }
|
|
|
|
|
.grouptitle { color: $accent; text-style: bold; padding: 1 0 0 0; }
|
[UPD] todo : nommer la case d'installation par ce qu'elle commande
« Installer ERPLibre » commandait TOUTE installation, l'hyperviseur Proxmox
VE compris : décochée, une VM Proxmox restait une Debian nue sans que rien
ne l'explique. Elle devient « Installer un logiciel dans la VM », passe sous
le type de VM — juste avant les sections qu'elle commande — et le suivi la
quitte, puisqu'il regarde la VM arriver même quand rien ne s'installe.
Ce qu'elle rend sans effet se grise, titre compris. Trois états et non deux :
sans installation mais AVEC un bureau, le magasin d'applications et les
outils de la phase « avant » servent encore ; seuls ceux qui vivent dans le
dépôt s'éteignent. Griser en bloc aurait menti autant que de tout laisser.
--- EN ---
"Install ERPLibre" commanded EVERY install, the Proxmox VE hypervisor
included: unticked, a Proxmox VM stayed a bare Debian with nothing to explain
it. It becomes "Install software in the VM", moves under the VM type — right
before the sections it commands — and the dashboard leaves it, since that
watches the VM arrive even when nothing is installed.
What it makes ineffective now greys out, title included. Three states, not
two: with no install but WITH a desktop, the application store and the
"before" tools still act; only those living in the repository go dark.
Greying the lot would have lied as much as greying nothing.
Assisted-by: Claude Opus 5
2026-08-24 02:32:09 -04:00
|
|
|
/* Une section que le choix d'installation rend inerte : le titre pâlit avec
|
|
|
|
|
ses champs, pour qu'elle se lise inactive d'un coup d'œil. */
|
|
|
|
|
.grouptitle.off { color: $text-muted; text-style: none; }
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
.freeval { display: none; width: 9; }
|
|
|
|
|
/* « width: auto » sur la CARTE, pas seulement sur la rangée. Un conteneur
|
|
|
|
|
Textual vaut « width: 1fr » par défaut : la carte se bornait donc au panneau,
|
|
|
|
|
et c'est ELLE que « #plan » mesure pour sa largeur virtuelle. La rangée avait
|
|
|
|
|
beau être en « auto », son débordement était coupé dans une carte qui ne
|
|
|
|
|
grandissait pas, et la barre horizontale n'apparaissait jamais. */
|
|
|
|
|
.vmcard {
|
|
|
|
|
height: auto; width: auto;
|
|
|
|
|
border-bottom: solid $panel; padding: 0 1;
|
|
|
|
|
}
|
|
|
|
|
/* Une VM figée se voit à la LIGNE, pas à une case perdue au bout : c'est ce
|
|
|
|
|
qui permet de balayer le plan et de savoir d'un coup ce qui échappe au
|
|
|
|
|
profil. */
|
|
|
|
|
.vmcard.locked { background: $success 20%; }
|
|
|
|
|
.vmlock { width: 5; min-width: 5; }
|
|
|
|
|
.vmcopy { width: 5; min-width: 5; }
|
|
|
|
|
.vmhead { height: 1; }
|
|
|
|
|
/* « width: auto » et le défilement du plan : sans eux, une rangée plus large
|
|
|
|
|
que le panneau est COUPÉE au lieu d'être atteignable. */
|
|
|
|
|
.vmrow { height: 3; width: auto; align-vertical: middle; }
|
|
|
|
|
.vmrow Select { width: 15; }
|
|
|
|
|
.vmrow Input { width: 11; }
|
|
|
|
|
#reslabel { color: $text-muted; }
|
|
|
|
|
PreviewScreen { align: center middle; }
|
|
|
|
|
#prevbox {
|
|
|
|
|
width: 90%; height: 70%; padding: 1 2;
|
|
|
|
|
border: thick $accent; background: $surface;
|
|
|
|
|
}
|
|
|
|
|
#prevtitle { height: 1; color: $accent; text-style: bold; }
|
|
|
|
|
#prevbody { height: 1fr; overflow-y: auto; }
|
[IMP] qemu deploy : pré-configurer la VM, et dire ce qui s'y installe
L'option des outils d'assistance posait trois installateurs amont, puis
laissait tout à retaper : hook global de rtk, zdiff3, hooks git du dépôt,
commandes Claude, activation du venv. Elle les pose, en deux temps parce que
hooks et gabarits VIVENT dans le dépôt ; le complément suit le clone, rend
toujours 0 — un confort ne fait pas échouer une VM — et sans clone la moitié
manquante est nommée. core.editor n'est posé qu'à défaut, l'hôte transmettant
déjà le sien. L'aide « ? » et F1 dit ce que chaque option installe, là où un
libellé de case porte trois des huit poses. Vérifié : 51 tests, les deux
écrans montés sans terminal, « bash -n » sur les fragments distants.
--- EN ---
The AI tools box posed three upstream installers, then left every setting to
retype: rtk's global hook, zdiff3, the checkout's git hooks, the Claude
commands, activating the venv. It poses them, in two phases because hooks and
templates LIVE in the checkout; the complement follows the clone, always
returns 0 — a comfort must not fail a VM — and with no clone the missing half
is named. core.editor is posed only where there is none, the host already
transmitting its own. The `?` and F1 help says what each option installs,
where a checkbox label carries three of this one's eight poses. Checked: 51
tests, both screens mounted headless, `bash -n` on the remote fragments.
Assisted-by: Claude Opus 5
2026-09-04 02:03:52 -04:00
|
|
|
HelpScreen { align: center middle; }
|
|
|
|
|
#helpbox {
|
|
|
|
|
width: 84%; height: 80%; padding: 1 2;
|
|
|
|
|
border: thick $accent; background: $surface;
|
|
|
|
|
}
|
|
|
|
|
#helptitle { height: 1; color: $accent; text-style: bold; }
|
|
|
|
|
#helpbody { height: 1fr; }
|
|
|
|
|
.helphead { color: $accent; text-style: bold; }
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
RenameScreen { align: center middle; }
|
|
|
|
|
#renbox {
|
|
|
|
|
width: 60; height: auto; padding: 1 2;
|
|
|
|
|
border: thick $accent; background: $surface;
|
|
|
|
|
}
|
|
|
|
|
#rentitle { color: $accent; text-style: bold; }
|
|
|
|
|
#renhint { color: $text-muted; }
|
|
|
|
|
#renbtns { height: auto; padding-top: 1; }
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def res_choices(presets, fmt=None):
|
|
|
|
|
"""[(libellé, valeur)] d'une liste de préréglages, « autre… » en dernier.
|
|
|
|
|
|
|
|
|
|
Le dernier choix est la sentinelle FREE : c'est lui qui révèle la saisie
|
|
|
|
|
libre. Les deux formulaires l'utilisent, donc « autre… » se présente et se
|
|
|
|
|
comporte pareil partout.
|
|
|
|
|
"""
|
|
|
|
|
faire = fmt or (lambda v: str(v))
|
|
|
|
|
return [(faire(v), str(v)) for v in presets] + [(t("other…"), FREE)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def res_value(choix, libre, defaut, lecteur=None):
|
|
|
|
|
"""Valeur retenue d'un couple (liste, saisie libre).
|
|
|
|
|
|
|
|
|
|
« autre… » sans rien taper ne veut pas dire zéro : il veut dire « laisse
|
|
|
|
|
comme avant ». Ce repli est la raison d'être de cette fonction — sans lui,
|
|
|
|
|
valider un formulaire à peine ouvert rétrécissait les machines.
|
|
|
|
|
"""
|
|
|
|
|
lire = lecteur or (lambda v: positive_int(v, 0))
|
|
|
|
|
if choix == FREE or choix is None:
|
|
|
|
|
return lire(libre) or defaut
|
|
|
|
|
return lire(choix) or defaut
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Les trois ressources que TOUT déploiement demande, et les deux widgets par
|
|
|
|
|
# lesquels chacune se règle : une liste de préréglages (« #f_ram ») et la
|
|
|
|
|
# saisie libre qu'elle révèle (« #c_ram »). Une seule table, pour que le
|
|
|
|
|
# lecteur d'un formulaire trouve les champs de l'autre au même endroit.
|
|
|
|
|
RES_FIELDS = {
|
|
|
|
|
"vcpus": ("#f_vcpus", "#c_vcpus"),
|
|
|
|
|
"ram": ("#f_ram", "#c_ram"),
|
|
|
|
|
"disk": ("#f_disk", "#c_disk"),
|
|
|
|
|
}
|
|
|
|
|
SELECT_TO_FIELD = {sel[1:]: f for f, (sel, _i) in RES_FIELDS.items()}
|
|
|
|
|
INPUT_TO_FIELD = {inp[1:]: f for f, (_s, inp) in RES_FIELDS.items()}
|
|
|
|
|
|
|
|
|
|
# La mémoire s'affiche en Go alors qu'elle se compte en Mo : c'est la seule
|
|
|
|
|
# ressource dont la valeur ne se lit pas telle quelle.
|
|
|
|
|
RES_FMT = {
|
|
|
|
|
"vcpus": lambda v: str(v),
|
|
|
|
|
"ram": lambda v: f"{v // 1024}G",
|
|
|
|
|
"disk": lambda v: str(v),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def res_labels():
|
|
|
|
|
"""Libellés des trois ressources, traduits à l'APPEL.
|
|
|
|
|
|
|
|
|
|
Pas une constante de module : la langue est choisie après l'import, et une
|
|
|
|
|
table figée à l'import resterait en anglais.
|
|
|
|
|
"""
|
|
|
|
|
return {
|
|
|
|
|
"vcpus": t("vCPU"),
|
|
|
|
|
"ram": t("RAM: 2048 or 8G"),
|
|
|
|
|
"disk": t("Disk"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def res_row_widgets(index, vm, presets, labels=None, null=None):
|
|
|
|
|
"""Les six widgets du triplet vCPU / RAM / disque d'une rangée de plan.
|
|
|
|
|
|
|
|
|
|
Chaque ressource se présente pareil : une liste de préréglages, plus une
|
|
|
|
|
saisie libre masquée que « valeur libre… » révèle. Les deux formulaires
|
|
|
|
|
appellent cette fabrique, donc les ids se correspondent d'un formulaire à
|
|
|
|
|
l'autre — « v3_ram » pour la liste de la quatrième rangée, « c3_ram » pour
|
|
|
|
|
sa saisie libre — et le code qui relit les surcharges n'a pas à savoir
|
|
|
|
|
quel formulaire a monté la rangée.
|
|
|
|
|
|
|
|
|
|
`presets` donne les choix par champ ; `labels` permet à l'appelant de
|
|
|
|
|
garder ses propres mots. `null` est la sentinelle « rien de sélectionné »
|
|
|
|
|
de Textual, que l'appelant a déjà résolue selon sa version.
|
|
|
|
|
"""
|
|
|
|
|
from textual.widgets import Input, Select
|
|
|
|
|
|
|
|
|
|
if null is None: # pragma: no cover - dépend de la version de Textual
|
|
|
|
|
null = getattr(Select, "NULL", Select.BLANK)
|
|
|
|
|
mots = res_labels()
|
|
|
|
|
mots.update(labels or {})
|
|
|
|
|
widgets = []
|
|
|
|
|
for champ in ("vcpus", "ram", "disk"):
|
|
|
|
|
choix = presets[champ]
|
|
|
|
|
valeur = vm[champ]
|
|
|
|
|
# « valeur dans les préréglages » décide de TOUT : la liste montre la
|
|
|
|
|
# valeur, ou elle se met en retrait et la saisie libre la porte.
|
|
|
|
|
connue = valeur in choix
|
|
|
|
|
widgets.append(
|
|
|
|
|
Select(
|
|
|
|
|
[(RES_FMT[champ](c), c) for c in choix]
|
|
|
|
|
+ [(t("free value…"), FREE)],
|
|
|
|
|
value=valeur if connue else null,
|
|
|
|
|
prompt=mots[champ],
|
|
|
|
|
id=f"v{index}_{champ}",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
widgets.append(
|
|
|
|
|
Input(
|
|
|
|
|
value="" if connue else str(valeur),
|
|
|
|
|
placeholder=mots[champ],
|
|
|
|
|
id=f"c{index}_{champ}",
|
|
|
|
|
classes="freeval",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return widgets
|
|
|
|
|
|
|
|
|
|
|
[FIX] proxmox : viser la bonne machine, et dire la vérité sur le disque
« s » ouvrait encore la VM locale homonyme : la vue de progression n'avait
que le NOM de la VM, et l'entrée ~/.ssh/config n'existe pas encore à ce
moment. Le déploiement lui passe maintenant « ssh -J <hôte> user@<ip> », qui
ne dépend de rien. Deux voisines du même défaut, jamais rapportées mais aussi
graves : la console ouvrait « virsh console <nom> » — celle de la VM LOCALE —
et la pause suspendait la locale. Les deux passent par le VMID sur l'hôte.
La colonne Disque annonçait « 6.0G/6.0G » sur une VM qui n'avait écrit que
1,2 Go : « du -sb » rend la taille APPARENTE, et un disque raw creux la donne
entière. « du -sB1 » compte les blocs. Enfin l'écran de déploiement dit ce qui
l'attend : « Quitter (q) pour lancer l'installation d'ERPLibre » — on
attendait devant une fenêtre terminée sans le savoir.
--- EN ---
"s" still opened the homonymous local VM: the progress view only had the VM's
NAME, and the ~/.ssh/config entry does not exist yet at that point. The
deployment now hands it "ssh -J <host> user@<ip>", which depends on nothing.
Two neighbours of the same defect, never reported but just as serious: the
console opened "virsh console <name>" — the LOCAL VM's — and pause suspended
the local one. Both now go through the VMID on the host.
The Disk column claimed "6.0G/6.0G" on a VM that had written 1.2 GB: "du -sb"
returns the APPARENT size, and a sparse raw disk gives it in full. "du -sB1"
counts blocks. Finally the deployment screen says what awaits it: "Quit (q)
to start the ERPLibre install" — one waited before a finished window without
knowing.
Assisted-by: Claude Opus 5
2026-08-24 06:58:02 -04:00
|
|
|
def run_deploy_progress(
|
|
|
|
|
jobs, parallelism, run_app: bool = True, ssh_cmds=None, suite=""
|
|
|
|
|
):
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
"""Déploie `jobs` = [(id, nom, argv)] en parallèle, un bloc repliable par
|
|
|
|
|
VM. Renvoie [(nom, rc, sortie, durée)]. `run_app=False` renvoie l'app.
|
|
|
|
|
|
|
|
|
|
Un bloc reste DÉPLIÉ tant que la VM tourne, se replie dès qu'elle réussit
|
|
|
|
|
— et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire."""
|
2026-08-24 05:38:28 -04:00
|
|
|
import shlex
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
import subprocess
|
2026-08-24 05:38:28 -04:00
|
|
|
|
|
|
|
|
shlex_quote = shlex.quote
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
from textual.app import App, ComposeResult
|
|
|
|
|
from textual.containers import Vertical, VerticalScroll
|
|
|
|
|
from textual.widgets import (
|
|
|
|
|
Button,
|
|
|
|
|
Collapsible,
|
|
|
|
|
Footer,
|
|
|
|
|
Header,
|
|
|
|
|
RichLog,
|
|
|
|
|
Static,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
results = []
|
|
|
|
|
|
|
|
|
|
def slug(name):
|
|
|
|
|
"""Identifiant de widget : Textual n'accepte ni point ni tiret en
|
|
|
|
|
tête, et les noms de VM en contiennent."""
|
|
|
|
|
return "vm_" + "".join(c if c.isalnum() else "_" for c in name)
|
|
|
|
|
|
|
|
|
|
class Progress(App):
|
|
|
|
|
CSS = """
|
|
|
|
|
#blocks { height: 1fr; }
|
|
|
|
|
RichLog { height: 14; border: solid $panel; }
|
|
|
|
|
#summary { height: auto; color: $accent; padding: 0 1; }
|
|
|
|
|
#hint { height: auto; color: $text-muted; padding: 0 1; }
|
|
|
|
|
"""
|
|
|
|
|
BINDINGS = [
|
2026-08-24 05:38:28 -04:00
|
|
|
# « s » comme dans le tableau de bord : une VM qui vient d'être
|
|
|
|
|
# créée est souvent joignable tout de suite, et on veut y entrer
|
|
|
|
|
# sans quitter l'écran. Rapporté comme manquant.
|
|
|
|
|
("s", "ssh", t("SSH")),
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
("c", "copy_current", t("Copy log")),
|
|
|
|
|
("C", "copy_all", t("Copy all logs")),
|
|
|
|
|
("q", "quit", t("Quit")),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self._out = {name: "" for _jid, name, _p in jobs}
|
2026-08-24 05:38:28 -04:00
|
|
|
# Ce qui a DÉJÀ été écrit à l'écran, pour ne pas le doubler.
|
|
|
|
|
self._ecrites = {}
|
|
|
|
|
# Les VM créées sans erreur : celles où « s » peut entrer.
|
|
|
|
|
self._reussies = []
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
self._done = 0
|
|
|
|
|
self._t0 = time.time()
|
|
|
|
|
self._slots = threading.Semaphore(max(1, parallelism))
|
|
|
|
|
|
|
|
|
|
def compose(self) -> ComposeResult:
|
|
|
|
|
yield Header()
|
|
|
|
|
with VerticalScroll(id="blocks"):
|
|
|
|
|
for jid, name, _parts in jobs:
|
|
|
|
|
with Collapsible(
|
|
|
|
|
title=f"⏳ [{jid}] {name}",
|
|
|
|
|
collapsed=False,
|
|
|
|
|
id=slug(name),
|
|
|
|
|
):
|
|
|
|
|
yield RichLog(
|
|
|
|
|
id=f"log_{slug(name)}",
|
|
|
|
|
highlight=False,
|
|
|
|
|
markup=False,
|
|
|
|
|
wrap=True,
|
|
|
|
|
)
|
|
|
|
|
with Vertical():
|
|
|
|
|
yield Static("", id="summary")
|
|
|
|
|
yield Static(
|
2026-08-24 05:38:28 -04:00
|
|
|
f" {t('s ssh · c copy log · C copy all · q quit')}",
|
|
|
|
|
id="hint",
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
)
|
|
|
|
|
yield Button(t("Copy all logs"), id="copyall")
|
|
|
|
|
yield Footer()
|
|
|
|
|
|
|
|
|
|
def on_mount(self) -> None:
|
|
|
|
|
self.title = t("Deploying")
|
|
|
|
|
self._refresh_summary()
|
|
|
|
|
for jid, name, parts in jobs:
|
|
|
|
|
self.run_job(jid, name, parts)
|
|
|
|
|
|
|
|
|
|
def _refresh_summary(self):
|
[FIX] proxmox : viser la bonne machine, et dire la vérité sur le disque
« s » ouvrait encore la VM locale homonyme : la vue de progression n'avait
que le NOM de la VM, et l'entrée ~/.ssh/config n'existe pas encore à ce
moment. Le déploiement lui passe maintenant « ssh -J <hôte> user@<ip> », qui
ne dépend de rien. Deux voisines du même défaut, jamais rapportées mais aussi
graves : la console ouvrait « virsh console <nom> » — celle de la VM LOCALE —
et la pause suspendait la locale. Les deux passent par le VMID sur l'hôte.
La colonne Disque annonçait « 6.0G/6.0G » sur une VM qui n'avait écrit que
1,2 Go : « du -sb » rend la taille APPARENTE, et un disque raw creux la donne
entière. « du -sB1 » compte les blocs. Enfin l'écran de déploiement dit ce qui
l'attend : « Quitter (q) pour lancer l'installation d'ERPLibre » — on
attendait devant une fenêtre terminée sans le savoir.
--- EN ---
"s" still opened the homonymous local VM: the progress view only had the VM's
NAME, and the ~/.ssh/config entry does not exist yet at that point. The
deployment now hands it "ssh -J <host> user@<ip>", which depends on nothing.
Two neighbours of the same defect, never reported but just as serious: the
console opened "virsh console <name>" — the LOCAL VM's — and pause suspended
the local one. Both now go through the VMID on the host.
The Disk column claimed "6.0G/6.0G" on a VM that had written 1.2 GB: "du -sb"
returns the APPARENT size, and a sparse raw disk gives it in full. "du -sB1"
counts blocks. Finally the deployment screen says what awaits it: "Quit (q)
to start the ERPLibre install" — one waited before a finished window without
knowing.
Assisted-by: Claude Opus 5
2026-08-24 06:58:02 -04:00
|
|
|
# CE QUI SUIT, dit ici : l'installation d'ERPLibre ne démarre
|
|
|
|
|
# qu'au moment où l'on quitte cet écran, et rien ne le disait —
|
|
|
|
|
# on attendait devant une fenêtre pourtant terminée.
|
|
|
|
|
fini = self._done >= len(jobs)
|
|
|
|
|
apres = f"\n → {suite}" if (fini and suite) else ""
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
self.query_one("#summary", Static).update(
|
|
|
|
|
f" {self._done}/{len(jobs)} — "
|
[FIX] proxmox : viser la bonne machine, et dire la vérité sur le disque
« s » ouvrait encore la VM locale homonyme : la vue de progression n'avait
que le NOM de la VM, et l'entrée ~/.ssh/config n'existe pas encore à ce
moment. Le déploiement lui passe maintenant « ssh -J <hôte> user@<ip> », qui
ne dépend de rien. Deux voisines du même défaut, jamais rapportées mais aussi
graves : la console ouvrait « virsh console <nom> » — celle de la VM LOCALE —
et la pause suspendait la locale. Les deux passent par le VMID sur l'hôte.
La colonne Disque annonçait « 6.0G/6.0G » sur une VM qui n'avait écrit que
1,2 Go : « du -sb » rend la taille APPARENTE, et un disque raw creux la donne
entière. « du -sB1 » compte les blocs. Enfin l'écran de déploiement dit ce qui
l'attend : « Quitter (q) pour lancer l'installation d'ERPLibre » — on
attendait devant une fenêtre terminée sans le savoir.
--- EN ---
"s" still opened the homonymous local VM: the progress view only had the VM's
NAME, and the ~/.ssh/config entry does not exist yet at that point. The
deployment now hands it "ssh -J <host> user@<ip>", which depends on nothing.
Two neighbours of the same defect, never reported but just as serious: the
console opened "virsh console <name>" — the LOCAL VM's — and pause suspended
the local one. Both now go through the VMID on the host.
The Disk column claimed "6.0G/6.0G" on a VM that had written 1.2 GB: "du -sb"
returns the APPARENT size, and a sparse raw disk gives it in full. "du -sB1"
counts blocks. Finally the deployment screen says what awaits it: "Quit (q)
to start the ERPLibre install" — one waited before a finished window without
knowing.
Assisted-by: Claude Opus 5
2026-08-24 06:58:02 -04:00
|
|
|
f"{fmt_dur(time.time() - self._t0)}{apres}"
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# `thread=True` : subprocess.run est bloquant ; le faire dans un
|
|
|
|
|
# thread garde la boucle d'événements Textual fluide. Le sémaphore
|
|
|
|
|
# borne les déploiements SIMULTANÉS — sans lui, demander « 4 en
|
|
|
|
|
# parallèle » en lancerait autant que de VM.
|
|
|
|
|
def run_job(self, jid, name, parts):
|
|
|
|
|
def _job() -> None:
|
|
|
|
|
with self._slots:
|
|
|
|
|
t0 = time.time()
|
2026-08-24 05:38:28 -04:00
|
|
|
lignes = []
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
try:
|
2026-08-24 05:38:28 -04:00
|
|
|
# « Popen » et LECTURE LIGNE À LIGNE, pas
|
|
|
|
|
# « subprocess.run » : celui-ci ne rend la sortie
|
|
|
|
|
# qu'à la fin du travail, et le bloc restait vide
|
|
|
|
|
# pendant tout le déploiement — rapporté. Sur Proxmox,
|
|
|
|
|
# une VM demande le téléchargement d'une image de
|
|
|
|
|
# 325 Mio puis l'import du disque : plusieurs minutes
|
|
|
|
|
# de silence, et rien à l'écran.
|
|
|
|
|
proc = subprocess.Popen(
|
|
|
|
|
parts,
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
text=True,
|
|
|
|
|
bufsize=1,
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
)
|
|
|
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
2026-08-24 05:38:28 -04:00
|
|
|
self.call_from_thread(
|
|
|
|
|
self._finish,
|
|
|
|
|
jid,
|
|
|
|
|
name,
|
|
|
|
|
1,
|
|
|
|
|
str(exc),
|
|
|
|
|
time.time() - t0,
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
for ligne in proc.stdout:
|
|
|
|
|
ligne = ligne.rstrip("\n")
|
|
|
|
|
lignes.append(ligne)
|
|
|
|
|
self.call_from_thread(self._ecrire, name, ligne)
|
|
|
|
|
rc = proc.wait()
|
|
|
|
|
out = "\n".join(lignes)
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
self.call_from_thread(
|
|
|
|
|
self._finish, jid, name, rc, out, time.time() - t0
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.run_worker(_job, thread=True, group="deploy", exclusive=False)
|
|
|
|
|
|
2026-08-24 05:38:28 -04:00
|
|
|
def _ecrire(self, name, ligne):
|
|
|
|
|
"""Une ligne, dès qu'elle arrive. Appelée depuis le fil."""
|
|
|
|
|
self._ecrites[name] = True
|
|
|
|
|
try:
|
|
|
|
|
self.query_one(f"#log_{slug(name)}", RichLog).write(ligne)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
def _finish(self, jid, name, rc, out, secs):
|
|
|
|
|
self._out[name] = out
|
|
|
|
|
results.append((name, rc, out, secs))
|
|
|
|
|
self._done += 1
|
2026-08-24 05:38:28 -04:00
|
|
|
if rc == 0:
|
|
|
|
|
self._reussies.append(name)
|
|
|
|
|
# La sortie est déjà à l'écran, ligne par ligne : la réécrire
|
|
|
|
|
# la doublerait. Seul un échec avant même le démarrage du
|
|
|
|
|
# processus n'a rien écrit.
|
|
|
|
|
if not self._ecrites.get(name):
|
|
|
|
|
log = self.query_one(f"#log_{slug(name)}", RichLog)
|
|
|
|
|
for line in out.strip().splitlines():
|
|
|
|
|
log.write(line)
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
block = self.query_one(f"#{slug(name)}", Collapsible)
|
|
|
|
|
mark = "✅" if rc == 0 else "❌"
|
|
|
|
|
block.title = f"{mark} [{jid}] {name} · {fmt_dur(secs)}" + (
|
|
|
|
|
"" if rc == 0 else f" · rc={rc}"
|
|
|
|
|
)
|
|
|
|
|
# Un succès se replie (il n'y a plus rien à y lire) ; un échec
|
|
|
|
|
# reste ouvert.
|
|
|
|
|
block.collapsed = rc == 0
|
|
|
|
|
self._refresh_summary()
|
|
|
|
|
|
2026-08-24 05:38:28 -04:00
|
|
|
def action_ssh(self) -> None:
|
|
|
|
|
"""Entre dans la VM créée, sans quitter l'écran.
|
|
|
|
|
|
[FIX] proxmox : viser la bonne machine, et dire la vérité sur le disque
« s » ouvrait encore la VM locale homonyme : la vue de progression n'avait
que le NOM de la VM, et l'entrée ~/.ssh/config n'existe pas encore à ce
moment. Le déploiement lui passe maintenant « ssh -J <hôte> user@<ip> », qui
ne dépend de rien. Deux voisines du même défaut, jamais rapportées mais aussi
graves : la console ouvrait « virsh console <nom> » — celle de la VM LOCALE —
et la pause suspendait la locale. Les deux passent par le VMID sur l'hôte.
La colonne Disque annonçait « 6.0G/6.0G » sur une VM qui n'avait écrit que
1,2 Go : « du -sb » rend la taille APPARENTE, et un disque raw creux la donne
entière. « du -sB1 » compte les blocs. Enfin l'écran de déploiement dit ce qui
l'attend : « Quitter (q) pour lancer l'installation d'ERPLibre » — on
attendait devant une fenêtre terminée sans le savoir.
--- EN ---
"s" still opened the homonymous local VM: the progress view only had the VM's
NAME, and the ~/.ssh/config entry does not exist yet at that point. The
deployment now hands it "ssh -J <host> user@<ip>", which depends on nothing.
Two neighbours of the same defect, never reported but just as serious: the
console opened "virsh console <name>" — the LOCAL VM's — and pause suspended
the local one. Both now go through the VMID on the host.
The Disk column claimed "6.0G/6.0G" on a VM that had written 1.2 GB: "du -sb"
returns the APPARENT size, and a sparse raw disk gives it in full. "du -sB1"
counts blocks. Finally the deployment screen says what awaits it: "Quit (q)
to start the ERPLibre install" — one waited before a finished window without
knowing.
Assisted-by: Claude Opus 5
2026-08-24 06:58:02 -04:00
|
|
|
La commande vient de l'APPELANT quand il sait joindre la VM : sur
|
|
|
|
|
Proxmox, l'entrée ~/.ssh/config n'est écrite qu'après le
|
|
|
|
|
déploiement, et le nom de la VM peut désigner une machine LOCALE
|
|
|
|
|
homonyme — « s » ouvrait alors la mauvaise. Le déploiement passe
|
|
|
|
|
donc « ssh -J <hôte> user@<ip> », qui ne dépend de rien.
|
2026-08-24 05:38:28 -04:00
|
|
|
|
|
|
|
|
« suspend() » rend le terminal : ssh a besoin du clavier, et
|
|
|
|
|
Textual le tient encore.
|
|
|
|
|
"""
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
if not self._reussies:
|
|
|
|
|
self.notify(t("No VM created yet."), severity="warning")
|
|
|
|
|
return
|
|
|
|
|
# La dernière créée : c'est celle qu'on regarde.
|
|
|
|
|
nom = self._reussies[-1]
|
[FIX] proxmox : viser la bonne machine, et dire la vérité sur le disque
« s » ouvrait encore la VM locale homonyme : la vue de progression n'avait
que le NOM de la VM, et l'entrée ~/.ssh/config n'existe pas encore à ce
moment. Le déploiement lui passe maintenant « ssh -J <hôte> user@<ip> », qui
ne dépend de rien. Deux voisines du même défaut, jamais rapportées mais aussi
graves : la console ouvrait « virsh console <nom> » — celle de la VM LOCALE —
et la pause suspendait la locale. Les deux passent par le VMID sur l'hôte.
La colonne Disque annonçait « 6.0G/6.0G » sur une VM qui n'avait écrit que
1,2 Go : « du -sb » rend la taille APPARENTE, et un disque raw creux la donne
entière. « du -sB1 » compte les blocs. Enfin l'écran de déploiement dit ce qui
l'attend : « Quitter (q) pour lancer l'installation d'ERPLibre » — on
attendait devant une fenêtre terminée sans le savoir.
--- EN ---
"s" still opened the homonymous local VM: the progress view only had the VM's
NAME, and the ~/.ssh/config entry does not exist yet at that point. The
deployment now hands it "ssh -J <host> user@<ip>", which depends on nothing.
Two neighbours of the same defect, never reported but just as serious: the
console opened "virsh console <name>" — the LOCAL VM's — and pause suspended
the local one. Both now go through the VMID on the host.
The Disk column claimed "6.0G/6.0G" on a VM that had written 1.2 GB: "du -sb"
returns the APPARENT size, and a sparse raw disk gives it in full. "du -sB1"
counts blocks. Finally the deployment screen says what awaits it: "Quit (q)
to start the ERPLibre install" — one waited before a finished window without
knowing.
Assisted-by: Claude Opus 5
2026-08-24 06:58:02 -04:00
|
|
|
cmd = (ssh_cmds or {}).get(nom) or f"ssh {shlex_quote(nom)}"
|
2026-08-24 05:38:28 -04:00
|
|
|
with self.suspend():
|
[FIX] proxmox : viser la bonne machine, et dire la vérité sur le disque
« s » ouvrait encore la VM locale homonyme : la vue de progression n'avait
que le NOM de la VM, et l'entrée ~/.ssh/config n'existe pas encore à ce
moment. Le déploiement lui passe maintenant « ssh -J <hôte> user@<ip> », qui
ne dépend de rien. Deux voisines du même défaut, jamais rapportées mais aussi
graves : la console ouvrait « virsh console <nom> » — celle de la VM LOCALE —
et la pause suspendait la locale. Les deux passent par le VMID sur l'hôte.
La colonne Disque annonçait « 6.0G/6.0G » sur une VM qui n'avait écrit que
1,2 Go : « du -sb » rend la taille APPARENTE, et un disque raw creux la donne
entière. « du -sB1 » compte les blocs. Enfin l'écran de déploiement dit ce qui
l'attend : « Quitter (q) pour lancer l'installation d'ERPLibre » — on
attendait devant une fenêtre terminée sans le savoir.
--- EN ---
"s" still opened the homonymous local VM: the progress view only had the VM's
NAME, and the ~/.ssh/config entry does not exist yet at that point. The
deployment now hands it "ssh -J <host> user@<ip>", which depends on nothing.
Two neighbours of the same defect, never reported but just as serious: the
console opened "virsh console <name>" — the LOCAL VM's — and pause suspended
the local one. Both now go through the VMID on the host.
The Disk column claimed "6.0G/6.0G" on a VM that had written 1.2 GB: "du -sb"
returns the APPARENT size, and a sparse raw disk gives it in full. "du -sB1"
counts blocks. Finally the deployment screen says what awaits it: "Quit (q)
to start the ERPLibre install" — one waited before a finished window without
knowing.
Assisted-by: Claude Opus 5
2026-08-24 06:58:02 -04:00
|
|
|
print(f"\n→ {cmd}\n")
|
|
|
|
|
os.system(f"{cmd} || true")
|
2026-08-24 05:38:28 -04:00
|
|
|
|
[REF] todo : un fichier par sujet, un socle par formulaire
todo.py passait 13 000 lignes : plus personne n'y trouvait où une chose
vivait. Il en garde 4 400 — les menus et les aides générales — et six
fichiers portent chacun un sujet, assemblés en mixins sur la classe TODO.
Aucun membre perdu : 438 avant, 438 après, et treize sources modifiées
seulement là où un appel de classe devait changer de nom.
Les deux formulaires de déploiement posent le même travail : ils partagent
maintenant la logique pure, le CSS, la fabrique des rangées de ressources
et les gestes du plan (surcharges, verrous, exemplaires, renommage).
Vérifié en rendant le formulaire QEMU avant et après, sur neuf gestes :
même écran au SVG près, même état, même spec.
--- EN ---
todo.py had passed 13,000 lines: nobody could find where anything lived.
It keeps 4,400 — the menus and the general helpers — and six files each
own one subject, assembled as mixins on the TODO class. No member lost:
438 before, 438 after, and thirteen sources changed only where a
class-level call had to change name.
Both deployment forms do the same work: they now share the pure logic, the
CSS, the resource-row factory and the plan's gestures (overrides, locks,
copies, renaming). Verified by rendering the QEMU form before and after
across nine gestures: same screen down to the SVG, same state, same spec.
Assisted-by: Claude Opus 5
2026-08-23 18:01:09 -04:00
|
|
|
# -- presse-papiers (OSC 52 : traverse SSH) --------------------- #
|
|
|
|
|
def _copy(self, text, what):
|
|
|
|
|
payload, cut = clip_payload(text)
|
|
|
|
|
if not payload.strip():
|
|
|
|
|
self.notify(t("Nothing to copy."), severity="warning")
|
|
|
|
|
return
|
|
|
|
|
self.copy_to_clipboard(payload)
|
|
|
|
|
note = f"{what} — {len(payload)} {t('chars')}"
|
|
|
|
|
if cut:
|
|
|
|
|
note += f" ({t('tail only, log was truncated')})"
|
|
|
|
|
self.notify(
|
|
|
|
|
note + "\n" + t("Needs an OSC 52 capable terminal."),
|
|
|
|
|
title=t("Clipboard"),
|
|
|
|
|
timeout=8,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def action_copy_current(self) -> None:
|
|
|
|
|
focused = self.focused
|
|
|
|
|
for _jid, name, _p in jobs:
|
|
|
|
|
node = focused
|
|
|
|
|
while node is not None:
|
|
|
|
|
if getattr(node, "id", None) == slug(name):
|
|
|
|
|
self._copy(self._out[name], name)
|
|
|
|
|
return
|
|
|
|
|
node = node.parent
|
|
|
|
|
self.action_copy_all()
|
|
|
|
|
|
|
|
|
|
def action_copy_all(self) -> None:
|
|
|
|
|
blob = "\n".join(
|
|
|
|
|
f"───── {name} ─────\n{self._out[name]}"
|
|
|
|
|
for _jid, name, _p in jobs
|
|
|
|
|
)
|
|
|
|
|
self._copy(blob, t("all logs"))
|
|
|
|
|
|
|
|
|
|
def on_button_pressed(self, event) -> None:
|
|
|
|
|
if event.button.id == "copyall":
|
|
|
|
|
self.action_copy_all()
|
|
|
|
|
|
|
|
|
|
app = Progress()
|
|
|
|
|
app._results = results # lecture par les tests headless
|
|
|
|
|
if not run_app:
|
|
|
|
|
return app
|
|
|
|
|
app.run()
|
|
|
|
|
return results
|