erplibre/script/todo/qemu_deploy_form.py

2022 lines
86 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Formulaire Textual de déploiement QEMU, et vue de progression.
Deux interfaces mènent au même déploiement : les invites en ligne de
`todo.py` et ce formulaire. Toutes deux produisent la MÊME structure — la
« spec » — que `TODO._qemu_run_spec` consomme. Rien n'est décidé ici qui ne
puisse l'être là-bas, et réciproquement.
- build_vms(...) / plan_rows(...) : logique pure, testable sans terminal.
- run_deploy_form(ctx, run_app=True) : le formulaire ; renvoie une spec ou None.
- run_deploy_progress(jobs, ...) : blocs repliables par VM pendant le
déploiement, avec copie du log vers le presse-papiers (OSC 52).
Le formulaire ne lance AUCUNE commande privilégiée ni réseau : tout appel
`virsh` passe par sudo et une invite de mot de passe casserait l'affichage
Textual. Les données coûteuses (domaines existants, branches distantes) sont
préchargées par l'appelant et arrivent dans `ctx`.
"""
from __future__ import annotations
import os
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
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:
[ADD] tui qemu: deploy several copies of one entry A "+" per row adds a copy of the entry, a "−" removes it. The "−" only appears on a copy: the original cannot be deleted by mistake, it is unticked in the catalogue. The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3. The first keeps the catalogue name, so existing deployments keep theirs. The real work is in identity. entry_key was (distro, version, arch): two copies shared it, so setting the first set the second and their locks fought each other. It now carries the instance number, and overrides as well as locks follow the right machine. Each instance is a COPY of the dictionary, never a shared reference. Removing a copy forgets its settings: keeping them would resurrect old values on the next copy, with nothing to explain it. --- FR --- Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le « − » n'apparaît que sur une copie : l'original ne se supprime pas par mégarde, il se décoche au catalogue. Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le premier garde le nom du catalogue, pour que les déploiements existants gardent le leur. Le vrai travail est dans l'identité. entry_key valait (distro, version, arch) : deux copies la partageaient, donc régler la première réglait la seconde et leurs verrous se marchaient dessus. Elle porte maintenant le numéro d'exemplaire, et surcharges comme verrous suivent la bonne machine. Chaque exemplaire est une COPIE du dictionnaire, jamais une référence partagée. Retirer une copie oublie ses réglages : les garder ferait resurgir d'anciennes valeurs à la copie suivante, sans que rien ne l'explique. Assisted-by: Claude Opus 5
2026-08-13 00:37:45 -04:00
"""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
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
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,
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# 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,
[ADD] tui qemu: an ERPLibre version per VM A branch list per row. The form's branch stays the default; picking another for a VM creates an override, going back clears it -- the VM then follows the form again, which is what one expects when restoring the common choice. It reaches all the way down to execution, like VM type before it. "branch" now takes either a string for the whole fleet or a {name: branch} map, and the remote command is built per machine as soon as branches differ. The ground was ready: launch_installs already knows how to take one command per VM. A VM can therefore carry develop while another validates 1.6.0, in the same deployment and the same monitoring table. One trap: a row's branch fell back to develop on remount, the list being rebuilt from the form rather than from the override it already held. --- FR --- Une liste de branches par rangée. La branche du formulaire reste le défaut ; en choisir une autre pour une VM crée une surcharge, y revenir l'efface — la VM suit alors de nouveau le formulaire, ce qui est le plus attendu quand on remet le choix commun. Elle descend jusqu'à l'exécution, comme le type de VM avant elle. « branch » accepte désormais une chaîne pour tout le parc ou une carte {nom: branche}, et la commande distante est bâtie par machine dès que les branches diffèrent. Le terrain était prêt : launch_installs sait déjà prendre une commande par VM. Une VM peut donc porter develop pendant qu'une autre valide 1.6.0, sur le même déploiement et dans le même tableau de suivi. Un piège : la branche d'une rangée retombait sur develop au remontage, la liste étant rebâtie depuis le formulaire plutôt que depuis la surcharge qu'elle portait déjà. Assisted-by: Claude Opus 5
2026-08-13 00:46:12 -04:00
# Branche ERPLibre. Même raison : « » signifie « celle du
# formulaire », et une surcharge la remplace pour cette VM.
"branch": "",
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
# 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
[ADD] tui qemu: rename a VM with the pencil A "✎" per row opens an input prefilled with the current name. The given name becomes an override like any other: it survives recomputation, and F4 removes it along with the VM's other settings. An explicit name makes automatic naming GIVE WAY. Switching the VM to GNOME no longer appends "-gnome": adding a suffix would amount to correcting the user. Clearing the field restores the automatic name, copy and desktop suffixes included. The name becomes the machine's HOSTNAME, so it is validated against RFC 1123 before being accepted. One capital or one dot too many and cloud-init silently ignores it -- the VM would stay "ubuntu", and you would find out once deployed. Capitals are lowered, the rest is refused with a message. --- FR --- Un « ✎ » par rangée ouvre une saisie préremplie du nom courant. Le nom donné devient une surcharge comme les autres : il survit au recalcul, et F4 le retire avec le reste des réglages de la VM. Un nom explicite fait CÉDER le nommage automatique. Passer la VM en GNOME n'y colle plus « -gnome » : y ajouter un suffixe reviendrait à corriger l'utilisateur. Vider le champ rend le nom automatique, suffixes de copie et de bureau compris. Le nom devient le NOM D'HÔTE de la machine : il est donc validé en RFC 1123 avant d'être retenu. Une majuscule ou un point de trop et cloud-init l'ignore en silence — la VM resterait « ubuntu », et on le découvrirait une fois déployée. Les majuscules sont abaissées, le reste est refusé avec un message. Assisted-by: Claude Opus 5
2026-08-13 00:41:16 -04:00
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
[ADD] qemu: a graphical VM name carries its desktop erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. A graphical VM is recognisable from a "virsh list", and its hostname says so too -- deploy_qemu uses the name as hostname when --hostname is not given. This is not only cosmetic. The name is the COLLISION KEY: a graphical VM and its server twin carried the same one, so the second was reported as "already exists" and silently skipped. They are now distinct. The suffix is applied AFTER overrides, the only point where each machine's type is known now that it is chosen VM by VM. In the form the name therefore follows the choice live, both ways. "mint" rather than "cinnamon": that is the name chosen for the fleet, the installed package still being Cinnamon from the distribution's own repositories. The suffix lives with the flavour, in _QEMU_DESKTOP, and the CLI calls the same function as the TUI rather than writing a second. --- FR --- erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. Une VM graphique se reconnaît d'un « virsh list », et son nom d'hôte le dit aussi — deploy_qemu prend le nom pour hôte quand --hostname n'est pas donné. Ce n'est pas que cosmétique. Le nom sert de CLÉ DE COLLISION : une VM graphique et sa jumelle serveur portaient le même, donc la seconde était signalée « existe déjà » et silencieusement ignorée. Elles se distinguent maintenant. Le suffixe est appliqué APRÈS les surcharges, seul moment où le type de chaque machine est connu depuis qu'il se choisit VM par VM. Dans le formulaire, le nom suit donc le choix en direct, dans les deux sens. « mint » et non « cinnamon » : c'est le nom retenu pour le parc, le paquet installé restant Cinnamon depuis les dépôts de la distribution. Le suffixe vit avec la saveur, dans _QEMU_DESKTOP, et la CLI appelle la même fonction que la TUI plutôt que d'en écrire une seconde. Assisted-by: Claude Opus 5
2026-08-12 03:29:33 -04:00
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}"
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
def build_vms(
[ADD] qemu: a graphical VM name carries its desktop erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. A graphical VM is recognisable from a "virsh list", and its hostname says so too -- deploy_qemu uses the name as hostname when --hostname is not given. This is not only cosmetic. The name is the COLLISION KEY: a graphical VM and its server twin carried the same one, so the second was reported as "already exists" and silently skipped. They are now distinct. The suffix is applied AFTER overrides, the only point where each machine's type is known now that it is chosen VM by VM. In the form the name therefore follows the choice live, both ways. "mint" rather than "cinnamon": that is the name chosen for the fleet, the installed package still being Cinnamon from the distribution's own repositories. The suffix lives with the flavour, in _QEMU_DESKTOP, and the CLI calls the same function as the TUI rather than writing a second. --- FR --- erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. Une VM graphique se reconnaît d'un « virsh list », et son nom d'hôte le dit aussi — deploy_qemu prend le nom pour hôte quand --hostname n'est pas donné. Ce n'est pas que cosmétique. Le nom sert de CLÉ DE COLLISION : une VM graphique et sa jumelle serveur portaient le même, donc la seconde était signalée « existe déjà » et silencieusement ignorée. Elles se distinguent maintenant. Le suffixe est appliqué APRÈS les surcharges, seul moment où le type de chaque machine est connu depuis qu'il se choisit VM par VM. Dans le formulaire, le nom suit donc le choix en direct, dans les deux sens. « mint » et non « cinnamon » : c'est le nom retenu pour le parc, le paquet installé restant Cinnamon depuis les dépôts de la distribution. Le suffixe vit avec la saveur, dans _QEMU_DESKTOP, et la CLI appelle la même fonction que la TUI plutôt que d'en écrire une seconde. Assisted-by: Claude Opus 5
2026-08-12 03:29:33 -04:00
entries,
profile,
base_vcpus,
host_cpu,
custom,
overrides,
desktop="",
suffixes=None,
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
):
"""Catalogue choisi + profil + surcharges -> liste de VM de la spec."""
[ADD] qemu: a graphical VM name carries its desktop erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. A graphical VM is recognisable from a "virsh list", and its hostname says so too -- deploy_qemu uses the name as hostname when --hostname is not given. This is not only cosmetic. The name is the COLLISION KEY: a graphical VM and its server twin carried the same one, so the second was reported as "already exists" and silently skipped. They are now distinct. The suffix is applied AFTER overrides, the only point where each machine's type is known now that it is chosen VM by VM. In the form the name therefore follows the choice live, both ways. "mint" rather than "cinnamon": that is the name chosen for the fleet, the installed package still being Cinnamon from the distribution's own repositories. The suffix lives with the flavour, in _QEMU_DESKTOP, and the CLI calls the same function as the TUI rather than writing a second. --- FR --- erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. Une VM graphique se reconnaît d'un « virsh list », et son nom d'hôte le dit aussi — deploy_qemu prend le nom pour hôte quand --hostname n'est pas donné. Ce n'est pas que cosmétique. Le nom sert de CLÉ DE COLLISION : une VM graphique et sa jumelle serveur portaient le même, donc la seconde était signalée « existe déjà » et silencieusement ignorée. Elles se distinguent maintenant. Le suffixe est appliqué APRÈS les surcharges, seul moment où le type de chaque machine est connu depuis qu'il se choisit VM par VM. Dans le formulaire, le nom suit donc le choix en direct, dans les deux sens. « mint » et non « cinnamon » : c'est le nom retenu pour le parc, le paquet installé restant Cinnamon depuis les dépôts de la distribution. Le suffixe vit avec la saveur, dans _QEMU_DESKTOP, et la CLI appelle la même fonction que la TUI plutôt que d'en écrire une seconde. Assisted-by: Claude Opus 5
2026-08-12 03:29:33 -04:00
vms = apply_overrides(
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
apply_profile(entries, profile, base_vcpus, host_cpu, custom, desktop),
entries,
overrides,
)
[ADD] qemu: a graphical VM name carries its desktop erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. A graphical VM is recognisable from a "virsh list", and its hostname says so too -- deploy_qemu uses the name as hostname when --hostname is not given. This is not only cosmetic. The name is the COLLISION KEY: a graphical VM and its server twin carried the same one, so the second was reported as "already exists" and silently skipped. They are now distinct. The suffix is applied AFTER overrides, the only point where each machine's type is known now that it is chosen VM by VM. In the form the name therefore follows the choice live, both ways. "mint" rather than "cinnamon": that is the name chosen for the fleet, the installed package still being Cinnamon from the distribution's own repositories. The suffix lives with the flavour, in _QEMU_DESKTOP, and the CLI calls the same function as the TUI rather than writing a second. --- FR --- erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. Une VM graphique se reconnaît d'un « virsh list », et son nom d'hôte le dit aussi — deploy_qemu prend le nom pour hôte quand --hostname n'est pas donné. Ce n'est pas que cosmétique. Le nom sert de CLÉ DE COLLISION : une VM graphique et sa jumelle serveur portaient le même, donc la seconde était signalée « existe déjà » et silencieusement ignorée. Elles se distinguent maintenant. Le suffixe est appliqué APRÈS les surcharges, seul moment où le type de chaque machine est connu depuis qu'il se choisit VM par VM. Dans le formulaire, le nom suit donc le choix en direct, dans les deux sens. « mint » et non « cinnamon » : c'est le nom retenu pour le parc, le paquet installé restant Cinnamon depuis les dépôts de la distribution. Le suffixe vit avec la saveur, dans _QEMU_DESKTOP, et la CLI appelle la même fonction que la TUI plutôt que d'en écrire une seconde. Assisted-by: Claude Opus 5
2026-08-12 03:29:33 -04:00
# APRÈS les surcharges : c'est là seulement que le type de chaque VM est
# connu, puisqu'il se choisit machine par machine.
[ADD] tui qemu: rename a VM with the pencil A "✎" per row opens an input prefilled with the current name. The given name becomes an override like any other: it survives recomputation, and F4 removes it along with the VM's other settings. An explicit name makes automatic naming GIVE WAY. Switching the VM to GNOME no longer appends "-gnome": adding a suffix would amount to correcting the user. Clearing the field restores the automatic name, copy and desktop suffixes included. The name becomes the machine's HOSTNAME, so it is validated against RFC 1123 before being accepted. One capital or one dot too many and cloud-init silently ignores it -- the VM would stay "ubuntu", and you would find out once deployed. Capitals are lowered, the rest is refused with a message. --- FR --- Un « ✎ » par rangée ouvre une saisie préremplie du nom courant. Le nom donné devient une surcharge comme les autres : il survit au recalcul, et F4 le retire avec le reste des réglages de la VM. Un nom explicite fait CÉDER le nommage automatique. Passer la VM en GNOME n'y colle plus « -gnome » : y ajouter un suffixe reviendrait à corriger l'utilisateur. Vider le champ rend le nom automatique, suffixes de copie et de bureau compris. Le nom devient le NOM D'HÔTE de la machine : il est donc validé en RFC 1123 avant d'être retenu. Une majuscule ou un point de trop et cloud-init l'ignore en silence — la VM resterait « ubuntu », et on le découvrirait une fois déployée. Les majuscules sont abaissées, le reste est refusé avec un message. Assisted-by: Claude Opus 5
2026-08-13 00:41:16 -04:00
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
[ADD] qemu: a graphical VM name carries its desktop erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. A graphical VM is recognisable from a "virsh list", and its hostname says so too -- deploy_qemu uses the name as hostname when --hostname is not given. This is not only cosmetic. The name is the COLLISION KEY: a graphical VM and its server twin carried the same one, so the second was reported as "already exists" and silently skipped. They are now distinct. The suffix is applied AFTER overrides, the only point where each machine's type is known now that it is chosen VM by VM. In the form the name therefore follows the choice live, both ways. "mint" rather than "cinnamon": that is the name chosen for the fleet, the installed package still being Cinnamon from the distribution's own repositories. The suffix lives with the flavour, in _QEMU_DESKTOP, and the CLI calls the same function as the TUI rather than writing a second. --- FR --- erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. Une VM graphique se reconnaît d'un « virsh list », et son nom d'hôte le dit aussi — deploy_qemu prend le nom pour hôte quand --hostname n'est pas donné. Ce n'est pas que cosmétique. Le nom sert de CLÉ DE COLLISION : une VM graphique et sa jumelle serveur portaient le même, donc la seconde était signalée « existe déjà » et silencieusement ignorée. Elles se distinguent maintenant. Le suffixe est appliqué APRÈS les surcharges, seul moment où le type de chaque machine est connu depuis qu'il se choisit VM par VM. Dans le formulaire, le nom suit donc le choix en direct, dans les deux sens. « mint » et non « cinnamon » : c'est le nom retenu pour le parc, le paquet installé restant Cinnamon depuis les dépôts de la distribution. Le suffixe vit avec la saveur, dans _QEMU_DESKTOP, et la CLI appelle la même fonction que la TUI plutôt que d'en écrire une seconde. Assisted-by: Claude Opus 5
2026-08-12 03:29:33 -04:00
vm["name"] = vm_name(vm["name"], vm.get("desktop"), suffixes)
return vms
def vm_status(name, domains):
"""É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."""
if name in domains:
return "exists", t("exists - skipped")
if os.path.exists(f"/var/lib/libvirt/images/{name}.qcow2"):
return "orphan", t("orphan disk - will FAIL")
return "new", ""
def plan_rows(vms, domains, extra_disk_gb=0):
"""Lignes du tableau du plan : une par VM, avec son état."""
rows = []
for vm in vms:
state, note = vm_status(vm["name"], domains)
rows.append(
{
"vm": vm,
"state": state,
"note": note,
"disk_gb": disk_gb(vm["disk"]) + extra_disk_gb,
}
)
return rows
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", ""),
[UPD] support: drop Ubuntu 20.04/22.04, add AlmaLinux and Rocky Ubuntu 20.04 and 22.04 leave EVERY architecture, not just s390x. pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no g++-10 for s390x at all. Python 3.8, node 10, cargo 0.67 and OpenSSL 1.1.1 each had a workaround; the pile of them did not. 18.04 follows, already off the lists. The refusal lands before any apt, this script also serving existing machines. AlmaLinux 9 and 10, Rocky 9 and 10 join the catalog on all four architectures: the twelve "latest" URLs were opened, with no index to parse unlike Fedora. They would have booted unreachable though -- the cloud-config forced "groups: users, sudo", but the RHEL family has no sudo group, only wheel, and an unknown group makes useradd fail, hence no password and no key. The very trap already known for Debian, repeated elsewhere. Host side, EPEL and CRB are enabled: without them most -devel packages are missing, silently. The server / graphical choice gains Cinnamon, the Linux Mint desktop, from the distribution's own repositories. Mint's repository is set aside: plain HTTP, and i386/amd64 only, which would rule out arm64 and s390x. Along the way, dnf now installs an ENVIRONMENT rather than a group -- "gnome-desktop" brings gdm and gnome-shell but not base-x, hence no X server. --- FR --- Ubuntu 20.04 et 22.04 partent de TOUTES les architectures, pas seulement de s390x. pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie même pas de g++-10 pour s390x. Python 3.8, node 10, cargo 0.67 et OpenSSL 1.1.1 avaient chacun leur contournement ; leur accumulation, non. 18.04 suit, déjà hors des listes. Le refus tombe avant tout apt, ce script servant aussi les machines existantes. AlmaLinux 9 et 10, Rocky 9 et 10 entrent au catalogue, sur les quatre architectures : les douze URL « latest » ont été ouvertes, aucun index à analyser contrairement à Fedora. Elles auraient pourtant démarré inaccessibles — le cloud-config imposait « groups: users, sudo », or la famille RHEL n'a pas de groupe sudo mais wheel, et un groupe inconnu fait échouer useradd, donc ni mot de passe ni clé. C'est le piège déjà connu pour Debian, reproduit ailleurs. Côté hôte, EPEL et CRB sont activés : sans eux la plupart des -devel manquent, en silence. Le choix serveur / graphique gagne Cinnamon, le bureau de Linux Mint, depuis les dépôts de la distribution. Le dépôt de Mint lui-même est écarté : il est en HTTP nu et ne publie que i386 et amd64, ce qui exclurait arm64 et s390x. Au passage, dnf installe désormais un ENVIRONNEMENT et non un groupe — « gnome-desktop » apporte gdm et gnome-shell mais pas base-x, donc pas de serveur X. Assisted-by: Claude Opus 5
2026-08-11 18:46:11 -04:00
"desktop": form.get("desktop", ""),
[ADD] tui qemu: choose mise or pyenv at deploy time The interpreter provider is now chosen when creating a VM, in both interfaces. "mise" installs it in the VM and lays down a precompiled CPython; "pyenv" keeps today's build-from-source. The question is only asked where it means something: mise publishes binaries for amd64 and arm64 only. An all-s390x fleet never sees it, a mixed fleet sees it along with the architectures that will fall back to pyenv -- said before deploying, not discovered in a log an hour later. mise goes into /usr/local/bin rather than ~/.local/bin: the remote command runs through "ssh host 'command'", where neither ~/.profile nor ~/.bashrc is read. Choosing "mise" exports "auto", not "mise", so a failed install can still fall back to pyenv. The unavailability notice was translated as "pyenv required", which said the opposite of what happens: pyenv is the fallback, not a demand. --- FR --- Le fournisseur d'interpréteur se choisit désormais à la création d'une VM, dans les deux interfaces. « mise » l'installe dans la VM et pose un CPython précompilé ; « pyenv » garde la compilation d'aujourd'hui. La question n'est posée que là où elle a un sens : mise ne publie de binaires que pour amd64 et arm64. Un parc tout s390x ne la voit jamais, un parc mixte la voit avec les architectures qui retomberont sur pyenv — dit avant de déployer, pas découvert dans un log une heure plus tard. mise va dans /usr/local/bin plutôt que ~/.local/bin : la commande distante passe par « ssh hôte 'commande' », où ni ~/.profile ni ~/.bashrc ne sont lus. Choisir « mise » exporte « auto », pas « mise », pour qu'une installation ratée puisse encore retomber sur pyenv. L'avis d'indisponibilité était traduit par « pyenv exigé », qui disait l'inverse de ce qui se passe : pyenv est le repli, pas une exigence. Assisted-by: Claude Opus 5
2026-08-11 22:11:23 -04:00
"python_provider": form.get("python_provider", ""),
[ADD] qemu: choose the application store of a graphical VM snap was not a choice but a fate: we disabled snapd, then gnome-core pulled a Firefox snap that froze the install for thirty minutes. The previous fix imposed "deb". Three answers are now offered. deb nothing but .deb, epiphany-browser as the browser. Default, and lightest: nothing extra to download. flatpak Flatpak tooling as well, WITHOUT the Flathub remote or any install. The machine is ready, the admin picks its remotes. snap Ubuntu's default, snapd left running and Firefox as a snap. It is the only mode where snapd is not disabled -- disabling it was precisely the cause of the freeze. The question is only asked when it means something: at least one graphical VM on a distro shipping snapd, Ubuntu alone here. A server pulls no snap, and Debian ships none. The TUI greys the choice out and says why; the CLI does not ask. Verified on a 26.04 VM: snapd is indeed preinstalled (2.75.2), flatpak and its GNOME Software plugin are packaged, and firefox-esr does not exist on Ubuntu. --- FR --- snap n'était plus un choix mais une fatalité : on coupait snapd, puis gnome-core tirait un Firefox-snap qui figeait l'installation trente minutes. Le correctif précédent imposait « deb ». Trois réponses sont maintenant offertes. deb rien que des .deb, epiphany-browser comme navigateur. Défaut, et le plus léger : rien de plus à télécharger. flatpak l'outillage Flatpak en plus, SANS dépôt Flathub ni installation. La machine est prête, l'administrateur choisit ses dépôts. snap le défaut d'Ubuntu, snapd laissé actif et Firefox en snap. C'est le seul mode où snapd n'est pas coupé — l'y couper était précisément la cause du blocage. La question n'est posée que lorsqu'elle a un sens : au moins une VM graphique sur une distribution qui livre snapd, Ubuntu seule ici. Un serveur ne tire aucun snap, et Debian n'en livre pas. La TUI grise le choix et dit pourquoi ; la CLI ne le demande pas. Vérifié sur une VM 26.04 : snapd y est bien préinstallé (2.75.2), flatpak et son greffon GNOME Software sont empaquetés, et firefox-esr n'existe pas sur Ubuntu. Assisted-by: Claude Opus 5
2026-08-12 03:24:19 -04:00
"app_store": form.get("app_store", "deb"),
"install": form["install"],
"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
# --------------------------------------------------------------------------- #
# Formulaire Textual
# --------------------------------------------------------------------------- #
def run_deploy_form(ctx, run_app: bool = True):
"""Formulaire de déploiement. Renvoie une spec, ou None si annulé.
`run_app=False` renvoie l'instance sans la lancer (tests headless)."""
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import (
[ADD] tui qemu: deploy several copies of one entry A "+" per row adds a copy of the entry, a "−" removes it. The "−" only appears on a copy: the original cannot be deleted by mistake, it is unticked in the catalogue. The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3. The first keeps the catalogue name, so existing deployments keep theirs. The real work is in identity. entry_key was (distro, version, arch): two copies shared it, so setting the first set the second and their locks fought each other. It now carries the instance number, and overrides as well as locks follow the right machine. Each instance is a COPY of the dictionary, never a shared reference. Removing a copy forgets its settings: keeping them would resurrect old values on the next copy, with nothing to explain it. --- FR --- Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le « − » n'apparaît que sur une copie : l'original ne se supprime pas par mégarde, il se décoche au catalogue. Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le premier garde le nom du catalogue, pour que les déploiements existants gardent le leur. Le vrai travail est dans l'identité. entry_key valait (distro, version, arch) : deux copies la partageaient, donc régler la première réglait la seconde et leurs verrous se marchaient dessus. Elle porte maintenant le numéro d'exemplaire, et surcharges comme verrous suivent la bonne machine. Chaque exemplaire est une COPIE du dictionnaire, jamais une référence partagée. Retirer une copie oublie ses réglages : les garder ferait resurgir d'anciennes valeurs à la copie suivante, sans que rien ne l'explique. Assisted-by: Claude Opus 5
2026-08-13 00:37:45 -04:00
Button,
Checkbox,
Footer,
Header,
Input,
RadioButton,
RadioSet,
Select,
SelectionList,
Static,
)
# Textual 8 a ramené Select.BLANK à un alias déprécié valant False ; le
# sentinelle « rien de choisi » est Select.NULL. Comparer à BLANK ne
# filtrait donc plus rien, et NoSelection — dépourvu de __bool__, donc
# tenu pour vrai — passait pour une valeur jusque dans les totaux.
SELECT_NULL = getattr(Select, "NULL", Select.BLANK)
catalog = ctx["catalog"]
arches = ctx["arches"]
domains = set(ctx.get("domains") or [])
profiles = ctx.get("install_profiles") or []
branches = ctx.get("branches") or ["master"]
host_cpu = ctx.get("host_cpu") or 2
free_ram = ctx.get("free_ram") or 0
base_vcpus = ctx.get("base_vcpus") or 2
extra_disk = ctx.get("extra_disk_gb") or 0
[ADD] tui qemu: graphical VMs, with a GNOME desktop VMs could only be servers. A server-or-graphical choice joins both interfaces, installing GNOME along with remote access to it. Packages come from the remote command, not cloud-init: their 1 to 2 GB would stretch an already long boot there while leaving no trace in the monitoring, and group installs do not go through it. The desktop therefore does not depend on ERPLibre -- a VM may be wanted graphical and bare. Each distribution has its own names, taken from the source: Arch has no xrdp in its official repositories and takes TigerVNC. The SPICE display is set only on amd64 and arm64; s390x does expose virtio-gpu-ccw, but nothing guarantees its kernel's DRM driver, whereas remote desktop works everywhere. --- FR --- Les VM ne pouvaient être que des serveurs. Un choix serveur ou graphique s'ajoute aux deux interfaces, et pose GNOME avec son accès distant. Les paquets viennent de la commande distante, pas de cloud-init : leurs 1 à 2 Go y allongeraient un démarrage déjà long sans laisser de trace dans le suivi, et les installations par groupe n'y passent pas. Le bureau ne dépend donc pas d'ERPLibre — une VM peut être voulue graphique et nue. Chaque distribution a ses noms, relevés à la source : Arch n'a pas xrdp dans ses dépôts officiels et prend TigerVNC. L'écran virtuel SPICE n'est posé que sur amd64 et arm64 ; s390x expose bien virtio-gpu-ccw, mais rien ne garantit le pilote DRM de son noyau, alors que le bureau distant, lui, marche partout. Assisted-by: Claude Opus 5
2026-08-11 09:49:17 -04:00
desktop_disk = ctx.get("desktop_disk_gb") or 0
[UPD] support: drop Ubuntu 20.04/22.04, add AlmaLinux and Rocky Ubuntu 20.04 and 22.04 leave EVERY architecture, not just s390x. pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no g++-10 for s390x at all. Python 3.8, node 10, cargo 0.67 and OpenSSL 1.1.1 each had a workaround; the pile of them did not. 18.04 follows, already off the lists. The refusal lands before any apt, this script also serving existing machines. AlmaLinux 9 and 10, Rocky 9 and 10 join the catalog on all four architectures: the twelve "latest" URLs were opened, with no index to parse unlike Fedora. They would have booted unreachable though -- the cloud-config forced "groups: users, sudo", but the RHEL family has no sudo group, only wheel, and an unknown group makes useradd fail, hence no password and no key. The very trap already known for Debian, repeated elsewhere. Host side, EPEL and CRB are enabled: without them most -devel packages are missing, silently. The server / graphical choice gains Cinnamon, the Linux Mint desktop, from the distribution's own repositories. Mint's repository is set aside: plain HTTP, and i386/amd64 only, which would rule out arm64 and s390x. Along the way, dnf now installs an ENVIRONMENT rather than a group -- "gnome-desktop" brings gdm and gnome-shell but not base-x, hence no X server. --- FR --- Ubuntu 20.04 et 22.04 partent de TOUTES les architectures, pas seulement de s390x. pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie même pas de g++-10 pour s390x. Python 3.8, node 10, cargo 0.67 et OpenSSL 1.1.1 avaient chacun leur contournement ; leur accumulation, non. 18.04 suit, déjà hors des listes. Le refus tombe avant tout apt, ce script servant aussi les machines existantes. AlmaLinux 9 et 10, Rocky 9 et 10 entrent au catalogue, sur les quatre architectures : les douze URL « latest » ont été ouvertes, aucun index à analyser contrairement à Fedora. Elles auraient pourtant démarré inaccessibles — le cloud-config imposait « groups: users, sudo », or la famille RHEL n'a pas de groupe sudo mais wheel, et un groupe inconnu fait échouer useradd, donc ni mot de passe ni clé. C'est le piège déjà connu pour Debian, reproduit ailleurs. Côté hôte, EPEL et CRB sont activés : sans eux la plupart des -devel manquent, en silence. Le choix serveur / graphique gagne Cinnamon, le bureau de Linux Mint, depuis les dépôts de la distribution. Le dépôt de Mint lui-même est écarté : il est en HTTP nu et ne publie que i386 et amd64, ce qui exclurait arm64 et s390x. Au passage, dnf installe désormais un ENVIRONNEMENT et non un groupe — « gnome-desktop » apporte gdm et gnome-shell mais pas base-x, donc pas de serveur X. Assisted-by: Claude Opus 5
2026-08-11 18:46:11 -04:00
# [(clé, libellé)] — la liste vient de todo.py, source unique.
desktops = list(ctx.get("desktops") or [])
[ADD] qemu: a graphical VM name carries its desktop erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. A graphical VM is recognisable from a "virsh list", and its hostname says so too -- deploy_qemu uses the name as hostname when --hostname is not given. This is not only cosmetic. The name is the COLLISION KEY: a graphical VM and its server twin carried the same one, so the second was reported as "already exists" and silently skipped. They are now distinct. The suffix is applied AFTER overrides, the only point where each machine's type is known now that it is chosen VM by VM. In the form the name therefore follows the choice live, both ways. "mint" rather than "cinnamon": that is the name chosen for the fleet, the installed package still being Cinnamon from the distribution's own repositories. The suffix lives with the flavour, in _QEMU_DESKTOP, and the CLI calls the same function as the TUI rather than writing a second. --- FR --- erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. Une VM graphique se reconnaît d'un « virsh list », et son nom d'hôte le dit aussi — deploy_qemu prend le nom pour hôte quand --hostname n'est pas donné. Ce n'est pas que cosmétique. Le nom sert de CLÉ DE COLLISION : une VM graphique et sa jumelle serveur portaient le même, donc la seconde était signalée « existe déjà » et silencieusement ignorée. Elles se distinguent maintenant. Le suffixe est appliqué APRÈS les surcharges, seul moment où le type de chaque machine est connu depuis qu'il se choisit VM par VM. Dans le formulaire, le nom suit donc le choix en direct, dans les deux sens. « mint » et non « cinnamon » : c'est le nom retenu pour le parc, le paquet installé restant Cinnamon depuis les dépôts de la distribution. Le suffixe vit avec la saveur, dans _QEMU_DESKTOP, et la CLI appelle la même fonction que la TUI plutôt que d'en écrire une seconde. Assisted-by: Claude Opus 5
2026-08-12 03:29:33 -04:00
# {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les
# saveurs — on ne le redéfinit pas ici.
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
[ADD] tui qemu: choose mise or pyenv at deploy time The interpreter provider is now chosen when creating a VM, in both interfaces. "mise" installs it in the VM and lays down a precompiled CPython; "pyenv" keeps today's build-from-source. The question is only asked where it means something: mise publishes binaries for amd64 and arm64 only. An all-s390x fleet never sees it, a mixed fleet sees it along with the architectures that will fall back to pyenv -- said before deploying, not discovered in a log an hour later. mise goes into /usr/local/bin rather than ~/.local/bin: the remote command runs through "ssh host 'command'", where neither ~/.profile nor ~/.bashrc is read. Choosing "mise" exports "auto", not "mise", so a failed install can still fall back to pyenv. The unavailability notice was translated as "pyenv required", which said the opposite of what happens: pyenv is the fallback, not a demand. --- FR --- Le fournisseur d'interpréteur se choisit désormais à la création d'une VM, dans les deux interfaces. « mise » l'installe dans la VM et pose un CPython précompilé ; « pyenv » garde la compilation d'aujourd'hui. La question n'est posée que là où elle a un sens : mise ne publie de binaires que pour amd64 et arm64. Un parc tout s390x ne la voit jamais, un parc mixte la voit avec les architectures qui retomberont sur pyenv — dit avant de déployer, pas découvert dans un log une heure plus tard. mise va dans /usr/local/bin plutôt que ~/.local/bin : la commande distante passe par « ssh hôte 'commande' », où ni ~/.profile ni ~/.bashrc ne sont lus. Choisir « mise » exporte « auto », pas « mise », pour qu'une installation ratée puisse encore retomber sur pyenv. L'avis d'indisponibilité était traduit par « pyenv exigé », qui disait l'inverse de ce qui se passe : pyenv est le repli, pas une exigence. Assisted-by: Claude Opus 5
2026-08-11 22:11:23 -04:00
# Architectures pour lesquelles mise publie un binaire.
mise_arches = set(ctx.get("mise_arches") or ())
[ADD] qemu: choose the application store of a graphical VM snap was not a choice but a fate: we disabled snapd, then gnome-core pulled a Firefox snap that froze the install for thirty minutes. The previous fix imposed "deb". Three answers are now offered. deb nothing but .deb, epiphany-browser as the browser. Default, and lightest: nothing extra to download. flatpak Flatpak tooling as well, WITHOUT the Flathub remote or any install. The machine is ready, the admin picks its remotes. snap Ubuntu's default, snapd left running and Firefox as a snap. It is the only mode where snapd is not disabled -- disabling it was precisely the cause of the freeze. The question is only asked when it means something: at least one graphical VM on a distro shipping snapd, Ubuntu alone here. A server pulls no snap, and Debian ships none. The TUI greys the choice out and says why; the CLI does not ask. Verified on a 26.04 VM: snapd is indeed preinstalled (2.75.2), flatpak and its GNOME Software plugin are packaged, and firefox-esr does not exist on Ubuntu. --- FR --- snap n'était plus un choix mais une fatalité : on coupait snapd, puis gnome-core tirait un Firefox-snap qui figeait l'installation trente minutes. Le correctif précédent imposait « deb ». Trois réponses sont maintenant offertes. deb rien que des .deb, epiphany-browser comme navigateur. Défaut, et le plus léger : rien de plus à télécharger. flatpak l'outillage Flatpak en plus, SANS dépôt Flathub ni installation. La machine est prête, l'administrateur choisit ses dépôts. snap le défaut d'Ubuntu, snapd laissé actif et Firefox en snap. C'est le seul mode où snapd n'est pas coupé — l'y couper était précisément la cause du blocage. La question n'est posée que lorsqu'elle a un sens : au moins une VM graphique sur une distribution qui livre snapd, Ubuntu seule ici. Un serveur ne tire aucun snap, et Debian n'en livre pas. La TUI grise le choix et dit pourquoi ; la CLI ne le demande pas. Vérifié sur une VM 26.04 : snapd y est bien préinstallé (2.75.2), flatpak et son greffon GNOME Software sont empaquetés, et firefox-esr n'existe pas sur Ubuntu. Assisted-by: Claude Opus 5
2026-08-12 03:24:19 -04:00
# [(clé, libellé)] des magasins d'applications, et les distributions qui
# livrent snapd — la question n'a de sens que pour celles-là, graphiques.
app_stores = list(ctx.get("app_stores") or [])
snap_distros = set(ctx.get("snap_distros") or ())
[ADD] tui qemu: pick the time zone from a list The timezone was typed by hand. A misspelt IANA name is not rejected by cloud-init: it is IGNORED. The VM stays on UTC, and you only notice from the timestamps, once deployed. A list of twenty-five zones, Québec first, then the rest of Canada and the places one actually meets. The host's zone goes to the top, without duplication: a machine outside this list must still see its own at a glance. NAMES, not offsets: "UTC-5" says nothing about daylight saving and cloud-init will not take it. A name carries its own switching rules. "free value…" keeps the door open to the other six hundred zones in the database. The choice is copied into the field, which stays the only value the spec reads -- one place holds the answer. --- FR --- Le fuseau se tapait à la main. Un nom IANA mal orthographié n'est pas refusé par cloud-init : il est IGNORÉ. La VM reste en UTC, et on ne s'en aperçoit qu'aux horodatages, une fois déployée. Une liste de vingt-cinq fuseaux, le Québec d'abord, puis le reste du Canada et les places qu'on rencontre en pratique. Le fuseau de l'hôte passe en tête, sans doublon : une machine hors de cette liste doit voir le sien en un coup d'œil. Des NOMS, pas des décalages : « UTC-5 » ne dit rien de l'heure d'été et cloud-init n'en veut pas. Un nom porte ses propres règles de bascule. « libre… » garde la porte ouverte aux six cents autres fuseaux de la base. Le choix est recopié dans le champ, qui reste la seule valeur lue par la spec — un seul endroit porte la réponse. Assisted-by: Claude Opus 5
2026-08-13 01:56:36 -04:00
# Fuseaux proposés, celui de l'hôte en tête (voir todo.py).
timezones = list(ctx.get("timezones") or [])
defaults = ctx.get("defaults") or {}
result = {"spec": None}
AUTO = "__auto__"
# Dernier choix de chaque liste de ressources : il ne porte pas de valeur,
# il révèle la saisie libre placée juste dessous.
FREE = "__free__"
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le
# rendrait indistinguable de la sentinelle « rien de sélectionné ».
SERVER = "__server__"
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()}
def entry_label(e):
star = " *" if e.get("default") else ""
return (
f"{e['distro']} {e['version']}{star} [{e['arch']}] "
f"RAM≥{e['ram']}Mo {e['disk']}"
)
[ADD] tui qemu: rename a VM with the pencil A "✎" per row opens an input prefilled with the current name. The given name becomes an override like any other: it survives recomputation, and F4 removes it along with the VM's other settings. An explicit name makes automatic naming GIVE WAY. Switching the VM to GNOME no longer appends "-gnome": adding a suffix would amount to correcting the user. Clearing the field restores the automatic name, copy and desktop suffixes included. The name becomes the machine's HOSTNAME, so it is validated against RFC 1123 before being accepted. One capital or one dot too many and cloud-init silently ignores it -- the VM would stay "ubuntu", and you would find out once deployed. Capitals are lowered, the rest is refused with a message. --- FR --- Un « ✎ » par rangée ouvre une saisie préremplie du nom courant. Le nom donné devient une surcharge comme les autres : il survit au recalcul, et F4 le retire avec le reste des réglages de la VM. Un nom explicite fait CÉDER le nommage automatique. Passer la VM en GNOME n'y colle plus « -gnome » : y ajouter un suffixe reviendrait à corriger l'utilisateur. Vider le champ rend le nom automatique, suffixes de copie et de bureau compris. Le nom devient le NOM D'HÔTE de la machine : il est donc validé en RFC 1123 avant d'être retenu. Une majuscule ou un point de trop et cloud-init l'ignore en silence — la VM resterait « ubuntu », et on le découvrirait une fois déployée. Les majuscules sont abaissées, le reste est refusé avec un message. Assisted-by: Claude Opus 5
2026-08-13 00:41:16 -04:00
class RenameScreen(ModalScreen):
"""Nom d'une VM. Vide = revenir au nom automatique."""
BINDINGS = [("escape", "cancel", t("Cancel"))]
def __init__(self, name, auto):
super().__init__()
self._name = name
self._auto = auto
def compose(self) -> ComposeResult:
with Vertical(id="renbox"):
yield Static(t("Rename the VM"), id="rentitle")
yield Input(value=self._name, id="renval")
yield Static(
f" {t('Empty = back to the automatic name:')} "
f"{self._auto}",
id="renhint",
)
with Horizontal(id="renbtns"):
yield Button(t("Cancel"), id="ren_no")
yield Button(t("Rename"), variant="primary", id="ren_ok")
def on_button_pressed(self, event) -> None:
if event.button.id != "ren_ok":
self.dismiss(None)
return
self.dismiss(self.query_one("#renval", Input).value)
def action_cancel(self) -> None:
self.dismiss(None)
class PreviewScreen(ModalScreen):
"""Aperçu des commandes qui seraient lancées (aucune exécution)."""
BINDINGS = [
("escape", "close", t("Close")),
("q", "close", t("Close")),
]
def __init__(self, lines):
super().__init__()
self._lines = lines
def compose(self) -> ComposeResult:
with Vertical(id="prevbox"):
yield Static(
f" {t('Preview (dry-run):')} ({t('Esc to close')})",
id="prevtitle",
)
yield Static("\n\n".join(self._lines), id="prevbody")
def action_close(self) -> None:
self.dismiss()
class DeployForm(App):
CSS = """
#body { height: 1fr; }
#fields { width: 62; border: solid $accent; overflow-y: auto; }
#right { width: 1fr; }
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
#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; }
SelectionList { height: 10; border: solid $panel; }
RadioSet { height: auto; layout: horizontal; }
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -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;
}
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
/* 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; }
/* La branche porte des noms longs (« 1.6.0 », « develop »,
« feature/xyz ») : trop étroite, la liste les tronque et on ne sait
plus ce qu'on a choisi. */
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
.vmcopy { width: 5; min-width: 5; }
.vmhead { height: 1; }
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
/* « 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; }
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
.vmrow Select { width: 15; }
.vmrow Input { width: 11; }
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
/* Ces deux règles portent « .vmrow Select » EN PLUS de leur classe :
« .vmrow Select » (une classe + un type) l'emporte sur « .vmbranch »
(une classe) par spécificité CSS. Écrites simplement, elles étaient
silencieusement écrasées à 15 — et le test, qui ne vérifiait que la
présence de la classe, passait sans rien prouver. */
.vmrow Select.vmbranch { width: 34; }
.vmrow Select.vmprof { width: 40; }
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
#reslabel { color: $text-muted; }
[ADD] tui qemu: rename a VM with the pencil A "✎" per row opens an input prefilled with the current name. The given name becomes an override like any other: it survives recomputation, and F4 removes it along with the VM's other settings. An explicit name makes automatic naming GIVE WAY. Switching the VM to GNOME no longer appends "-gnome": adding a suffix would amount to correcting the user. Clearing the field restores the automatic name, copy and desktop suffixes included. The name becomes the machine's HOSTNAME, so it is validated against RFC 1123 before being accepted. One capital or one dot too many and cloud-init silently ignores it -- the VM would stay "ubuntu", and you would find out once deployed. Capitals are lowered, the rest is refused with a message. --- FR --- Un « ✎ » par rangée ouvre une saisie préremplie du nom courant. Le nom donné devient une surcharge comme les autres : il survit au recalcul, et F4 le retire avec le reste des réglages de la VM. Un nom explicite fait CÉDER le nommage automatique. Passer la VM en GNOME n'y colle plus « -gnome » : y ajouter un suffixe reviendrait à corriger l'utilisateur. Vider le champ rend le nom automatique, suffixes de copie et de bureau compris. Le nom devient le NOM D'HÔTE de la machine : il est donc validé en RFC 1123 avant d'être retenu. Une majuscule ou un point de trop et cloud-init l'ignore en silence — la VM resterait « ubuntu », et on le découvrirait une fois déployée. Les majuscules sont abaissées, le reste est refusé avec un message. Assisted-by: Claude Opus 5
2026-08-13 00:41:16 -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; }
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; }
"""
# Touches de fonction plutôt que ctrl+lettre : ctrl+p est pris par la
# palette de commandes de Textual, et une lettre seule serait avalée
# par le champ de saisie qui a le focus.
BINDINGS = [
("f5", "deploy", t("Deploy")),
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
("f4", "clear_vm", t("Reset VM")),
("f3", "preview", t("Preview")),
[ADD] tui qemu: F9 dumps the form state, widgets and model A per-VM setting appears not to be taken into account, and nothing on screen tells which link failed: the gap may be between the widget and the model, or between the model and the spec. A screenshot shows neither. F9 writes both side by side into ~/.erplibre/deploy-form-dump.txt: profile, shared values, overrides, locks, row generation, then VM by VM what the list DISPLAYS against what the model HOLDS, and finally the spec that would go to deployment. The file can be pasted into a message, unlike an image, and it answers the question on its own: if the list shows 16384 while the model says 1024, the defect is in the intake; if they agree and the created VM differs, it is downstream, in deploy_qemu. --- FR --- Un réglage par VM ne semble pas pris en compte, et rien dans ce que l'écran montre ne permet de trancher : l'écart peut être entre le widget et le modèle, ou entre le modèle et la spec. Une capture d'écran ne dit ni l'un ni l'autre. F9 écrit les deux côte à côte dans ~/.erplibre/deploy-form-dump.txt : profil, valeurs communes, surcharges, verrous, génération des rangées, puis VM par VM ce que la liste AFFICHE contre ce que le modèle CONTIENT, et enfin la spec qui partirait au déploiement. Le fichier se recopie dans un message, contrairement à une image, et il répond seul à la question : si la liste montre 16384 et que le modèle dit 1024, le défaut est dans la prise en compte ; s'ils s'accordent et que la VM créée diffère, il est en aval, dans deploy_qemu. Assisted-by: Claude Opus 5
2026-08-13 04:51:27 -04:00
("f9", "dump_state", t("Diagnostic dump")),
("f6", "select_all", t("All")),
("f7", "select_main", t("Main versions")),
("f8", "select_none", t("None")),
("escape", "cancel", t("Cancel")),
]
def __init__(self):
super().__init__()
self.arch = ctx.get("native") or arches[0]
self.profile = "1"
self.custom = {}
self._free = {}
self.overrides = {}
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Vrai pendant qu'on repositionne les widgets nous-mêmes : sans
# ce verrou, poser une valeur déclencherait on_select_changed, qui
# réécrirait une surcharge — une boucle qui se nourrit seule.
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
self._syncing = False
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Jeu de VM actuellement monté dans le panneau droit.
self._shown_ids = ()
# Génération du jeu de rangées monté. Les identifiants de widgets
# portent un RANG, et le rang se décale quand on coche ou décoche
# une entrée : un événement émis par un widget déjà détruit
# s'appliquerait alors à la VM qui a pris sa place. Chaque widget
# de rangée retient sa génération ; ceux d'une génération périmée
# sont ignorés.
self._gen = 0
# VM dont les ressources sont FIGÉES, par identité de catalogue.
# Distinct des surcharges : une VM peut être modifiée sans être
# verrouillée, et le verrou fige TOUT, pas seulement ce qu'on a
# touché.
self.locked = set()
# {clé de base: nombre d'exemplaires EN PLUS du premier}.
self.copies = {}
self.rows = []
self.vms = []
self.rows = []
# -- construction de l'écran ----------------------------------- #
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="body"):
with VerticalScroll(id="fields"):
yield Static(t("Architecture"), classes="grouptitle")
with RadioSet(id="f_arch"):
for a in arches:
label = a if a != "all" else t("all archs")
yield RadioButton(label, value=a == self.arch)
yield Static(t("Catalog"), classes="grouptitle")
yield SelectionList(id="f_catalog")
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
yield Static(
t("Resources — applied to ALL VMs"),
classes="grouptitle",
)
with RadioSet(id="f_profile"):
for label in ("x1", "x2", "x3", "x4"):
yield RadioButton(label, value=label == "x1")
yield RadioButton(t("custom"))
# Chaque ressource offre ses suggestions plus « libre… »,
# qui révèle une saisie. C'est l'équivalent TUI de la règle
# de la CLI : une lettre choisit une suggestion, un chiffre
# vaut pour lui-même.
yield Select(
[(str(c), c) for c in ctx["cpu_presets"]]
+ [(t("free value…"), FREE)],
prompt=t("vCPU"),
id="f_vcpus",
disabled=True,
)
yield Input(
placeholder=t("vCPU"),
id="c_vcpus",
classes="freeval",
disabled=True,
)
yield Select(
[
(f"{m} ({m // 1024}G)", m)
for m in ctx["ram_presets"]
]
+ [(t("free value…"), FREE)],
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
prompt=t("RAM: 2048 or 8G"),
id="f_ram",
disabled=True,
)
yield Input(
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
placeholder=t("RAM: 2048 or 8G"),
id="c_ram",
classes="freeval",
disabled=True,
)
yield Select(
[(d, d) for d in ctx["disk_presets"]]
+ [(t("free value…"), FREE)],
prompt=t("Disk"),
id="f_disk",
disabled=True,
)
yield Input(
placeholder=t("Disk (e.g. 250G, 1.5T)"),
id="c_disk",
classes="freeval",
disabled=True,
)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
yield Static(
f" {t('The profile and these fields change EVERY VM.')}"
f"\n {t('A VM edited on the right (marked) keeps its own.')}",
id="scopetarget",
)
[ADD] tui qemu: graphical VMs, with a GNOME desktop VMs could only be servers. A server-or-graphical choice joins both interfaces, installing GNOME along with remote access to it. Packages come from the remote command, not cloud-init: their 1 to 2 GB would stretch an already long boot there while leaving no trace in the monitoring, and group installs do not go through it. The desktop therefore does not depend on ERPLibre -- a VM may be wanted graphical and bare. Each distribution has its own names, taken from the source: Arch has no xrdp in its official repositories and takes TigerVNC. The SPICE display is set only on amd64 and arm64; s390x does expose virtio-gpu-ccw, but nothing guarantees its kernel's DRM driver, whereas remote desktop works everywhere. --- FR --- Les VM ne pouvaient être que des serveurs. Un choix serveur ou graphique s'ajoute aux deux interfaces, et pose GNOME avec son accès distant. Les paquets viennent de la commande distante, pas de cloud-init : leurs 1 à 2 Go y allongeraient un démarrage déjà long sans laisser de trace dans le suivi, et les installations par groupe n'y passent pas. Le bureau ne dépend donc pas d'ERPLibre — une VM peut être voulue graphique et nue. Chaque distribution a ses noms, relevés à la source : Arch n'a pas xrdp dans ses dépôts officiels et prend TigerVNC. L'écran virtuel SPICE n'est posé que sur amd64 et arm64 ; s390x expose bien virtio-gpu-ccw, mais rien ne garantit le pilote DRM de son noyau, alors que le bureau distant, lui, marche partout. Assisted-by: Claude Opus 5
2026-08-11 09:49:17 -04:00
# Serveur par défaut : c'est ce que sert une image cloud,
# et GNOME ajoute une à deux heures sur une architecture
# émulée. Le plan annonce le surcoût disque.
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
yield Static(t("VM type (default):"), classes="grouptitle")
[ADD] tui qemu: graphical VMs, with a GNOME desktop VMs could only be servers. A server-or-graphical choice joins both interfaces, installing GNOME along with remote access to it. Packages come from the remote command, not cloud-init: their 1 to 2 GB would stretch an already long boot there while leaving no trace in the monitoring, and group installs do not go through it. The desktop therefore does not depend on ERPLibre -- a VM may be wanted graphical and bare. Each distribution has its own names, taken from the source: Arch has no xrdp in its official repositories and takes TigerVNC. The SPICE display is set only on amd64 and arm64; s390x does expose virtio-gpu-ccw, but nothing guarantees its kernel's DRM driver, whereas remote desktop works everywhere. --- FR --- Les VM ne pouvaient être que des serveurs. Un choix serveur ou graphique s'ajoute aux deux interfaces, et pose GNOME avec son accès distant. Les paquets viennent de la commande distante, pas de cloud-init : leurs 1 à 2 Go y allongeraient un démarrage déjà long sans laisser de trace dans le suivi, et les installations par groupe n'y passent pas. Le bureau ne dépend donc pas d'ERPLibre — une VM peut être voulue graphique et nue. Chaque distribution a ses noms, relevés à la source : Arch n'a pas xrdp dans ses dépôts officiels et prend TigerVNC. L'écran virtuel SPICE n'est posé que sur amd64 et arm64 ; s390x expose bien virtio-gpu-ccw, mais rien ne garantit le pilote DRM de son noyau, alors que le bureau distant, lui, marche partout. Assisted-by: Claude Opus 5
2026-08-11 09:49:17 -04:00
with RadioSet(id="f_type"):
yield RadioButton(
t("Server (no graphical interface)"),
[UPD] support: drop Ubuntu 20.04/22.04, add AlmaLinux and Rocky Ubuntu 20.04 and 22.04 leave EVERY architecture, not just s390x. pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no g++-10 for s390x at all. Python 3.8, node 10, cargo 0.67 and OpenSSL 1.1.1 each had a workaround; the pile of them did not. 18.04 follows, already off the lists. The refusal lands before any apt, this script also serving existing machines. AlmaLinux 9 and 10, Rocky 9 and 10 join the catalog on all four architectures: the twelve "latest" URLs were opened, with no index to parse unlike Fedora. They would have booted unreachable though -- the cloud-config forced "groups: users, sudo", but the RHEL family has no sudo group, only wheel, and an unknown group makes useradd fail, hence no password and no key. The very trap already known for Debian, repeated elsewhere. Host side, EPEL and CRB are enabled: without them most -devel packages are missing, silently. The server / graphical choice gains Cinnamon, the Linux Mint desktop, from the distribution's own repositories. Mint's repository is set aside: plain HTTP, and i386/amd64 only, which would rule out arm64 and s390x. Along the way, dnf now installs an ENVIRONMENT rather than a group -- "gnome-desktop" brings gdm and gnome-shell but not base-x, hence no X server. --- FR --- Ubuntu 20.04 et 22.04 partent de TOUTES les architectures, pas seulement de s390x. pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie même pas de g++-10 pour s390x. Python 3.8, node 10, cargo 0.67 et OpenSSL 1.1.1 avaient chacun leur contournement ; leur accumulation, non. 18.04 suit, déjà hors des listes. Le refus tombe avant tout apt, ce script servant aussi les machines existantes. AlmaLinux 9 et 10, Rocky 9 et 10 entrent au catalogue, sur les quatre architectures : les douze URL « latest » ont été ouvertes, aucun index à analyser contrairement à Fedora. Elles auraient pourtant démarré inaccessibles — le cloud-config imposait « groups: users, sudo », or la famille RHEL n'a pas de groupe sudo mais wheel, et un groupe inconnu fait échouer useradd, donc ni mot de passe ni clé. C'est le piège déjà connu pour Debian, reproduit ailleurs. Côté hôte, EPEL et CRB sont activés : sans eux la plupart des -devel manquent, en silence. Le choix serveur / graphique gagne Cinnamon, le bureau de Linux Mint, depuis les dépôts de la distribution. Le dépôt de Mint lui-même est écarté : il est en HTTP nu et ne publie que i386 et amd64, ce qui exclurait arm64 et s390x. Au passage, dnf installe désormais un ENVIRONNEMENT et non un groupe — « gnome-desktop » apporte gdm et gnome-shell mais pas base-x, donc pas de serveur X. Assisted-by: Claude Opus 5
2026-08-11 18:46:11 -04:00
value=not defaults.get("desktop", ""),
[ADD] tui qemu: graphical VMs, with a GNOME desktop VMs could only be servers. A server-or-graphical choice joins both interfaces, installing GNOME along with remote access to it. Packages come from the remote command, not cloud-init: their 1 to 2 GB would stretch an already long boot there while leaving no trace in the monitoring, and group installs do not go through it. The desktop therefore does not depend on ERPLibre -- a VM may be wanted graphical and bare. Each distribution has its own names, taken from the source: Arch has no xrdp in its official repositories and takes TigerVNC. The SPICE display is set only on amd64 and arm64; s390x does expose virtio-gpu-ccw, but nothing guarantees its kernel's DRM driver, whereas remote desktop works everywhere. --- FR --- Les VM ne pouvaient être que des serveurs. Un choix serveur ou graphique s'ajoute aux deux interfaces, et pose GNOME avec son accès distant. Les paquets viennent de la commande distante, pas de cloud-init : leurs 1 à 2 Go y allongeraient un démarrage déjà long sans laisser de trace dans le suivi, et les installations par groupe n'y passent pas. Le bureau ne dépend donc pas d'ERPLibre — une VM peut être voulue graphique et nue. Chaque distribution a ses noms, relevés à la source : Arch n'a pas xrdp dans ses dépôts officiels et prend TigerVNC. L'écran virtuel SPICE n'est posé que sur amd64 et arm64 ; s390x expose bien virtio-gpu-ccw, mais rien ne garantit le pilote DRM de son noyau, alors que le bureau distant, lui, marche partout. Assisted-by: Claude Opus 5
2026-08-11 09:49:17 -04:00
)
[UPD] support: drop Ubuntu 20.04/22.04, add AlmaLinux and Rocky Ubuntu 20.04 and 22.04 leave EVERY architecture, not just s390x. pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no g++-10 for s390x at all. Python 3.8, node 10, cargo 0.67 and OpenSSL 1.1.1 each had a workaround; the pile of them did not. 18.04 follows, already off the lists. The refusal lands before any apt, this script also serving existing machines. AlmaLinux 9 and 10, Rocky 9 and 10 join the catalog on all four architectures: the twelve "latest" URLs were opened, with no index to parse unlike Fedora. They would have booted unreachable though -- the cloud-config forced "groups: users, sudo", but the RHEL family has no sudo group, only wheel, and an unknown group makes useradd fail, hence no password and no key. The very trap already known for Debian, repeated elsewhere. Host side, EPEL and CRB are enabled: without them most -devel packages are missing, silently. The server / graphical choice gains Cinnamon, the Linux Mint desktop, from the distribution's own repositories. Mint's repository is set aside: plain HTTP, and i386/amd64 only, which would rule out arm64 and s390x. Along the way, dnf now installs an ENVIRONMENT rather than a group -- "gnome-desktop" brings gdm and gnome-shell but not base-x, hence no X server. --- FR --- Ubuntu 20.04 et 22.04 partent de TOUTES les architectures, pas seulement de s390x. pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie même pas de g++-10 pour s390x. Python 3.8, node 10, cargo 0.67 et OpenSSL 1.1.1 avaient chacun leur contournement ; leur accumulation, non. 18.04 suit, déjà hors des listes. Le refus tombe avant tout apt, ce script servant aussi les machines existantes. AlmaLinux 9 et 10, Rocky 9 et 10 entrent au catalogue, sur les quatre architectures : les douze URL « latest » ont été ouvertes, aucun index à analyser contrairement à Fedora. Elles auraient pourtant démarré inaccessibles — le cloud-config imposait « groups: users, sudo », or la famille RHEL n'a pas de groupe sudo mais wheel, et un groupe inconnu fait échouer useradd, donc ni mot de passe ni clé. C'est le piège déjà connu pour Debian, reproduit ailleurs. Côté hôte, EPEL et CRB sont activés : sans eux la plupart des -devel manquent, en silence. Le choix serveur / graphique gagne Cinnamon, le bureau de Linux Mint, depuis les dépôts de la distribution. Le dépôt de Mint lui-même est écarté : il est en HTTP nu et ne publie que i386 et amd64, ce qui exclurait arm64 et s390x. Au passage, dnf installe désormais un ENVIRONNEMENT et non un groupe — « gnome-desktop » apporte gdm et gnome-shell mais pas base-x, donc pas de serveur X. Assisted-by: Claude Opus 5
2026-08-11 18:46:11 -04:00
for key, label in desktops:
yield RadioButton(
f"{t('Graphical (server + desktop):')} {label}",
value=defaults.get("desktop", "") == key,
)
[ADD] qemu: choose the application store of a graphical VM snap was not a choice but a fate: we disabled snapd, then gnome-core pulled a Firefox snap that froze the install for thirty minutes. The previous fix imposed "deb". Three answers are now offered. deb nothing but .deb, epiphany-browser as the browser. Default, and lightest: nothing extra to download. flatpak Flatpak tooling as well, WITHOUT the Flathub remote or any install. The machine is ready, the admin picks its remotes. snap Ubuntu's default, snapd left running and Firefox as a snap. It is the only mode where snapd is not disabled -- disabling it was precisely the cause of the freeze. The question is only asked when it means something: at least one graphical VM on a distro shipping snapd, Ubuntu alone here. A server pulls no snap, and Debian ships none. The TUI greys the choice out and says why; the CLI does not ask. Verified on a 26.04 VM: snapd is indeed preinstalled (2.75.2), flatpak and its GNOME Software plugin are packaged, and firefox-esr does not exist on Ubuntu. --- FR --- snap n'était plus un choix mais une fatalité : on coupait snapd, puis gnome-core tirait un Firefox-snap qui figeait l'installation trente minutes. Le correctif précédent imposait « deb ». Trois réponses sont maintenant offertes. deb rien que des .deb, epiphany-browser comme navigateur. Défaut, et le plus léger : rien de plus à télécharger. flatpak l'outillage Flatpak en plus, SANS dépôt Flathub ni installation. La machine est prête, l'administrateur choisit ses dépôts. snap le défaut d'Ubuntu, snapd laissé actif et Firefox en snap. C'est le seul mode où snapd n'est pas coupé — l'y couper était précisément la cause du blocage. La question n'est posée que lorsqu'elle a un sens : au moins une VM graphique sur une distribution qui livre snapd, Ubuntu seule ici. Un serveur ne tire aucun snap, et Debian n'en livre pas. La TUI grise le choix et dit pourquoi ; la CLI ne le demande pas. Vérifié sur une VM 26.04 : snapd y est bien préinstallé (2.75.2), flatpak et son greffon GNOME Software sont empaquetés, et firefox-esr n'existe pas sur Ubuntu. Assisted-by: Claude Opus 5
2026-08-12 03:24:19 -04:00
if app_stores:
yield Static(
t("Application store:"), classes="grouptitle"
)
with RadioSet(id="f_store"):
for i, (_k, label) in enumerate(app_stores):
yield RadioButton(label, value=i == 0)
yield Static("", id="storewarn")
yield Static("ERPLibre", classes="grouptitle")
yield Checkbox(
t("Install ERPLibre"),
value=defaults.get("install", True),
id="f_install",
)
yield Select(
[(b, b) for b in branches],
value=(
"develop" if "develop" in branches else branches[0]
),
allow_blank=False,
id="f_branch",
)
yield Select(
[(lbl, i) for i, (lbl, _c) in enumerate(profiles)],
value=0 if profiles else SELECT_NULL,
allow_blank=not profiles,
id="f_profile_install",
)
yield Checkbox(
t("Production (/opt, confined)"),
value=defaults.get("prod", False),
id="f_prod",
)
yield Checkbox(
t("Monitoring dashboard"),
value=defaults.get("monitor", True),
id="f_monitor",
)
yield Static(t("Timezone"), classes="grouptitle")
[ADD] tui qemu: pick the time zone from a list The timezone was typed by hand. A misspelt IANA name is not rejected by cloud-init: it is IGNORED. The VM stays on UTC, and you only notice from the timestamps, once deployed. A list of twenty-five zones, Québec first, then the rest of Canada and the places one actually meets. The host's zone goes to the top, without duplication: a machine outside this list must still see its own at a glance. NAMES, not offsets: "UTC-5" says nothing about daylight saving and cloud-init will not take it. A name carries its own switching rules. "free value…" keeps the door open to the other six hundred zones in the database. The choice is copied into the field, which stays the only value the spec reads -- one place holds the answer. --- FR --- Le fuseau se tapait à la main. Un nom IANA mal orthographié n'est pas refusé par cloud-init : il est IGNORÉ. La VM reste en UTC, et on ne s'en aperçoit qu'aux horodatages, une fois déployée. Une liste de vingt-cinq fuseaux, le Québec d'abord, puis le reste du Canada et les places qu'on rencontre en pratique. Le fuseau de l'hôte passe en tête, sans doublon : une machine hors de cette liste doit voir le sien en un coup d'œil. Des NOMS, pas des décalages : « UTC-5 » ne dit rien de l'heure d'été et cloud-init n'en veut pas. Un nom porte ses propres règles de bascule. « libre… » garde la porte ouverte aux six cents autres fuseaux de la base. Le choix est recopié dans le champ, qui reste la seule valeur lue par la spec — un seul endroit porte la réponse. Assisted-by: Claude Opus 5
2026-08-13 01:56:36 -04:00
# Une liste plutôt qu'une saisie : un nom IANA mal
# orthographié n'est pas refusé par cloud-init, il est
# IGNORÉ — la VM reste en UTC et on ne s'en aperçoit
# qu'aux horodatages. « libre… » garde la porte ouverte
# aux six cents autres fuseaux de la base.
yield Select(
[(z, z) for z in timezones]
+ [(t("free value…"), FREE)],
value=(timezones[0] if timezones else SELECT_NULL),
allow_blank=False,
id="f_tz_sel",
)
yield Input(
value=ctx.get("timezone") or "",
placeholder=t("Timezone for the VMs"),
id="f_tz",
[ADD] tui qemu: pick the time zone from a list The timezone was typed by hand. A misspelt IANA name is not rejected by cloud-init: it is IGNORED. The VM stays on UTC, and you only notice from the timestamps, once deployed. A list of twenty-five zones, Québec first, then the rest of Canada and the places one actually meets. The host's zone goes to the top, without duplication: a machine outside this list must still see its own at a glance. NAMES, not offsets: "UTC-5" says nothing about daylight saving and cloud-init will not take it. A name carries its own switching rules. "free value…" keeps the door open to the other six hundred zones in the database. The choice is copied into the field, which stays the only value the spec reads -- one place holds the answer. --- FR --- Le fuseau se tapait à la main. Un nom IANA mal orthographié n'est pas refusé par cloud-init : il est IGNORÉ. La VM reste en UTC, et on ne s'en aperçoit qu'aux horodatages, une fois déployée. Une liste de vingt-cinq fuseaux, le Québec d'abord, puis le reste du Canada et les places qu'on rencontre en pratique. Le fuseau de l'hôte passe en tête, sans doublon : une machine hors de cette liste doit voir le sien en un coup d'œil. Des NOMS, pas des décalages : « UTC-5 » ne dit rien de l'heure d'été et cloud-init n'en veut pas. Un nom porte ses propres règles de bascule. « libre… » garde la porte ouverte aux six cents autres fuseaux de la base. Le choix est recopié dans le champ, qui reste la seule valeur lue par la spec — un seul endroit porte la réponse. Assisted-by: Claude Opus 5
2026-08-13 01:56:36 -04:00
classes="freeval",
)
yield Static("SSH", classes="grouptitle")
yield Input(
value=ctx.get("ssh_key") or "",
placeholder=t("SSH public key path"),
id="f_key",
)
yield Checkbox(
t("Add each VM to ~/.ssh/config"),
value=defaults.get("add_ssh_config", True),
id="f_sshcfg",
)
[ADD] tui qemu: choose mise or pyenv at deploy time The interpreter provider is now chosen when creating a VM, in both interfaces. "mise" installs it in the VM and lays down a precompiled CPython; "pyenv" keeps today's build-from-source. The question is only asked where it means something: mise publishes binaries for amd64 and arm64 only. An all-s390x fleet never sees it, a mixed fleet sees it along with the architectures that will fall back to pyenv -- said before deploying, not discovered in a log an hour later. mise goes into /usr/local/bin rather than ~/.local/bin: the remote command runs through "ssh host 'command'", where neither ~/.profile nor ~/.bashrc is read. Choosing "mise" exports "auto", not "mise", so a failed install can still fall back to pyenv. The unavailability notice was translated as "pyenv required", which said the opposite of what happens: pyenv is the fallback, not a demand. --- FR --- Le fournisseur d'interpréteur se choisit désormais à la création d'une VM, dans les deux interfaces. « mise » l'installe dans la VM et pose un CPython précompilé ; « pyenv » garde la compilation d'aujourd'hui. La question n'est posée que là où elle a un sens : mise ne publie de binaires que pour amd64 et arm64. Un parc tout s390x ne la voit jamais, un parc mixte la voit avec les architectures qui retomberont sur pyenv — dit avant de déployer, pas découvert dans un log une heure plus tard. mise va dans /usr/local/bin plutôt que ~/.local/bin : la commande distante passe par « ssh hôte 'commande' », où ni ~/.profile ni ~/.bashrc ne sont lus. Choisir « mise » exporte « auto », pas « mise », pour qu'une installation ratée puisse encore retomber sur pyenv. L'avis d'indisponibilité était traduit par « pyenv exigé », qui disait l'inverse de ce qui se passe : pyenv est le repli, pas une exigence. Assisted-by: Claude Opus 5
2026-08-11 22:11:23 -04:00
# mise pose un CPython précompilé, pyenv le compile.
# Grisé quand AUCUNE des VM retenues n'est sur une
# architecture que mise sert.
yield Static(
t("Python interpreter:"), classes="grouptitle"
)
with RadioSet(id="f_python"):
yield RadioButton(
t("mise (precompiled, faster)"), value=True
)
yield RadioButton(t("pyenv (compiles from source)"))
yield Static("", id="miswarn")
yield Static(t("Parallelism"), classes="grouptitle")
# Cochée, la case donne une exécution PAR installation :
# le plafond du nombre de CPU ne s'applique plus. Décochée,
# le nombre reprend la main, et son défaut suit l'hôte —
# la CLI comptait déjà les CPU, la TUI restait figée à 4.
yield Checkbox(
t("One run per install"),
value=defaults.get("par_per_install", True),
id="f_par_all",
)
yield Select(
[(str(n), n) for n in range(1, host_cpu + 1)],
value=host_cpu,
allow_blank=False,
disabled=True,
id="f_par",
)
with Vertical(id="right"):
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Une liste de widgets, pas un tableau : chaque VM porte
# SES listes déroulantes, modifiables sur place. Un
# DataTable ne sait pas héberger de widget.
yield VerticalScroll(id="plan")
yield Static("", id="totals")
yield Footer()
def on_mount(self) -> None:
self.title = t("Deploy ERPLibre VM(s)!")
self._reload_catalog(first_load=True)
# -- catalogue et recalcul ------------------------------------- #
[ADD] tui qemu: deploy several copies of one entry A "+" per row adds a copy of the entry, a "−" removes it. The "−" only appears on a copy: the original cannot be deleted by mistake, it is unticked in the catalogue. The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3. The first keeps the catalogue name, so existing deployments keep theirs. The real work is in identity. entry_key was (distro, version, arch): two copies shared it, so setting the first set the second and their locks fought each other. It now carries the instance number, and overrides as well as locks follow the right machine. Each instance is a COPY of the dictionary, never a shared reference. Removing a copy forgets its settings: keeping them would resurrect old values on the next copy, with nothing to explain it. --- FR --- Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le « − » n'apparaît que sur une copie : l'original ne se supprime pas par mégarde, il se décoche au catalogue. Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le premier garde le nom du catalogue, pour que les déploiements existants gardent le leur. Le vrai travail est dans l'identité. entry_key valait (distro, version, arch) : deux copies la partageaient, donc régler la première réglait la seconde et leurs verrous se marchaient dessus. Elle porte maintenant le numéro d'exemplaire, et surcharges comme verrous suivent la bonne machine. Chaque exemplaire est une COPIE du dictionnaire, jamais une référence partagée. Retirer une copie oublie ses réglages : les garder ferait resurgir d'anciennes valeurs à la copie suivante, sans que rien ne l'explique. Assisted-by: Claude Opus 5
2026-08-13 00:37:45 -04:00
def _plan_entries(self):
"""Entrées du plan : la sélection, dépliée en exemplaires."""
return expand_copies(self._selected_entries(), self.copies)
def _entries(self):
return catalog.get(self.arch, [])
def _reload_catalog(self, first_load=False):
"""(Re)charge la liste à cocher.
RIEN n'est coché d'avance : déployer coûte cher, et une case
pré-cochée ferait créer une VM que personne n'a demandée. Le « * »
marque toujours la version principale, et F7 les coche toutes.
Après un changement d'architecture, les cases déjà cochées sont
conservées quand l'entrée existe encore — l'identité est
(distro, version, archi), pas le rang dans la liste."""
widget = self.query_one("#f_catalog", SelectionList)
keep = (
set()
if first_load
else {
entry_key(self._entries_before[i])
for i in widget.selected
if i < len(self._entries_before)
}
)
widget.clear_options()
entries = self._entries()
for i, e in enumerate(entries):
widget.add_option((entry_label(e), i, entry_key(e) in keep))
self._entries_before = entries
self._recompute()
def _selected_entries(self):
widget = self.query_one("#f_catalog", SelectionList)
entries = self._entries()
return [entries[i] for i in sorted(widget.selected)]
def _recompute(self):
[ADD] tui qemu: deploy several copies of one entry A "+" per row adds a copy of the entry, a "−" removes it. The "−" only appears on a copy: the original cannot be deleted by mistake, it is unticked in the catalogue. The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3. The first keeps the catalogue name, so existing deployments keep theirs. The real work is in identity. entry_key was (distro, version, arch): two copies shared it, so setting the first set the second and their locks fought each other. It now carries the instance number, and overrides as well as locks follow the right machine. Each instance is a COPY of the dictionary, never a shared reference. Removing a copy forgets its settings: keeping them would resurrect old values on the next copy, with nothing to explain it. --- FR --- Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le « − » n'apparaît que sur une copie : l'original ne se supprime pas par mégarde, il se décoche au catalogue. Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le premier garde le nom du catalogue, pour que les déploiements existants gardent le leur. Le vrai travail est dans l'identité. entry_key valait (distro, version, arch) : deux copies la partageaient, donc régler la première réglait la seconde et leurs verrous se marchaient dessus. Elle porte maintenant le numéro d'exemplaire, et surcharges comme verrous suivent la bonne machine. Chaque exemplaire est une COPIE du dictionnaire, jamais une référence partagée. Retirer une copie oublie ses réglages : les garder ferait resurgir d'anciennes valeurs à la copie suivante, sans que rien ne l'explique. Assisted-by: Claude Opus 5
2026-08-13 00:37:45 -04:00
entries = self._plan_entries()
self.vms = build_vms(
entries,
self.profile,
base_vcpus,
host_cpu,
self.custom,
self.overrides,
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
self._default_desktop(),
[ADD] qemu: a graphical VM name carries its desktop erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. A graphical VM is recognisable from a "virsh list", and its hostname says so too -- deploy_qemu uses the name as hostname when --hostname is not given. This is not only cosmetic. The name is the COLLISION KEY: a graphical VM and its server twin carried the same one, so the second was reported as "already exists" and silently skipped. They are now distinct. The suffix is applied AFTER overrides, the only point where each machine's type is known now that it is chosen VM by VM. In the form the name therefore follows the choice live, both ways. "mint" rather than "cinnamon": that is the name chosen for the fleet, the installed package still being Cinnamon from the distribution's own repositories. The suffix lives with the flavour, in _QEMU_DESKTOP, and the CLI calls the same function as the TUI rather than writing a second. --- FR --- erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. Une VM graphique se reconnaît d'un « virsh list », et son nom d'hôte le dit aussi — deploy_qemu prend le nom pour hôte quand --hostname n'est pas donné. Ce n'est pas que cosmétique. Le nom sert de CLÉ DE COLLISION : une VM graphique et sa jumelle serveur portaient le même, donc la seconde était signalée « existe déjà » et silencieusement ignorée. Elles se distinguent maintenant. Le suffixe est appliqué APRÈS les surcharges, seul moment où le type de chaque machine est connu depuis qu'il se choisit VM par VM. Dans le formulaire, le nom suit donc le choix en direct, dans les deux sens. « mint » et non « cinnamon » : c'est le nom retenu pour le parc, le paquet installé restant Cinnamon depuis les dépôts de la distribution. Le suffixe vit avec la saveur, dans _QEMU_DESKTOP, et la CLI appelle la même fonction que la TUI plutôt que d'en écrire une seconde. Assisted-by: Claude Opus 5
2026-08-12 03:29:33 -04:00
desktop_suffixes,
)
[ADD] tui qemu: graphical VMs, with a GNOME desktop VMs could only be servers. A server-or-graphical choice joins both interfaces, installing GNOME along with remote access to it. Packages come from the remote command, not cloud-init: their 1 to 2 GB would stretch an already long boot there while leaving no trace in the monitoring, and group installs do not go through it. The desktop therefore does not depend on ERPLibre -- a VM may be wanted graphical and bare. Each distribution has its own names, taken from the source: Arch has no xrdp in its official repositories and takes TigerVNC. The SPICE display is set only on amd64 and arm64; s390x does expose virtio-gpu-ccw, but nothing guarantees its kernel's DRM driver, whereas remote desktop works everywhere. --- FR --- Les VM ne pouvaient être que des serveurs. Un choix serveur ou graphique s'ajoute aux deux interfaces, et pose GNOME avec son accès distant. Les paquets viennent de la commande distante, pas de cloud-init : leurs 1 à 2 Go y allongeraient un démarrage déjà long sans laisser de trace dans le suivi, et les installations par groupe n'y passent pas. Le bureau ne dépend donc pas d'ERPLibre — une VM peut être voulue graphique et nue. Chaque distribution a ses noms, relevés à la source : Arch n'a pas xrdp dans ses dépôts officiels et prend TigerVNC. L'écran virtuel SPICE n'est posé que sur amd64 et arm64 ; s390x expose bien virtio-gpu-ccw, mais rien ne garantit le pilote DRM de son noyau, alors que le bureau distant, lui, marche partout. Assisted-by: Claude Opus 5
2026-08-11 09:49:17 -04:00
# ERPLibre et GNOME pèsent chacun sur le disque, et se cumulent.
grow = 0
if self.query_one("#f_install", Checkbox).value:
grow += extra_disk
self.rows = plan_rows(self.vms, domains, grow)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle
# seule : un supplément commun mentait dès que les types
# différaient d'une machine à l'autre.
for row in self.rows:
if row["vm"].get("desktop"):
row["disk_gb"] += desktop_disk
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
# Le plan doit MONTRER qu'une VM a été personnalisée : sans marque,
# deux lignes aux ressources différentes n'ont aucune explication à
# l'écran, et la surcharge est oubliée à la relecture. Le drapeau
# vit sur la ligne d'affichage, jamais sur la VM : celle-ci part
# telle quelle dans la spec, que la CLI produit à l'identique.
for row, entry in zip(self.rows, entries):
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
key = entry_key(entry)
row["custom"] = bool(self.overrides.get(key))
row["locked"] = key in self.locked
self._render_plan()
[ADD] tui qemu: choose mise or pyenv at deploy time The interpreter provider is now chosen when creating a VM, in both interfaces. "mise" installs it in the VM and lays down a precompiled CPython; "pyenv" keeps today's build-from-source. The question is only asked where it means something: mise publishes binaries for amd64 and arm64 only. An all-s390x fleet never sees it, a mixed fleet sees it along with the architectures that will fall back to pyenv -- said before deploying, not discovered in a log an hour later. mise goes into /usr/local/bin rather than ~/.local/bin: the remote command runs through "ssh host 'command'", where neither ~/.profile nor ~/.bashrc is read. Choosing "mise" exports "auto", not "mise", so a failed install can still fall back to pyenv. The unavailability notice was translated as "pyenv required", which said the opposite of what happens: pyenv is the fallback, not a demand. --- FR --- Le fournisseur d'interpréteur se choisit désormais à la création d'une VM, dans les deux interfaces. « mise » l'installe dans la VM et pose un CPython précompilé ; « pyenv » garde la compilation d'aujourd'hui. La question n'est posée que là où elle a un sens : mise ne publie de binaires que pour amd64 et arm64. Un parc tout s390x ne la voit jamais, un parc mixte la voit avec les architectures qui retomberont sur pyenv — dit avant de déployer, pas découvert dans un log une heure plus tard. mise va dans /usr/local/bin plutôt que ~/.local/bin : la commande distante passe par « ssh hôte 'commande' », où ni ~/.profile ni ~/.bashrc ne sont lus. Choisir « mise » exporte « auto », pas « mise », pour qu'une installation ratée puisse encore retomber sur pyenv. L'avis d'indisponibilité était traduit par « pyenv exigé », qui disait l'inverse de ce qui se passe : pyenv est le repli, pas une exigence. Assisted-by: Claude Opus 5
2026-08-11 22:11:23 -04:00
self._render_mise()
[ADD] qemu: choose the application store of a graphical VM snap was not a choice but a fate: we disabled snapd, then gnome-core pulled a Firefox snap that froze the install for thirty minutes. The previous fix imposed "deb". Three answers are now offered. deb nothing but .deb, epiphany-browser as the browser. Default, and lightest: nothing extra to download. flatpak Flatpak tooling as well, WITHOUT the Flathub remote or any install. The machine is ready, the admin picks its remotes. snap Ubuntu's default, snapd left running and Firefox as a snap. It is the only mode where snapd is not disabled -- disabling it was precisely the cause of the freeze. The question is only asked when it means something: at least one graphical VM on a distro shipping snapd, Ubuntu alone here. A server pulls no snap, and Debian ships none. The TUI greys the choice out and says why; the CLI does not ask. Verified on a 26.04 VM: snapd is indeed preinstalled (2.75.2), flatpak and its GNOME Software plugin are packaged, and firefox-esr does not exist on Ubuntu. --- FR --- snap n'était plus un choix mais une fatalité : on coupait snapd, puis gnome-core tirait un Firefox-snap qui figeait l'installation trente minutes. Le correctif précédent imposait « deb ». Trois réponses sont maintenant offertes. deb rien que des .deb, epiphany-browser comme navigateur. Défaut, et le plus léger : rien de plus à télécharger. flatpak l'outillage Flatpak en plus, SANS dépôt Flathub ni installation. La machine est prête, l'administrateur choisit ses dépôts. snap le défaut d'Ubuntu, snapd laissé actif et Firefox en snap. C'est le seul mode où snapd n'est pas coupé — l'y couper était précisément la cause du blocage. La question n'est posée que lorsqu'elle a un sens : au moins une VM graphique sur une distribution qui livre snapd, Ubuntu seule ici. Un serveur ne tire aucun snap, et Debian n'en livre pas. La TUI grise le choix et dit pourquoi ; la CLI ne le demande pas. Vérifié sur une VM 26.04 : snapd y est bien préinstallé (2.75.2), flatpak et son greffon GNOME Software sont empaquetés, et firefox-esr n'existe pas sur Ubuntu. Assisted-by: Claude Opus 5
2026-08-12 03:24:19 -04:00
self._render_store()
[ADD] tui qemu: choose mise or pyenv at deploy time The interpreter provider is now chosen when creating a VM, in both interfaces. "mise" installs it in the VM and lays down a precompiled CPython; "pyenv" keeps today's build-from-source. The question is only asked where it means something: mise publishes binaries for amd64 and arm64 only. An all-s390x fleet never sees it, a mixed fleet sees it along with the architectures that will fall back to pyenv -- said before deploying, not discovered in a log an hour later. mise goes into /usr/local/bin rather than ~/.local/bin: the remote command runs through "ssh host 'command'", where neither ~/.profile nor ~/.bashrc is read. Choosing "mise" exports "auto", not "mise", so a failed install can still fall back to pyenv. The unavailability notice was translated as "pyenv required", which said the opposite of what happens: pyenv is the fallback, not a demand. --- FR --- Le fournisseur d'interpréteur se choisit désormais à la création d'une VM, dans les deux interfaces. « mise » l'installe dans la VM et pose un CPython précompilé ; « pyenv » garde la compilation d'aujourd'hui. La question n'est posée que là où elle a un sens : mise ne publie de binaires que pour amd64 et arm64. Un parc tout s390x ne la voit jamais, un parc mixte la voit avec les architectures qui retomberont sur pyenv — dit avant de déployer, pas découvert dans un log une heure plus tard. mise va dans /usr/local/bin plutôt que ~/.local/bin : la commande distante passe par « ssh hôte 'commande' », où ni ~/.profile ni ~/.bashrc ne sont lus. Choisir « mise » exporte « auto », pas « mise », pour qu'une installation ratée puisse encore retomber sur pyenv. L'avis d'indisponibilité était traduit par « pyenv exigé », qui disait l'inverse de ce qui se passe : pyenv est le repli, pas une exigence. Assisted-by: Claude Opus 5
2026-08-11 22:11:23 -04:00
def _render_mise(self):
"""Grise le choix quand aucune VM retenue n'est servie par mise,
et nomme les architectures qui retomberont sur pyenv."""
usable = self._mise_usable()
self.query_one("#f_python", RadioSet).disabled = not usable
skipped = sorted(
{
vm["arch"]
for vm in self.vms
if vm["arch"] not in mise_arches
}
)
msg = ""
if skipped:
msg = (
f" ⚠ {t('mise has no binary for:')} "
f"{', '.join(skipped)} — {t('those VMs use pyenv')}"
)
self.query_one("#miswarn", Static).update(msg)
def _python_provider(self):
"""« mise », « pyenv », ou rien — c'est-à-dire « automatique ».
Rien, et surtout pas « pyenv », quand mise n'est servi par aucune
architecture retenue. « mise est indisponible » ne veut pas dire
« l'utilisateur exige pyenv » : la nuance décide de tout, puisqu'un
choix EXPLICITE écarte le Python de la distribution. Sur s390x,
renvoyer « pyenv » forçait la compilation de CPython — celle dont
gcc 15.2 ne revient pas."""
if not self._mise_usable():
return ""
index = self.query_one("#f_python", RadioSet).pressed_index
return "pyenv" if index == 1 else "mise"
[ADD] qemu: choose the application store of a graphical VM snap was not a choice but a fate: we disabled snapd, then gnome-core pulled a Firefox snap that froze the install for thirty minutes. The previous fix imposed "deb". Three answers are now offered. deb nothing but .deb, epiphany-browser as the browser. Default, and lightest: nothing extra to download. flatpak Flatpak tooling as well, WITHOUT the Flathub remote or any install. The machine is ready, the admin picks its remotes. snap Ubuntu's default, snapd left running and Firefox as a snap. It is the only mode where snapd is not disabled -- disabling it was precisely the cause of the freeze. The question is only asked when it means something: at least one graphical VM on a distro shipping snapd, Ubuntu alone here. A server pulls no snap, and Debian ships none. The TUI greys the choice out and says why; the CLI does not ask. Verified on a 26.04 VM: snapd is indeed preinstalled (2.75.2), flatpak and its GNOME Software plugin are packaged, and firefox-esr does not exist on Ubuntu. --- FR --- snap n'était plus un choix mais une fatalité : on coupait snapd, puis gnome-core tirait un Firefox-snap qui figeait l'installation trente minutes. Le correctif précédent imposait « deb ». Trois réponses sont maintenant offertes. deb rien que des .deb, epiphany-browser comme navigateur. Défaut, et le plus léger : rien de plus à télécharger. flatpak l'outillage Flatpak en plus, SANS dépôt Flathub ni installation. La machine est prête, l'administrateur choisit ses dépôts. snap le défaut d'Ubuntu, snapd laissé actif et Firefox en snap. C'est le seul mode où snapd n'est pas coupé — l'y couper était précisément la cause du blocage. La question n'est posée que lorsqu'elle a un sens : au moins une VM graphique sur une distribution qui livre snapd, Ubuntu seule ici. Un serveur ne tire aucun snap, et Debian n'en livre pas. La TUI grise le choix et dit pourquoi ; la CLI ne le demande pas. Vérifié sur une VM 26.04 : snapd y est bien préinstallé (2.75.2), flatpak et son greffon GNOME Software sont empaquetés, et firefox-esr n'existe pas sur Ubuntu. Assisted-by: Claude Opus 5
2026-08-12 03:24:19 -04:00
def _app_store(self):
"""Magasin retenu. Sans VM concernée, la réponse est « deb » :
elle ne change rien, et laisser passer « snap » réactiverait snapd
pour rien."""
if not app_stores or not self._app_store_needed():
return "deb"
index = self.query_one("#f_store", RadioSet).pressed_index
if index is None or not (0 <= index < len(app_stores)):
return app_stores[0][0]
return app_stores[index][0]
def _app_store_needed(self):
"""Au moins une VM graphique sur une distribution qui livre snapd."""
return any(
vm.get("desktop") and vm["distro"] in snap_distros
for vm in self.vms
)
def _render_store(self):
"""Grise le choix quand aucune VM ne le concerne, et dit pourquoi."""
if not app_stores:
return
needed = self._app_store_needed()
self.query_one("#f_store", RadioSet).disabled = not needed
self.query_one("#storewarn", Static).update(
""
if needed
else f" {t('No graphical VM on a snap-based distro.')}"
)
[ADD] tui qemu: choose mise or pyenv at deploy time The interpreter provider is now chosen when creating a VM, in both interfaces. "mise" installs it in the VM and lays down a precompiled CPython; "pyenv" keeps today's build-from-source. The question is only asked where it means something: mise publishes binaries for amd64 and arm64 only. An all-s390x fleet never sees it, a mixed fleet sees it along with the architectures that will fall back to pyenv -- said before deploying, not discovered in a log an hour later. mise goes into /usr/local/bin rather than ~/.local/bin: the remote command runs through "ssh host 'command'", where neither ~/.profile nor ~/.bashrc is read. Choosing "mise" exports "auto", not "mise", so a failed install can still fall back to pyenv. The unavailability notice was translated as "pyenv required", which said the opposite of what happens: pyenv is the fallback, not a demand. --- FR --- Le fournisseur d'interpréteur se choisit désormais à la création d'une VM, dans les deux interfaces. « mise » l'installe dans la VM et pose un CPython précompilé ; « pyenv » garde la compilation d'aujourd'hui. La question n'est posée que là où elle a un sens : mise ne publie de binaires que pour amd64 et arm64. Un parc tout s390x ne la voit jamais, un parc mixte la voit avec les architectures qui retomberont sur pyenv — dit avant de déployer, pas découvert dans un log une heure plus tard. mise va dans /usr/local/bin plutôt que ~/.local/bin : la commande distante passe par « ssh hôte 'commande' », où ni ~/.profile ni ~/.bashrc ne sont lus. Choisir « mise » exporte « auto », pas « mise », pour qu'une installation ratée puisse encore retomber sur pyenv. L'avis d'indisponibilité était traduit par « pyenv exigé », qui disait l'inverse de ce qui se passe : pyenv est le repli, pas une exigence. Assisted-by: Claude Opus 5
2026-08-11 22:11:23 -04:00
def _mise_usable(self):
return any(vm["arch"] in mise_arches for vm in self.vms)
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
def _profile_cmd(self):
"""Commande du profil choisi en haut : le défaut de chaque VM."""
if not profiles:
return ""
index = self.query_one("#f_profile_install", Select).value
return profiles[index if isinstance(index, int) else 0][1]
def _row_profile_index(self, i):
"""Rang du profil que la rangée doit AFFICHER."""
cmd = ""
if i < len(self.rows):
cmd = self.rows[i]["vm"].get("install_cmd") or ""
cmd = cmd or self._profile_cmd()
for k, (_lbl, c) in enumerate(profiles):
if c == cmd:
return k
return 0
[ADD] tui qemu: an ERPLibre version per VM A branch list per row. The form's branch stays the default; picking another for a VM creates an override, going back clears it -- the VM then follows the form again, which is what one expects when restoring the common choice. It reaches all the way down to execution, like VM type before it. "branch" now takes either a string for the whole fleet or a {name: branch} map, and the remote command is built per machine as soon as branches differ. The ground was ready: launch_installs already knows how to take one command per VM. A VM can therefore carry develop while another validates 1.6.0, in the same deployment and the same monitoring table. One trap: a row's branch fell back to develop on remount, the list being rebuilt from the form rather than from the override it already held. --- FR --- Une liste de branches par rangée. La branche du formulaire reste le défaut ; en choisir une autre pour une VM crée une surcharge, y revenir l'efface — la VM suit alors de nouveau le formulaire, ce qui est le plus attendu quand on remet le choix commun. Elle descend jusqu'à l'exécution, comme le type de VM avant elle. « branch » accepte désormais une chaîne pour tout le parc ou une carte {nom: branche}, et la commande distante est bâtie par machine dès que les branches diffèrent. Le terrain était prêt : launch_installs sait déjà prendre une commande par VM. Une VM peut donc porter develop pendant qu'une autre valide 1.6.0, sur le même déploiement et dans le même tableau de suivi. Un piège : la branche d'une rangée retombait sur develop au remontage, la liste étant rebâtie depuis le formulaire plutôt que depuis la surcharge qu'elle portait déjà. Assisted-by: Claude Opus 5
2026-08-13 00:46:12 -04:00
def _branch(self):
"""Branche du formulaire : le défaut de chaque VM."""
value = self.query_one("#f_branch", Select).value
return value if isinstance(value, str) else branches[0]
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
def _default_desktop(self):
[UPD] support: drop Ubuntu 20.04/22.04, add AlmaLinux and Rocky Ubuntu 20.04 and 22.04 leave EVERY architecture, not just s390x. pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no g++-10 for s390x at all. Python 3.8, node 10, cargo 0.67 and OpenSSL 1.1.1 each had a workaround; the pile of them did not. 18.04 follows, already off the lists. The refusal lands before any apt, this script also serving existing machines. AlmaLinux 9 and 10, Rocky 9 and 10 join the catalog on all four architectures: the twelve "latest" URLs were opened, with no index to parse unlike Fedora. They would have booted unreachable though -- the cloud-config forced "groups: users, sudo", but the RHEL family has no sudo group, only wheel, and an unknown group makes useradd fail, hence no password and no key. The very trap already known for Debian, repeated elsewhere. Host side, EPEL and CRB are enabled: without them most -devel packages are missing, silently. The server / graphical choice gains Cinnamon, the Linux Mint desktop, from the distribution's own repositories. Mint's repository is set aside: plain HTTP, and i386/amd64 only, which would rule out arm64 and s390x. Along the way, dnf now installs an ENVIRONMENT rather than a group -- "gnome-desktop" brings gdm and gnome-shell but not base-x, hence no X server. --- FR --- Ubuntu 20.04 et 22.04 partent de TOUTES les architectures, pas seulement de s390x. pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie même pas de g++-10 pour s390x. Python 3.8, node 10, cargo 0.67 et OpenSSL 1.1.1 avaient chacun leur contournement ; leur accumulation, non. 18.04 suit, déjà hors des listes. Le refus tombe avant tout apt, ce script servant aussi les machines existantes. AlmaLinux 9 et 10, Rocky 9 et 10 entrent au catalogue, sur les quatre architectures : les douze URL « latest » ont été ouvertes, aucun index à analyser contrairement à Fedora. Elles auraient pourtant démarré inaccessibles — le cloud-config imposait « groups: users, sudo », or la famille RHEL n'a pas de groupe sudo mais wheel, et un groupe inconnu fait échouer useradd, donc ni mot de passe ni clé. C'est le piège déjà connu pour Debian, reproduit ailleurs. Côté hôte, EPEL et CRB sont activés : sans eux la plupart des -devel manquent, en silence. Le choix serveur / graphique gagne Cinnamon, le bureau de Linux Mint, depuis les dépôts de la distribution. Le dépôt de Mint lui-même est écarté : il est en HTTP nu et ne publie que i386 et amd64, ce qui exclurait arm64 et s390x. Au passage, dnf installe désormais un ENVIRONNEMENT et non un groupe — « gnome-desktop » apporte gdm et gnome-shell mais pas base-x, donc pas de serveur X. Assisted-by: Claude Opus 5
2026-08-11 18:46:11 -04:00
"""« » pour un serveur, sinon la clé de la saveur choisie."""
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
"""Type de VM par défaut. Chaque rangée peut s'en écarter."""
[UPD] support: drop Ubuntu 20.04/22.04, add AlmaLinux and Rocky Ubuntu 20.04 and 22.04 leave EVERY architecture, not just s390x. pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no g++-10 for s390x at all. Python 3.8, node 10, cargo 0.67 and OpenSSL 1.1.1 each had a workaround; the pile of them did not. 18.04 follows, already off the lists. The refusal lands before any apt, this script also serving existing machines. AlmaLinux 9 and 10, Rocky 9 and 10 join the catalog on all four architectures: the twelve "latest" URLs were opened, with no index to parse unlike Fedora. They would have booted unreachable though -- the cloud-config forced "groups: users, sudo", but the RHEL family has no sudo group, only wheel, and an unknown group makes useradd fail, hence no password and no key. The very trap already known for Debian, repeated elsewhere. Host side, EPEL and CRB are enabled: without them most -devel packages are missing, silently. The server / graphical choice gains Cinnamon, the Linux Mint desktop, from the distribution's own repositories. Mint's repository is set aside: plain HTTP, and i386/amd64 only, which would rule out arm64 and s390x. Along the way, dnf now installs an ENVIRONMENT rather than a group -- "gnome-desktop" brings gdm and gnome-shell but not base-x, hence no X server. --- FR --- Ubuntu 20.04 et 22.04 partent de TOUTES les architectures, pas seulement de s390x. pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie même pas de g++-10 pour s390x. Python 3.8, node 10, cargo 0.67 et OpenSSL 1.1.1 avaient chacun leur contournement ; leur accumulation, non. 18.04 suit, déjà hors des listes. Le refus tombe avant tout apt, ce script servant aussi les machines existantes. AlmaLinux 9 et 10, Rocky 9 et 10 entrent au catalogue, sur les quatre architectures : les douze URL « latest » ont été ouvertes, aucun index à analyser contrairement à Fedora. Elles auraient pourtant démarré inaccessibles — le cloud-config imposait « groups: users, sudo », or la famille RHEL n'a pas de groupe sudo mais wheel, et un groupe inconnu fait échouer useradd, donc ni mot de passe ni clé. C'est le piège déjà connu pour Debian, reproduit ailleurs. Côté hôte, EPEL et CRB sont activés : sans eux la plupart des -devel manquent, en silence. Le choix serveur / graphique gagne Cinnamon, le bureau de Linux Mint, depuis les dépôts de la distribution. Le dépôt de Mint lui-même est écarté : il est en HTTP nu et ne publie que i386 et amd64, ce qui exclurait arm64 et s390x. Au passage, dnf installe désormais un ENVIRONNEMENT et non un groupe — « gnome-desktop » apporte gdm et gnome-shell mais pas base-x, donc pas de serveur X. Assisted-by: Claude Opus 5
2026-08-11 18:46:11 -04:00
index = self.query_one("#f_type", RadioSet).pressed_index
if index is None or index < 1 or index > len(desktops):
return ""
return desktops[index - 1][0]
[ADD] tui qemu: graphical VMs, with a GNOME desktop VMs could only be servers. A server-or-graphical choice joins both interfaces, installing GNOME along with remote access to it. Packages come from the remote command, not cloud-init: their 1 to 2 GB would stretch an already long boot there while leaving no trace in the monitoring, and group installs do not go through it. The desktop therefore does not depend on ERPLibre -- a VM may be wanted graphical and bare. Each distribution has its own names, taken from the source: Arch has no xrdp in its official repositories and takes TigerVNC. The SPICE display is set only on amd64 and arm64; s390x does expose virtio-gpu-ccw, but nothing guarantees its kernel's DRM driver, whereas remote desktop works everywhere. --- FR --- Les VM ne pouvaient être que des serveurs. Un choix serveur ou graphique s'ajoute aux deux interfaces, et pose GNOME avec son accès distant. Les paquets viennent de la commande distante, pas de cloud-init : leurs 1 à 2 Go y allongeraient un démarrage déjà long sans laisser de trace dans le suivi, et les installations par groupe n'y passent pas. Le bureau ne dépend donc pas d'ERPLibre — une VM peut être voulue graphique et nue. Chaque distribution a ses noms, relevés à la source : Arch n'a pas xrdp dans ses dépôts officiels et prend TigerVNC. L'écran virtuel SPICE n'est posé que sur amd64 et arm64 ; s390x expose bien virtio-gpu-ccw, mais rien ne garantit le pilote DRM de son noyau, alors que le bureau distant, lui, marche partout. Assisted-by: Claude Opus 5
2026-08-11 09:49:17 -04:00
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# -- panneau droit : une rangée de widgets par VM ---------------- #
def _row_ids(self):
"""Identité du JEU de VM affiché. Reconstruire les widgets à chaque
frappe ferait perdre le focus en pleine saisie : on ne le fait que
si la liste elle-même a changé."""
[ADD] tui qemu: deploy several copies of one entry A "+" per row adds a copy of the entry, a "−" removes it. The "−" only appears on a copy: the original cannot be deleted by mistake, it is unticked in the catalogue. The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3. The first keeps the catalogue name, so existing deployments keep theirs. The real work is in identity. entry_key was (distro, version, arch): two copies shared it, so setting the first set the second and their locks fought each other. It now carries the instance number, and overrides as well as locks follow the right machine. Each instance is a COPY of the dictionary, never a shared reference. Removing a copy forgets its settings: keeping them would resurrect old values on the next copy, with nothing to explain it. --- FR --- Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le « − » n'apparaît que sur une copie : l'original ne se supprime pas par mégarde, il se décoche au catalogue. Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le premier garde le nom du catalogue, pour que les déploiements existants gardent le leur. Le vrai travail est dans l'identité. entry_key valait (distro, version, arch) : deux copies la partageaient, donc régler la première réglait la seconde et leurs verrous se marchaient dessus. Elle porte maintenant le numéro d'exemplaire, et surcharges comme verrous suivent la bonne machine. Chaque exemplaire est une COPIE du dictionnaire, jamais une référence partagée. Retirer une copie oublie ses réglages : les garder ferait resurgir d'anciennes valeurs à la copie suivante, sans que rien ne l'explique. Assisted-by: Claude Opus 5
2026-08-13 00:37:45 -04:00
return tuple(entry_key(e) for e in self._plan_entries())
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
def _type_options(self):
return [(t("Server"), SERVER)] + [
(label, key) for key, label in desktops
]
def _mount_rows(self) -> None:
"""(Re)construit le panneau droit.
Le verrou couvre TOUT le montage : poser « value= » sur un Select
fait émettre un Changed à Textual, que on_select_changed prenait
pour une saisie. Résultat mesuré — les trois champs de CHAQUE VM
recevaient une surcharge dès l'affichage, le profil x1..x4 ne
pouvait plus rien changer, et toutes les lignes portaient la
marque ✎. Il est relâché après le rafraîchissement, une fois ces
messages consommés."""
self._syncing = True
self._gen += 1
plan = self.query_one("#plan", VerticalScroll)
plan.remove_children()
widgets = []
for i, r in enumerate(self.rows):
vm = r["vm"]
[ADD] tui qemu: deploy several copies of one entry A "+" per row adds a copy of the entry, a "−" removes it. The "−" only appears on a copy: the original cannot be deleted by mistake, it is unticked in the catalogue. The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3. The first keeps the catalogue name, so existing deployments keep theirs. The real work is in identity. entry_key was (distro, version, arch): two copies shared it, so setting the first set the second and their locks fought each other. It now carries the instance number, and overrides as well as locks follow the right machine. Each instance is a COPY of the dictionary, never a shared reference. Removing a copy forgets its settings: keeping them would resurrect old values on the next copy, with nothing to explain it. --- FR --- Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le « − » n'apparaît que sur une copie : l'original ne se supprime pas par mégarde, il se décoche au catalogue. Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le premier garde le nom du catalogue, pour que les déploiements existants gardent le leur. Le vrai travail est dans l'identité. entry_key valait (distro, version, arch) : deux copies la partageaient, donc régler la première réglait la seconde et leurs verrous se marchaient dessus. Elle porte maintenant le numéro d'exemplaire, et surcharges comme verrous suivent la bonne machine. Chaque exemplaire est une COPIE du dictionnaire, jamais une référence partagée. Retirer une copie oublie ses réglages : les garder ferait resurgir d'anciennes valeurs à la copie suivante, sans que rien ne l'explique. Assisted-by: Claude Opus 5
2026-08-13 00:37:45 -04:00
item = self._plan_entries()[i]
key = entry_key(item)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
row = Horizontal(
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
# « + » ajoute un exemplaire de CETTE entrée ; « - » ne
# s'affiche que sur une copie, pour qu'on ne puisse pas
# retirer l'original par mégarde.
Button("+", id=f"p{i}", classes="vmcopy"),
Button("✎", id=f"r{i}", classes="vmcopy"),
Button(
"🔒" if key in self.locked else "🔓",
id=f"l{i}",
variant="success" if key in self.locked else "default",
classes="vmlock",
),
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
Select(
[(str(c), c) for c in ctx["cpu_presets"]]
+ [(t("free value…"), FREE)],
value=(
vm["vcpus"]
if vm["vcpus"] in ctx["cpu_presets"]
else SELECT_NULL
),
prompt="vCPU",
id=f"v{i}_vcpus",
),
Input(
value=(
""
if vm["vcpus"] in ctx["cpu_presets"]
else str(vm["vcpus"])
),
placeholder="vCPU",
id=f"c{i}_vcpus",
classes="freeval",
),
Select(
[(f"{m // 1024}G", m) for m in ctx["ram_presets"]]
+ [(t("free value…"), FREE)],
value=(
vm["ram"]
if vm["ram"] in ctx["ram_presets"]
else SELECT_NULL
),
prompt=t("RAM: 2048 or 8G"),
id=f"v{i}_ram",
),
Input(
value=(
""
if vm["ram"] in ctx["ram_presets"]
else str(vm["ram"])
),
placeholder=t("RAM: 2048 or 8G"),
id=f"c{i}_ram",
classes="freeval",
),
Select(
[(d, d) for d in ctx["disk_presets"]]
+ [(t("free value…"), FREE)],
value=(
vm["disk"]
if vm["disk"] in ctx["disk_presets"]
else SELECT_NULL
),
prompt=t("Disk"),
id=f"v{i}_disk",
),
Input(
value=(
""
if vm["disk"] in ctx["disk_presets"]
else str(vm["disk"])
),
placeholder=t("Disk"),
id=f"c{i}_disk",
classes="freeval",
),
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
(
Button("−", id=f"m{i}", classes="vmcopy")
if item.get("instance")
else Static("", classes="vmcopy")
),
Select(
[(b, b) for b in branches],
classes="vmbranch",
[ADD] tui qemu: an ERPLibre version per VM A branch list per row. The form's branch stays the default; picking another for a VM creates an override, going back clears it -- the VM then follows the form again, which is what one expects when restoring the common choice. It reaches all the way down to execution, like VM type before it. "branch" now takes either a string for the whole fleet or a {name: branch} map, and the remote command is built per machine as soon as branches differ. The ground was ready: launch_installs already knows how to take one command per VM. A VM can therefore carry develop while another validates 1.6.0, in the same deployment and the same monitoring table. One trap: a row's branch fell back to develop on remount, the list being rebuilt from the form rather than from the override it already held. --- FR --- Une liste de branches par rangée. La branche du formulaire reste le défaut ; en choisir une autre pour une VM crée une surcharge, y revenir l'efface — la VM suit alors de nouveau le formulaire, ce qui est le plus attendu quand on remet le choix commun. Elle descend jusqu'à l'exécution, comme le type de VM avant elle. « branch » accepte désormais une chaîne pour tout le parc ou une carte {nom: branche}, et la commande distante est bâtie par machine dès que les branches diffèrent. Le terrain était prêt : launch_installs sait déjà prendre une commande par VM. Une VM peut donc porter develop pendant qu'une autre valide 1.6.0, sur le même déploiement et dans le même tableau de suivi. Un piège : la branche d'une rangée retombait sur develop au remontage, la liste étant rebâtie depuis le formulaire plutôt que depuis la surcharge qu'elle portait déjà. Assisted-by: Claude Opus 5
2026-08-13 00:46:12 -04:00
# Repli sur la branche du FORMULAIRE, jamais sur
# branches[0] : les rangées sont remontées dès que le
# jeu de VM change (une entrée cochée, une copie
# ajoutée, un renommage), et elles retombaient alors
# toutes sur « develop » quel que soit le choix commun.
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
value=(
[ADD] tui qemu: an ERPLibre version per VM A branch list per row. The form's branch stays the default; picking another for a VM creates an override, going back clears it -- the VM then follows the form again, which is what one expects when restoring the common choice. It reaches all the way down to execution, like VM type before it. "branch" now takes either a string for the whole fleet or a {name: branch} map, and the remote command is built per machine as soon as branches differ. The ground was ready: launch_installs already knows how to take one command per VM. A VM can therefore carry develop while another validates 1.6.0, in the same deployment and the same monitoring table. One trap: a row's branch fell back to develop on remount, the list being rebuilt from the form rather than from the override it already held. --- FR --- Une liste de branches par rangée. La branche du formulaire reste le défaut ; en choisir une autre pour une VM crée une surcharge, y revenir l'efface — la VM suit alors de nouveau le formulaire, ce qui est le plus attendu quand on remet le choix commun. Elle descend jusqu'à l'exécution, comme le type de VM avant elle. « branch » accepte désormais une chaîne pour tout le parc ou une carte {nom: branche}, et la commande distante est bâtie par machine dès que les branches diffèrent. Le terrain était prêt : launch_installs sait déjà prendre une commande par VM. Une VM peut donc porter develop pendant qu'une autre valide 1.6.0, sur le même déploiement et dans le même tableau de suivi. Un piège : la branche d'une rangée retombait sur develop au remontage, la liste étant rebâtie depuis le formulaire plutôt que depuis la surcharge qu'elle portait déjà. Assisted-by: Claude Opus 5
2026-08-13 00:46:12 -04:00
self.rows[i]["vm"].get("branch")
if i < len(self.rows)
else ""
)
or self._branch(),
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
allow_blank=False,
id=f"v{i}_branch",
),
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
(
Select(
[(lbl, i) for i, (lbl, _c) in enumerate(profiles)],
value=self._row_profile_index(i),
allow_blank=False,
classes="vmprof",
id=f"v{i}_prof",
)
if profiles
else Static("", classes="vmprof")
),
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
Select(
self._type_options(),
value=vm.get("desktop") or SERVER,
allow_blank=False,
id=f"v{i}_type",
),
classes="vmrow",
)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
widgets.append(
Vertical(
Static(self._row_head(i, r), id=f"h{i}"),
row,
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
# Pas d'id sur la carte : « remove_children() » est
# ASYNCHRONE, les anciennes sont encore là au montage
# et Textual refuse deux frères de même id. Les ids
# des champs vivent un niveau plus bas, dans un parent
# neuf — la collision ne les touche pas. On atteint
# donc la carte par son RANG.
classes=(
"vmcard locked" if key in self.locked else "vmcard"
),
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
)
)
# Marque de génération, posée sur CHAQUE widget de rangée.
# « walk_children() » ne voit RIEN avant le montage : les enfants
# passés au constructeur attendent dans « _pending_children ».
def mark(node):
node._el_gen = self._gen
for child in getattr(node, "_pending_children", None) or []:
mark(child)
for card in widgets:
mark(card)
# Le montage vient APRÈS le marquage, jamais avant : « mount_all »
# consomme « _pending_children » et le vide. Marquer ensuite était
# une COURSE — gagnée sur une machine, perdue sur une autre. Perdue,
# les listes des rangées n'avaient plus de génération, TOUS les
# changements par VM étaient rejetés en silence, et seuls les
# réglages globaux semblaient agir.
if widgets:
plan.mount_all(widgets)
self._shown_ids = self._row_ids()
# Les saisies libres ne se révèlent qu'après le montage : leur
# style ne peut pas être touché avant qu'elles existent.
self.call_after_refresh(self._after_mount_rows)
def _after_mount_rows(self) -> None:
self._sync_free_inputs()
self._syncing = False
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
def _refresh_row_widgets(self) -> None:
"""Remet les listes de chaque rangée sur ce que la VM vaut MAINTENANT.
Sans cela, changer le profil x1..x4 mettait les totaux à jour mais
laissait les listes sur leurs anciennes valeurs : l'écran affichait
8192 pendant que la VM valait 4096. Les rangées ne sont remontées
que si le JEU de VM change — pour ne pas voler le focus — donc ce
rafraîchissement doit se faire à la main.
Aucun risque de boucle : on_select_changed ignore une valeur déjà
égale à celle du modèle, et le modèle vient précisément d'être
recalculé."""
for i, r in enumerate(self.rows):
vm = r["vm"]
for field, presets in (
("vcpus", ctx["cpu_presets"]),
("ram", ctx["ram_presets"]),
("disk", ctx["disk_presets"]),
):
try:
sel = self.query_one(f"#v{i}_{field}", Select)
except Exception:
continue
# Une liste posée sur « libre… » ne doit PAS être remise
# sur une valeur : l'utilisateur vient de la choisir, et
# tant qu'il n'a rien tapé la VM vaut encore celle du
# profil — on la lui reprendrait sous les doigts.
if sel.value is FREE:
continue
# Une valeur libre déjà saisie n'est dans aucune liste :
# liste vide, la saisie à côté porte le nombre.
sel.value = (
vm[field] if vm[field] in presets else SELECT_NULL
)
for wid, value in (
(f"#v{i}_type", vm.get("desktop") or SERVER),
(f"#v{i}_branch", vm.get("branch") or self._branch()),
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
(f"#v{i}_prof", self._row_profile_index(i)),
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
):
try:
self.query_one(wid, Select).value = value
except Exception:
pass
self._sync_free_inputs()
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
def _sync_free_inputs(self) -> None:
for i, r in enumerate(self.rows):
vm = r["vm"]
for field, presets in (
("vcpus", ctx["cpu_presets"]),
("ram", ctx["ram_presets"]),
("disk", ctx["disk_presets"]),
):
try:
widget = self.query_one(f"#c{i}_{field}", Input)
except Exception:
continue
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
# Visible si la valeur EST libre, ou si la liste est
# posée sur « libre… » en attente d'une saisie.
try:
chosen_free = (
self.query_one(f"#v{i}_{field}", Select).value
is FREE
)
except Exception:
chosen_free = False
free = chosen_free or vm[field] not in presets
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
widget.display = free
widget.disabled = not free
def _row_head(self, index, row):
"""Ligne de titre d'une VM : nom, origine, état, marque de
personnalisation. Sans elle, deux rangées aux réglages différents
n'ont aucune explication à l'écran."""
vm = row["vm"]
icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[row["state"]]
state = "" if row["state"] == "new" else f" {icon}{row['note']}"
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
if row.get("locked"):
mark = " 🔒 figée"
elif row.get("custom"):
mark = " ✎"
else:
mark = ""
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
return (
f"[b]{vm['name']}[/b] {vm['distro']} {vm['version']} "
f"[{vm['arch']}] {row['disk_gb']}G{state}{mark}"
)
def _render_plan(self):
# Le JEU de VM a-t-il changé ? Si oui on remonte les widgets, sinon
# on se contente des titres : remonter à chaque frappe volerait le
# focus au champ en cours de saisie.
if self._row_ids() != self._shown_ids:
self._mount_rows()
else:
for i, r in enumerate(self.rows):
try:
self.query_one(f"#h{i}", Static).update(
self._row_head(i, r)
)
except Exception:
pass
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
self._refresh_row_widgets()
if not self.rows:
# Rien de coché : un total à zéro n'apprend rien, on dit
# plutôt comment remplir la liste.
self.query_one("#totals", Static).update(
f" {t('Tick what to deploy')} — "
f"{t('F7 main versions · F6 all')}"
)
return
n, cpus, ram, disk = plan_totals(self.rows)
warn = ""
if free_ram and ram > free_ram:
warn = f" ⚠ {t('> host free RAM')}"
elif cpus > host_cpu:
warn = f" ⚠ {t('> host cores')} ({host_cpu})"
dupes = len({vm["name"] for vm in self.vms}) != len(self.vms)
dup_txt = (
f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}"
if dupes
else ""
)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Une VM DÉJÀ DÉFINIE n'est pas recréée : elle ne consomme rien
# de neuf, donc plan_totals l'écarte. Mais un total à zéro sans
# explication se lit comme un bogue — on a cru que les réglages
# par VM n'avaient aucun effet, alors qu'ils portaient sur une
# machine qui ne sera pas créée.
skipped = sum(1 for r in self.rows if r["state"] == "exists")
skip_txt = (
f" ({skipped} {t('already defined, not counted')})"
if skipped
else ""
)
self.query_one("#totals", Static).update(
f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G"
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
f"{skip_txt}{warn}{dup_txt}"
)
# -- réactions aux champs -------------------------------------- #
def on_radio_set_changed(self, event) -> None:
if event.radio_set.id == "f_arch":
self.arch = arches[event.radio_set.pressed_index]
self._reload_catalog()
[ADD] tui qemu: graphical VMs, with a GNOME desktop VMs could only be servers. A server-or-graphical choice joins both interfaces, installing GNOME along with remote access to it. Packages come from the remote command, not cloud-init: their 1 to 2 GB would stretch an already long boot there while leaving no trace in the monitoring, and group installs do not go through it. The desktop therefore does not depend on ERPLibre -- a VM may be wanted graphical and bare. Each distribution has its own names, taken from the source: Arch has no xrdp in its official repositories and takes TigerVNC. The SPICE display is set only on amd64 and arm64; s390x does expose virtio-gpu-ccw, but nothing guarantees its kernel's DRM driver, whereas remote desktop works everywhere. --- FR --- Les VM ne pouvaient être que des serveurs. Un choix serveur ou graphique s'ajoute aux deux interfaces, et pose GNOME avec son accès distant. Les paquets viennent de la commande distante, pas de cloud-init : leurs 1 à 2 Go y allongeraient un démarrage déjà long sans laisser de trace dans le suivi, et les installations par groupe n'y passent pas. Le bureau ne dépend donc pas d'ERPLibre — une VM peut être voulue graphique et nue. Chaque distribution a ses noms, relevés à la source : Arch n'a pas xrdp dans ses dépôts officiels et prend TigerVNC. L'écran virtuel SPICE n'est posé que sur amd64 et arm64 ; s390x expose bien virtio-gpu-ccw, mais rien ne garantit le pilote DRM de son noyau, alors que le bureau distant, lui, marche partout. Assisted-by: Claude Opus 5
2026-08-11 09:49:17 -04:00
elif event.radio_set.id == "f_type":
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
self._clear_overrides(("desktop",))
# Recalcul : le disque annonce inclut le bureau, et la
# colonne Statut affiche le type de VM.
self._recompute()
elif event.radio_set.id == "f_profile":
index = event.radio_set.pressed_index
self.profile = "custom" if index == 4 else str(index + 1)
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
# Un multiplicateur x1..x4 ne touche QUE les vCPU et la RAM —
# apply_profile y laisse le disque du catalogue. Y effacer une
# taille de disque réglée à la main la faisait disparaître sans
# rien mettre à la place : on revenait à 20G sans l'avoir
# demandé. Le disque n'est rendu au commun que par le profil
# « personnalisé », qui en porte un.
fields = ("vcpus", "ram")
if self.profile == "custom":
fields += ("disk",)
self._clear_overrides(fields)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
custom = self.profile == "custom"
for field, (sel, _inp) in RES_FIELDS.items():
self.query_one(sel, Select).disabled = not custom
self._show_free(field, custom and self._free.get(field))
self._recompute()
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# -- rangées du panneau droit ------------------------------- #
def _is_current(self, widget):
"""Le widget appartient-il au jeu de rangées ACTUEL ?"""
return getattr(widget, "_el_gen", None) == self._gen
def _focused_row(self):
"""Rang de la VM dont un widget a le focus, ou None. C'est la seule
désignation qui ait un sens ici : il n'y a plus de curseur unique,
chaque rangée est éditable directement."""
wid = getattr(self.focused, "id", "") or ""
match = re.match(r"[vch](\d+)(?:_|$)", wid)
if not match:
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
return None
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
index = int(match.group(1))
return index if 0 <= index < len(self.rows) else None
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
def _row_key(self, index):
[ADD] tui qemu: deploy several copies of one entry A "+" per row adds a copy of the entry, a "−" removes it. The "−" only appears on a copy: the original cannot be deleted by mistake, it is unticked in the catalogue. The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3. The first keeps the catalogue name, so existing deployments keep theirs. The real work is in identity. entry_key was (distro, version, arch): two copies shared it, so setting the first set the second and their locks fought each other. It now carries the instance number, and overrides as well as locks follow the right machine. Each instance is a COPY of the dictionary, never a shared reference. Removing a copy forgets its settings: keeping them would resurrect old values on the next copy, with nothing to explain it. --- FR --- Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le « − » n'apparaît que sur une copie : l'original ne se supprime pas par mégarde, il se décoche au catalogue. Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le premier garde le nom du catalogue, pour que les déploiements existants gardent le leur. Le vrai travail est dans l'identité. entry_key valait (distro, version, arch) : deux copies la partageaient, donc régler la première réglait la seconde et leurs verrous se marchaient dessus. Elle porte maintenant le numéro d'exemplaire, et surcharges comme verrous suivent la bonne machine. Chaque exemplaire est une COPIE du dictionnaire, jamais une référence partagée. Retirer une copie oublie ses réglages : les garder ferait resurgir d'anciennes valeurs à la copie suivante, sans que rien ne l'explique. Assisted-by: Claude Opus 5
2026-08-13 00:37:45 -04:00
entries = self._plan_entries()
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
return entry_key(entries[index]) if index < len(entries) else None
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
def _clear_overrides(self, fields) -> None:
"""Rend au choix commun les VM NON figées, pour ces champs-là.
Le cadenas est la seule chose qui résiste. Une valeur réglée à la
main sur une rangée cède donc au choix global suivant : c'est ce
qu'on attend d'un réglage « général », et le verrou existe
précisément pour dire « pas celle-ci ».
Par champ, pas en bloc : changer la RAM générale n'a aucune raison
d'effacer le disque qu'on a réglé sur une VM.
« name » n'y figure jamais : un renommage est explicite et ne
découle d'aucune valeur générale."""
changed = False
for key in list(self.overrides):
if key in self.locked:
continue
for field in fields:
changed |= self.overrides[key].pop(field, None) is not None
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
if field == "install_cmd":
# Le libelle n'a pas de sens sans sa commande.
self.overrides[key].pop("install_label", None)
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
if not self.overrides[key]:
self.overrides.pop(key, None)
if changed:
# Forcer le remontage : une rangée peut porter une saisie
# LIBRE, que le simple rafraîchissement laisse en place — on
# verrait « 12 » à l'écran pendant que la VM vaut 2. Le
# remontage rebâtit tout depuis le modèle. Sans risque de vol
# de focus : ce chemin part d'un widget GLOBAL, jamais d'une
# rangée.
self._shown_ids = ()
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
def _set_override(self, index, field, value) -> None:
"""Écrit — ou retire — la surcharge d'UNE VM."""
key = self._row_key(index)
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
if key is None:
return
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
if value in ("", 0, None):
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
# Saisie vidée ou invalide : on RETIRE la surcharge au lieu
# d'écrire un zéro, qui donnerait une VM à 0 vCPU.
self.overrides.get(key, {}).pop(field, None)
if not self.overrides.get(key):
self.overrides.pop(key, None)
else:
self.overrides.setdefault(key, {})[field] = value
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
def _row_free(self, index, field, visible) -> None:
widget = self.query_one(f"#c{index}_{field}", Input)
widget.display = bool(visible)
widget.disabled = not visible
if visible:
widget.focus()
def _read_row_free(self, index, field):
raw = self.query_one(f"#c{index}_{field}", Input).value.strip()
if field == "disk":
return parse_disk(raw) or ""
if field == "ram":
return parse_ram(raw)
return positive_int(raw, 0)
def _show_free(self, field, visible) -> None:
"""Montre ou cache la saisie libre d'une ressource."""
widget = self.query_one(RES_FIELDS[field][1], Input)
widget.display = bool(visible)
widget.disabled = not visible
def on_selection_list_selected_changed(self, event) -> None:
self._recompute()
def on_select_changed(self, event) -> None:
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
if self._syncing:
return
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
wid = event.select.id or ""
row = re.match(r"v(\d+)_(vcpus|ram|disk|type|branch|prof)$", wid)
if row and not self._is_current(event.select):
# Widget d'une génération périmée : son rang ne désigne plus
# la même VM. L'appliquer écraserait le réglage d'une voisine.
return
if row:
index, field = int(row.group(1)), row.group(2)
if index >= len(self.rows):
return
# Poser « value= » au montage fait émettre un Changed que
# Textual délivre APRÈS coup : un verrou temporel ne l'attrape
# pas — mesuré, les trois champs de chaque VM se retrouvaient
# surchargés dès l'affichage et le profil x1..x4 devenait
# inopérant. On compare donc à ce que le modèle dit déjà : une
# valeur identique n'est pas une saisie, c'est l'écho.
#
# Cas limite assumé : choisir explicitement la valeur que le
# profil donne déjà n'enregistre pas de surcharge. La VM
# suivra donc le profil s'il change — ce qui est aussi le plus
# attendu quand on n'a rien changé de visible.
vm_now = self.rows[index]["vm"]
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
if field == "prof":
label, cmd = profiles[event.value]
if cmd == (
vm_now.get("install_cmd") or self._profile_cmd()
):
return
same = cmd == self._profile_cmd()
self._set_override(
index, "install_cmd", "" if same else cmd
)
self._set_override(
index, "install_label", "" if same else label
)
self._recompute()
return
[ADD] tui qemu: an ERPLibre version per VM A branch list per row. The form's branch stays the default; picking another for a VM creates an override, going back clears it -- the VM then follows the form again, which is what one expects when restoring the common choice. It reaches all the way down to execution, like VM type before it. "branch" now takes either a string for the whole fleet or a {name: branch} map, and the remote command is built per machine as soon as branches differ. The ground was ready: launch_installs already knows how to take one command per VM. A VM can therefore carry develop while another validates 1.6.0, in the same deployment and the same monitoring table. One trap: a row's branch fell back to develop on remount, the list being rebuilt from the form rather than from the override it already held. --- FR --- Une liste de branches par rangée. La branche du formulaire reste le défaut ; en choisir une autre pour une VM crée une surcharge, y revenir l'efface — la VM suit alors de nouveau le formulaire, ce qui est le plus attendu quand on remet le choix commun. Elle descend jusqu'à l'exécution, comme le type de VM avant elle. « branch » accepte désormais une chaîne pour tout le parc ou une carte {nom: branche}, et la commande distante est bâtie par machine dès que les branches diffèrent. Le terrain était prêt : launch_installs sait déjà prendre une commande par VM. Une VM peut donc porter develop pendant qu'une autre valide 1.6.0, sur le même déploiement et dans le même tableau de suivi. Un piège : la branche d'une rangée retombait sur develop au remontage, la liste étant rebâtie depuis le formulaire plutôt que depuis la surcharge qu'elle portait déjà. Assisted-by: Claude Opus 5
2026-08-13 00:46:12 -04:00
if field == "branch":
# « la branche du formulaire » n'est pas une surcharge :
# la VM doit suivre si on la change en haut.
current = vm_now.get("branch") or self._branch()
if event.value == current:
return
self._set_override(
index,
"branch",
"" if event.value == self._branch() else event.value,
)
self._recompute()
return
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
if field == "type":
new_desk = "" if event.value == SERVER else event.value
if new_desk == (vm_now.get("desktop") or ""):
return
elif (
event.value is not FREE and event.value is not SELECT_NULL
):
if event.value == vm_now.get(field):
return
if field == "type":
self._set_override(
index,
"desktop",
"" if event.value == SERVER else event.value,
)
# « Serveur » est un choix légitime, pas un retrait : on le
# note explicitement pour qu'il tienne face au défaut.
if event.value == SERVER:
key = self._row_key(index)
if key is not None:
self.overrides.setdefault(key, {})["desktop"] = ""
elif event.value is FREE:
self._row_free(index, field, True)
self._set_override(
index, field, self._read_row_free(index, field)
)
elif event.value is not SELECT_NULL:
self._row_free(index, field, False)
self._set_override(index, field, event.value)
self._recompute()
return
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
# Les choix GLOBAUX de branche et de profil ne portent aucune
# valeur de ressource : ils tombaient donc dans le « return »
# ci-dessous sans rien recalculer, et les rangées restaient sur
# l'ancienne version. Elles n'en gardent pas de copie — « » y
# veut dire « celle du formulaire » — il suffit de redessiner.
[ADD] tui qemu: pick the time zone from a list The timezone was typed by hand. A misspelt IANA name is not rejected by cloud-init: it is IGNORED. The VM stays on UTC, and you only notice from the timestamps, once deployed. A list of twenty-five zones, Québec first, then the rest of Canada and the places one actually meets. The host's zone goes to the top, without duplication: a machine outside this list must still see its own at a glance. NAMES, not offsets: "UTC-5" says nothing about daylight saving and cloud-init will not take it. A name carries its own switching rules. "free value…" keeps the door open to the other six hundred zones in the database. The choice is copied into the field, which stays the only value the spec reads -- one place holds the answer. --- FR --- Le fuseau se tapait à la main. Un nom IANA mal orthographié n'est pas refusé par cloud-init : il est IGNORÉ. La VM reste en UTC, et on ne s'en aperçoit qu'aux horodatages, une fois déployée. Une liste de vingt-cinq fuseaux, le Québec d'abord, puis le reste du Canada et les places qu'on rencontre en pratique. Le fuseau de l'hôte passe en tête, sans doublon : une machine hors de cette liste doit voir le sien en un coup d'œil. Des NOMS, pas des décalages : « UTC-5 » ne dit rien de l'heure d'été et cloud-init n'en veut pas. Un nom porte ses propres règles de bascule. « libre… » garde la porte ouverte aux six cents autres fuseaux de la base. Le choix est recopié dans le champ, qui reste la seule valeur lue par la spec — un seul endroit porte la réponse. Assisted-by: Claude Opus 5
2026-08-13 01:56:36 -04:00
if event.select.id == "f_tz_sel":
# « libre… » révèle la saisie ; un fuseau choisi la referme et
# y recopie le nom, seule valeur que lisent _form_values et la
# spec — un seul endroit porte la réponse.
free = event.value is FREE
field = self.query_one("#f_tz", Input)
field.display = free
field.disabled = not free
if free:
field.focus()
elif isinstance(event.value, str):
field.value = event.value
return
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
if event.select.id in ("f_branch", "f_profile_install"):
self._clear_overrides(
("branch",)
if event.select.id == "f_branch"
else ("install_cmd",)
)
self._recompute()
return
field = SELECT_TO_FIELD.get(event.select.id)
if not field:
return
if event.value is FREE:
# La valeur retenue est celle de la saisie, pas ce choix-ci.
self._free[field] = True
self._show_free(field, True)
self.query_one(RES_FIELDS[field][1], Input).focus()
self._apply_free(field)
elif event.value is not SELECT_NULL:
self._free[field] = False
self._show_free(field, False)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
self.custom[field] = event.value
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
self._clear_overrides((field,))
self._recompute()
def _apply_free(self, field) -> None:
"""Relit la saisie libre. Une valeur invalide n'écrase rien : le
profil retombe alors sur celle du catalogue."""
raw = self.query_one(RES_FIELDS[field][1], Input).value.strip()
if field == "disk":
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
self.custom[field] = parse_disk(raw) or ""
elif field == "ram":
self.custom[field] = parse_ram(raw)
else:
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
self.custom[field] = positive_int(raw, 0)
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
self._clear_overrides((field,))
def on_input_changed(self, event) -> None:
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
if self._syncing:
return
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
wid = event.input.id or ""
row = re.match(r"c(\d+)_(vcpus|ram|disk)$", wid)
if row and not self._is_current(event.input):
return
if row:
index, field = int(row.group(1)), row.group(2)
self._set_override(
index, field, self._read_row_free(index, field)
)
self._recompute()
return
field = INPUT_TO_FIELD.get(event.input.id)
if field:
self._apply_free(field)
self._recompute()
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
def _set_lock(self, index, on) -> None:
"""Fige — ou libère — les ressources d'une VM.
Figer, c'est recopier les valeurs EFFECTIVES du moment dans les
surcharges : le profil commun ne les atteint plus. Libérer les
retire, et la VM retombe sous le profil. Le mécanisme est celui
des surcharges, déjà éprouvé ; le verrou n'en est que la commande
explicite, et il couvre les quatre champs d'un coup."""
key = self._row_key(index)
if key is None or index >= len(self.rows):
return
if on:
vm = self.rows[index]["vm"]
self.locked.add(key)
# TOUT ce que la VM tient d'un choix commun est recopié, pas
# seulement les ressources : la branche et le profil Odoo en
# font partie. Les oublier laissait une VM « figée » changer
# de version d'ERPLibre dès qu'on touchait au choix générique,
# ce qui vide le mot de son sens.
#
# Les deux se résolvent AVANT d'être figés : « » y signifie
# « celle du formulaire », et geler une chaîne vide ne
# gèlerait rien du tout.
self.overrides[key] = {
"vcpus": vm["vcpus"],
"ram": vm["ram"],
"disk": vm["disk"],
"desktop": vm.get("desktop") or "",
"branch": vm.get("branch") or self._branch(),
"install_cmd": (
vm.get("install_cmd") or self._profile_cmd()
),
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
"install_label": (
vm.get("install_label")
or (
profiles[self._row_profile_index(index)][0]
if profiles
else ""
)
),
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, 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. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
}
else:
self.locked.discard(key)
self.overrides.pop(key, None)
self._recompute()
# La couleur de la ligne suit le verrou sans tout remonter : un
# remontage volerait le focus à la case qu'on vient de cocher.
cards = self.query_one("#plan", VerticalScroll).children
if index < len(cards):
cards[index].set_class(on, "locked")
btn = self.query_one(f"#l{index}", Button)
btn.label = "🔒" if on else "🔓"
btn.variant = "success" if on else "default"
def _add_copy(self, index, delta) -> None:
"""Ajoute ou retire un exemplaire de l'entrée visée.
Retirer enlève le DERNIER exemplaire, et avec lui ses réglages :
les garder ferait resurgir d'anciennes valeurs à la copie
suivante, sans que rien ne l'explique."""
entries = self._plan_entries()
if index >= len(entries):
return
item = entries[index]
base = (item["distro"], item["version"], item["arch"])
count = self.copies.get(base, 0)
if delta > 0:
self.copies[base] = count + 1
else:
if count <= 0:
return
gone = (*base, count)
self.overrides.pop(gone, None)
self.locked.discard(gone)
self.copies[base] = count - 1
if not self.copies[base]:
self.copies.pop(base, None)
self._recompute()
# Le JEU de VM a changé : les rangées doivent être rebâties.
self._mount_rows()
def on_button_pressed(self, event) -> None:
match = re.match(r"([pm])(\d+)$", event.button.id or "")
if match:
self._add_copy(
int(match.group(2)), 1 if match.group(1) == "p" else -1
)
return
match = re.match(r"r(\d+)$", event.button.id or "")
if match:
self._rename(int(match.group(1)))
return
match = re.match(r"l(\d+)$", event.button.id or "")
if match:
index = int(match.group(1))
key = self._row_key(index)
if key is not None:
self._set_lock(index, key not in self.locked)
def _rename(self, index) -> None:
"""Renomme une VM. Le nom saisi devient une surcharge comme les
autres : il survit au recalcul, et F4 le retire avec le reste."""
key = self._row_key(index)
if key is None or index >= len(self.rows):
return
entries = self._plan_entries()
auto = vm_name(
entries[index]["name"],
self.rows[index]["vm"].get("desktop"),
desktop_suffixes,
)
def done(value):
if value is None:
return
if not str(value).strip():
self.overrides.get(key, {}).pop("name", None)
if not self.overrides.get(key):
self.overrides.pop(key, None)
else:
clean = clean_hostname(value)
if not clean:
self.notify(
t("Invalid name: letters, digits, hyphens."),
severity="error",
)
return
self.overrides.setdefault(key, {})["name"] = clean
self._recompute()
self._mount_rows()
self.push_screen(
RenameScreen(self.rows[index]["vm"]["name"], auto), done
)
def on_checkbox_changed(self, event) -> None:
if event.checkbox.id == "f_install":
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
elif event.checkbox.id == "f_par_all":
self.query_one("#f_par", Select).disabled = event.value
# -- actions ---------------------------------------------------- #
def action_select_all(self) -> None:
self.query_one("#f_catalog", SelectionList).select_all()
def action_select_none(self) -> None:
self.query_one("#f_catalog", SelectionList).deselect_all()
def action_select_main(self) -> None:
"""Une VM par distro : la version marquée par défaut."""
widget = self.query_one("#f_catalog", SelectionList)
widget.deselect_all()
for i, e in enumerate(self._entries()):
if e.get("default"):
widget.select(i)
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
def action_clear_vm(self) -> None:
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
"""Rend au profil commun la VM dont un widget a le focus. Sans
cette sortie, un réglage posé par erreur ne se défaisait qu'en
rouvrant le formulaire."""
index = self._focused_row()
if index is None:
return
key = self._row_key(index)
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
if key is None or key not in self.overrides:
return
self.overrides.pop(key)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Les widgets de la rangée portent encore l'ancienne valeur : on
# les remonte pour qu'ils disent la vérité.
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
self._recompute()
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
self._mount_rows()
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
def _form_values(self):
install = None
if self.query_one("#f_install", Checkbox).value and profiles:
index = self.query_one("#f_profile_install", Select).value
label, cmd = profiles[index if isinstance(index, int) else 0]
install = {
"branch": self.query_one("#f_branch", Select).value,
"prod": self.query_one("#f_prod", Checkbox).value,
"label": label,
"cmd": cmd,
"monitor": self.query_one("#f_monitor", Checkbox).value,
}
key = self.query_one("#f_key", Input).value.strip()
return {
"res_label": (
t("custom")
if self.profile == "custom"
else f"x{self.profile}"
),
"ssh_key": os.path.expanduser(key) if key else "",
# Un champ vidé retombe sur le fuseau de l'hôte plutôt que sur
# rien : sans valeur, la VM démarrerait en UTC.
"timezone": self.query_one("#f_tz", Input).value.strip()
or ctx.get("timezone")
or "",
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
"desktop": self._default_desktop(),
[ADD] tui qemu: choose mise or pyenv at deploy time The interpreter provider is now chosen when creating a VM, in both interfaces. "mise" installs it in the VM and lays down a precompiled CPython; "pyenv" keeps today's build-from-source. The question is only asked where it means something: mise publishes binaries for amd64 and arm64 only. An all-s390x fleet never sees it, a mixed fleet sees it along with the architectures that will fall back to pyenv -- said before deploying, not discovered in a log an hour later. mise goes into /usr/local/bin rather than ~/.local/bin: the remote command runs through "ssh host 'command'", where neither ~/.profile nor ~/.bashrc is read. Choosing "mise" exports "auto", not "mise", so a failed install can still fall back to pyenv. The unavailability notice was translated as "pyenv required", which said the opposite of what happens: pyenv is the fallback, not a demand. --- FR --- Le fournisseur d'interpréteur se choisit désormais à la création d'une VM, dans les deux interfaces. « mise » l'installe dans la VM et pose un CPython précompilé ; « pyenv » garde la compilation d'aujourd'hui. La question n'est posée que là où elle a un sens : mise ne publie de binaires que pour amd64 et arm64. Un parc tout s390x ne la voit jamais, un parc mixte la voit avec les architectures qui retomberont sur pyenv — dit avant de déployer, pas découvert dans un log une heure plus tard. mise va dans /usr/local/bin plutôt que ~/.local/bin : la commande distante passe par « ssh hôte 'commande' », où ni ~/.profile ni ~/.bashrc ne sont lus. Choisir « mise » exporte « auto », pas « mise », pour qu'une installation ratée puisse encore retomber sur pyenv. L'avis d'indisponibilité était traduit par « pyenv exigé », qui disait l'inverse de ce qui se passe : pyenv est le repli, pas une exigence. Assisted-by: Claude Opus 5
2026-08-11 22:11:23 -04:00
"python_provider": self._python_provider(),
[ADD] qemu: choose the application store of a graphical VM snap was not a choice but a fate: we disabled snapd, then gnome-core pulled a Firefox snap that froze the install for thirty minutes. The previous fix imposed "deb". Three answers are now offered. deb nothing but .deb, epiphany-browser as the browser. Default, and lightest: nothing extra to download. flatpak Flatpak tooling as well, WITHOUT the Flathub remote or any install. The machine is ready, the admin picks its remotes. snap Ubuntu's default, snapd left running and Firefox as a snap. It is the only mode where snapd is not disabled -- disabling it was precisely the cause of the freeze. The question is only asked when it means something: at least one graphical VM on a distro shipping snapd, Ubuntu alone here. A server pulls no snap, and Debian ships none. The TUI greys the choice out and says why; the CLI does not ask. Verified on a 26.04 VM: snapd is indeed preinstalled (2.75.2), flatpak and its GNOME Software plugin are packaged, and firefox-esr does not exist on Ubuntu. --- FR --- snap n'était plus un choix mais une fatalité : on coupait snapd, puis gnome-core tirait un Firefox-snap qui figeait l'installation trente minutes. Le correctif précédent imposait « deb ». Trois réponses sont maintenant offertes. deb rien que des .deb, epiphany-browser comme navigateur. Défaut, et le plus léger : rien de plus à télécharger. flatpak l'outillage Flatpak en plus, SANS dépôt Flathub ni installation. La machine est prête, l'administrateur choisit ses dépôts. snap le défaut d'Ubuntu, snapd laissé actif et Firefox en snap. C'est le seul mode où snapd n'est pas coupé — l'y couper était précisément la cause du blocage. La question n'est posée que lorsqu'elle a un sens : au moins une VM graphique sur une distribution qui livre snapd, Ubuntu seule ici. Un serveur ne tire aucun snap, et Debian n'en livre pas. La TUI grise le choix et dit pourquoi ; la CLI ne le demande pas. Vérifié sur une VM 26.04 : snapd y est bien préinstallé (2.75.2), flatpak et son greffon GNOME Software sont empaquetés, et firefox-esr n'existe pas sur Ubuntu. Assisted-by: Claude Opus 5
2026-08-12 03:24:19 -04:00
"app_store": self._app_store(),
"install": install,
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
# Une exécution par installation : le nombre de VM retenues
# fait foi. Le déploiement le borne ensuite à ce même nombre,
# donc une valeur haute ne crée jamais de travailleur inutile.
"parallelism": (
max(1, len(self.vms))
if self.query_one("#f_par_all", Checkbox).value
else self.query_one("#f_par", Select).value
),
}
def action_preview(self) -> None:
spec = build_spec(self.vms, domains, self._form_values())
build = ctx.get("build_command")
if not build:
return
lines = [build(vm, spec, True) for vm in spec["vms"]]
self.push_screen(PreviewScreen(lines or [t("Nothing selected.")]))
[ADD] tui qemu: F9 dumps the form state, widgets and model A per-VM setting appears not to be taken into account, and nothing on screen tells which link failed: the gap may be between the widget and the model, or between the model and the spec. A screenshot shows neither. F9 writes both side by side into ~/.erplibre/deploy-form-dump.txt: profile, shared values, overrides, locks, row generation, then VM by VM what the list DISPLAYS against what the model HOLDS, and finally the spec that would go to deployment. The file can be pasted into a message, unlike an image, and it answers the question on its own: if the list shows 16384 while the model says 1024, the defect is in the intake; if they agree and the created VM differs, it is downstream, in deploy_qemu. --- FR --- Un réglage par VM ne semble pas pris en compte, et rien dans ce que l'écran montre ne permet de trancher : l'écart peut être entre le widget et le modèle, ou entre le modèle et la spec. Une capture d'écran ne dit ni l'un ni l'autre. F9 écrit les deux côte à côte dans ~/.erplibre/deploy-form-dump.txt : profil, valeurs communes, surcharges, verrous, génération des rangées, puis VM par VM ce que la liste AFFICHE contre ce que le modèle CONTIENT, et enfin la spec qui partirait au déploiement. Le fichier se recopie dans un message, contrairement à une image, et il répond seul à la question : si la liste montre 16384 et que le modèle dit 1024, le défaut est dans la prise en compte ; s'ils s'accordent et que la VM créée diffère, il est en aval, dans deploy_qemu. Assisted-by: Claude Opus 5
2026-08-13 04:51:27 -04:00
def action_dump_state(self) -> None:
"""Écrit l'état COMPLET dans un fichier, widgets ET modèle.
Une capture d'écran ne dit pas si l'écart vient de ce qu'on voit
ou de ce qui sera déployé. Ce vidage met les deux côte à côte, VM
par VM : si la liste affiche 16384 et que le modèle dit 1024, le
défaut est dans la prise en compte ; s'ils s'accordent et que la
VM déployée diffère, il est en aval."""
path = os.path.expanduser("~/.erplibre/deploy-form-dump.txt")
os.makedirs(os.path.dirname(path), exist_ok=True)
out = [
"=== formulaire de deploiement ===",
f"profil={self.profile} custom={self.custom}",
f"verrous={sorted(self.locked)}",
f"surcharges={self.overrides}",
f"generation={self._gen} jeu_monte={self._shown_ids}",
f"branche_globale={self._branch()}",
f"profil_odoo_global={self._profile_cmd()}",
"",
" # widget -> modele, VM par VM",
]
for i, r in enumerate(self.rows):
vm = r["vm"]
shown = {}
for f in ("vcpus", "ram", "disk", "type", "branch", "prof"):
try:
shown[f] = self.query_one(f"#v{i}_{f}", Select).value
except Exception:
shown[f] = "-"
try:
free = self.query_one(f"#c{i}_{f}", Input)
if free.display:
shown[f] = f"libre:{free.value!r}"
except Exception:
pass
out.append(f" [{i}] {vm['name']} etat={r['state']}")
out.append(f" widgets = {shown}")
out.append(
f" modele = vcpus={vm['vcpus']} ram={vm['ram']} "
f"disk={vm['disk']} desktop={vm.get('desktop')!r} "
f"branch={vm.get('branch')!r} cmd={vm.get('install_cmd')!r}"
)
spec = build_spec(self.vms, domains, self._form_values())
out.append("")
out.append(" # spec qui partirait au deploiement")
for vm in spec["vms"]:
out.append(
f" {vm['name']}: ram={vm['ram']} vcpus={vm['vcpus']} "
f"disk={vm['disk']}"
)
out.append(f" ignorees (existent deja) = {spec['existing']}")
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(out) + "\n")
self.notify(f"{t('State written to')} {path}")
def action_deploy(self) -> None:
if not self.vms:
self.notify(t("Nothing selected."), severity="warning")
return
spec = build_spec(self.vms, domains, self._form_values())
if not spec["vms"]:
self.notify(
t("Nothing to create - every VM already exists."),
severity="warning",
)
return
orphans = [r for r in self.rows if r["state"] == "orphan"]
if orphans and not getattr(self, "_orphan_ack", False):
# Un qcow2 orphelin fait échouer deploy_qemu : on prévient une
# première fois, F5 à nouveau vaut confirmation.
self._orphan_ack = True
self.notify(
t("orphan disk - will FAIL")
+ f" ({len(orphans)}) — "
+ t("press F5 again to confirm"),
severity="error",
timeout=10,
)
return
result["spec"] = spec
self.exit()
def action_cancel(self) -> None:
self.exit()
app = DeployForm()
# Exposé pour les tests headless (run_app=False), qui pilotent l'app
# eux-mêmes et ont besoin de lire la spec produite.
app._result = result
if not run_app:
return app
app.run()
return result["spec"]
# --------------------------------------------------------------------------- #
# Vue de progression : un bloc repliable par VM
# --------------------------------------------------------------------------- #
def run_deploy_progress(jobs, parallelism, run_app: bool = True):
"""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."""
import subprocess
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 = [
("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}
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(
f" {t('c copy log · C copy all · q quit')}", id="hint"
)
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):
self.query_one("#summary", Static).update(
f" {self._done}/{len(jobs)} — "
f"{fmt_dur(time.time() - self._t0)}"
)
# `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()
try:
res = subprocess.run(
parts, capture_output=True, text=True
)
rc = res.returncode
out = (res.stdout or "") + (res.stderr or "")
except (OSError, subprocess.SubprocessError) as exc:
rc, out = 1, str(exc)
self.call_from_thread(
self._finish, jid, name, rc, out, time.time() - t0
)
self.run_worker(_job, thread=True, group="deploy", exclusive=False)
def _finish(self, jid, name, rc, out, secs):
self._out[name] = out
results.append((name, rc, out, secs))
self._done += 1
log = self.query_one(f"#log_{slug(name)}", RichLog)
for line in out.strip().splitlines():
log.write(line)
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()
# -- 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