2026-08-07 03:22:26 -04:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
"""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
|
2026-08-07 03:22:26 -04:00
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
except Exception: # pragma: no cover - repli si i18n indisponible
|
|
|
|
|
|
|
|
|
|
|
|
def t(key: str) -> str:
|
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# Logique pure — aucune dépendance à Textual, testable telle quelle
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def entry_key(entry) -> tuple:
|
|
|
|
|
|
"""Identité stable d'une entrée de catalogue, indépendante de son rang :
|
|
|
|
|
|
les surcharges par VM y survivent quand la sélection change."""
|
|
|
|
|
|
return (entry["distro"], entry["version"], entry["arch"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] tui qemu: the form crashed on the resource totals
"TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection'"
as soon as the custom resource profile was picked.
Textual 8 turned Select.BLANK into a deprecated alias worth False, the
sentinel having become Select.NULL. The guard compared against False and
filtered nothing; NoSelection, lacking __bool__ and thus truthy, sailed
through "or default" into the plan totals.
The sentinel is now resolved at runtime, and both summed fields are
coerced to a positive int inside the pure function -- where the invariant
belongs, whatever Textual version is installed.
--- FR ---
« TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection' »
dès qu'on choisissait le profil personnalisé.
Textual 8 a ramené Select.BLANK à un alias déprécié valant False, le
sentinelle étant devenu Select.NULL. La garde comparait donc à False et
ne filtrait plus rien ; NoSelection, dépourvu de __bool__ et tenu pour
vrai, traversait « ou valeur par défaut » jusqu'aux sommes du plan.
Le sentinelle est résolu à l'exécution, et les deux champs additionnés
sont ramenés à un entier positif dans la fonction pure — c'est là que
l'invariant appartient, quelle que soit la version de Textual.
Assisted-by: Claude Opus 5
2026-08-11 06:57:23 -04:00
|
|
|
|
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=""
|
|
|
|
|
|
):
|
2026-08-07 03:22:26 -04:00
|
|
|
|
"""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 {}
|
[FIX] tui qemu: the form crashed on the resource totals
"TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection'"
as soon as the custom resource profile was picked.
Textual 8 turned Select.BLANK into a deprecated alias worth False, the
sentinel having become Select.NULL. The guard compared against False and
filtered nothing; NoSelection, lacking __bool__ and thus truthy, sailed
through "or default" into the plan totals.
The sentinel is now resolved at runtime, and both summed fields are
coerced to a positive int inside the pure function -- where the invariant
belongs, whatever Textual version is installed.
--- FR ---
« TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection' »
dès qu'on choisissait le profil personnalisé.
Textual 8 a ramené Select.BLANK à un alias déprécié valant False, le
sentinelle étant devenu Select.NULL. La garde comparait donc à False et
ne filtrait plus rien ; NoSelection, dépourvu de __bool__ et tenu pour
vrai, traversait « ou valeur par défaut » jusqu'aux sommes du plan.
Le sentinelle est résolu à l'exécution, et les deux champs additionnés
sont ramenés à un entier positif dans la fonction pure — c'est là que
l'invariant appartient, quelle que soit la version de Textual.
Assisted-by: Claude Opus 5
2026-08-11 06:57:23 -04:00
|
|
|
|
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)
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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,
|
2026-08-07 03:22:26 -04:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
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] 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
|
|
|
|
):
|
2026-08-07 03:22:26 -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),
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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.
|
|
|
|
|
|
for vm in vms:
|
|
|
|
|
|
vm["name"] = vm_name(vm["name"], vm.get("desktop"), suffixes)
|
|
|
|
|
|
return vms
|
2026-08-07 03:22:26 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"],
|
[IMP] qemu: a VM one can reach, and a first boot that does not stall
The monitor froze the address given at launch, so it lost the VM as soon as
cloud-init renamed the host and DHCP handed out another lease. It now
re-resolves at each attempt, in the views too, and reads virsh without sudo
— « sudo -n » fails in a detached session with no tty.
First boot also stopped paying for what it does not need: the guest agent
leaves cloud-init, snapd and locale-gen go, apt takes the fastest mirror.
The timezone follows the host. And when KVM is missing, the deployment says
so before the wait instead of being mysteriously fifteen times slower.
--- FR ---
Le suivi figeait l'adresse connue au lancement : il perdait donc la VM dès
que cloud-init posait le vrai nom d'hôte et que DHCP donnait un autre bail.
Il la ré-résout désormais à chaque tentative, dans les vues aussi, et lit
virsh sans sudo — « sudo -n » échoue dans une session détachée, sans tty.
Le premier démarrage cesse aussi de payer l'inutile : l'agent invité sort
de cloud-init, snapd et locale-gen disparaissent, apt prend le miroir le
plus rapide. Le fuseau suit l'hôte. Et faute de KVM, le déploiement le dit
avant l'attente, au lieu d'être quinze fois plus lent sans raison visible.
Assisted-by: Claude Opus 5
2026-08-10 03:10:50 -04:00
|
|
|
|
"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"),
|
2026-08-07 03:22:26 -04:00
|
|
|
|
"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 (
|
|
|
|
|
|
Checkbox,
|
|
|
|
|
|
Footer,
|
|
|
|
|
|
Header,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
RadioButton,
|
|
|
|
|
|
RadioSet,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectionList,
|
|
|
|
|
|
Static,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
[FIX] tui qemu: the form crashed on the resource totals
"TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection'"
as soon as the custom resource profile was picked.
Textual 8 turned Select.BLANK into a deprecated alias worth False, the
sentinel having become Select.NULL. The guard compared against False and
filtered nothing; NoSelection, lacking __bool__ and thus truthy, sailed
through "or default" into the plan totals.
The sentinel is now resolved at runtime, and both summed fields are
coerced to a positive int inside the pure function -- where the invariant
belongs, whatever Textual version is installed.
--- FR ---
« TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection' »
dès qu'on choisissait le profil personnalisé.
Textual 8 a ramené Select.BLANK à un alias déprécié valant False, le
sentinelle étant devenu Select.NULL. La garde comparait donc à False et
ne filtrait plus rien ; NoSelection, dépourvu de __bool__ et tenu pour
vrai, traversait « ou valeur par défaut » jusqu'aux sommes du plan.
Le sentinelle est résolu à l'exécution, et les deux champs additionnés
sont ramenés à un entier positif dans la fonction pure — c'est là que
l'invariant appartient, quelle que soit la version de Textual.
Assisted-by: Claude Opus 5
2026-08-11 06:57:23 -04:00
|
|
|
|
# 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)
|
|
|
|
|
|
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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 ())
|
2026-08-07 03:22:26 -04:00
|
|
|
|
defaults = ctx.get("defaults") or {}
|
|
|
|
|
|
result = {"spec": None}
|
|
|
|
|
|
|
|
|
|
|
|
AUTO = "__auto__"
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
# 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__"
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
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()}
|
2026-08-07 03:22:26 -04:00
|
|
|
|
|
|
|
|
|
|
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']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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; }
|
|
|
|
|
|
#plan { height: 1fr; border: solid $accent; }
|
|
|
|
|
|
#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;
|
2026-08-07 03:22:26 -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
|
|
|
|
/* 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: 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
|
|
|
|
.vmbranch { width: 18; }
|
[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; }
|
|
|
|
|
|
.vmrow { height: 3; align-vertical: middle; }
|
|
|
|
|
|
.vmrow Select { width: 15; }
|
|
|
|
|
|
.vmrow Input { width: 11; }
|
|
|
|
|
|
#reslabel { color: $text-muted; }
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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")),
|
2026-08-07 03:22:26 -04:00
|
|
|
|
("f3", "preview", t("Preview")),
|
|
|
|
|
|
("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 = {}
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
self._free = {}
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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 = []
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-08-07 03:22:26 -04:00
|
|
|
|
with RadioSet(id="f_profile"):
|
|
|
|
|
|
for label in ("x1", "x2", "x3", "x4"):
|
|
|
|
|
|
yield RadioButton(label, value=label == "x1")
|
|
|
|
|
|
yield RadioButton(t("custom"))
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
# 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.
|
2026-08-07 03:22:26 -04:00
|
|
|
|
yield Select(
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
[(str(c), c) for c in ctx["cpu_presets"]]
|
|
|
|
|
|
+ [(t("free value…"), FREE)],
|
2026-08-07 03:22:26 -04:00
|
|
|
|
prompt=t("vCPU"),
|
|
|
|
|
|
id="f_vcpus",
|
|
|
|
|
|
disabled=True,
|
|
|
|
|
|
)
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
yield Input(
|
|
|
|
|
|
placeholder=t("vCPU"),
|
|
|
|
|
|
id="c_vcpus",
|
|
|
|
|
|
classes="freeval",
|
|
|
|
|
|
disabled=True,
|
|
|
|
|
|
)
|
2026-08-07 03:22:26 -04:00
|
|
|
|
yield Select(
|
|
|
|
|
|
[
|
|
|
|
|
|
(f"{m} ({m // 1024}G)", m)
|
|
|
|
|
|
for m in ctx["ram_presets"]
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
]
|
|
|
|
|
|
+ [(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"),
|
2026-08-07 03:22:26 -04:00
|
|
|
|
id="f_ram",
|
|
|
|
|
|
disabled=True,
|
|
|
|
|
|
)
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
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"),
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
id="c_ram",
|
|
|
|
|
|
classes="freeval",
|
|
|
|
|
|
disabled=True,
|
|
|
|
|
|
)
|
2026-08-07 03:22:26 -04:00
|
|
|
|
yield Select(
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
[(d, d) for d in ctx["disk_presets"]]
|
|
|
|
|
|
+ [(t("free value…"), FREE)],
|
2026-08-07 03:22:26 -04:00
|
|
|
|
prompt=t("Disk"),
|
|
|
|
|
|
id="f_disk",
|
|
|
|
|
|
disabled=True,
|
|
|
|
|
|
)
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
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")
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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)],
|
[FIX] tui qemu: the form crashed on the resource totals
"TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection'"
as soon as the custom resource profile was picked.
Textual 8 turned Select.BLANK into a deprecated alias worth False, the
sentinel having become Select.NULL. The guard compared against False and
filtered nothing; NoSelection, lacking __bool__ and thus truthy, sailed
through "or default" into the plan totals.
The sentinel is now resolved at runtime, and both summed fields are
coerced to a positive int inside the pure function -- where the invariant
belongs, whatever Textual version is installed.
--- FR ---
« TypeError: unsupported operand type(s) for +: 'int' and 'NoSelection' »
dès qu'on choisissait le profil personnalisé.
Textual 8 a ramené Select.BLANK à un alias déprécié valant False, le
sentinelle étant devenu Select.NULL. La garde comparait donc à False et
ne filtrait plus rien ; NoSelection, dépourvu de __bool__ et tenu pour
vrai, traversait « ou valeur par défaut » jusqu'aux sommes du plan.
Le sentinelle est résolu à l'exécution, et les deux champs additionnés
sont ramenés à un entier positif dans la fonction pure — c'est là que
l'invariant appartient, quelle que soit la version de Textual.
Assisted-by: Claude Opus 5
2026-08-11 06:57:23 -04:00
|
|
|
|
value=0 if profiles else SELECT_NULL,
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
[IMP] qemu: a VM one can reach, and a first boot that does not stall
The monitor froze the address given at launch, so it lost the VM as soon as
cloud-init renamed the host and DHCP handed out another lease. It now
re-resolves at each attempt, in the views too, and reads virsh without sudo
— « sudo -n » fails in a detached session with no tty.
First boot also stopped paying for what it does not need: the guest agent
leaves cloud-init, snapd and locale-gen go, apt takes the fastest mirror.
The timezone follows the host. And when KVM is missing, the deployment says
so before the wait instead of being mysteriously fifteen times slower.
--- FR ---
Le suivi figeait l'adresse connue au lancement : il perdait donc la VM dès
que cloud-init posait le vrai nom d'hôte et que DHCP donnait un autre bail.
Il la ré-résout désormais à chaque tentative, dans les vues aussi, et lit
virsh sans sudo — « sudo -n » échoue dans une session détachée, sans tty.
Le premier démarrage cesse aussi de payer l'inutile : l'agent invité sort
de cloud-init, snapd et locale-gen disparaissent, apt prend le miroir le
plus rapide. Le fuseau suit l'hôte. Et faute de KVM, le déploiement le dit
avant l'attente, au lieu d'être quinze fois plus lent sans raison visible.
Assisted-by: Claude Opus 5
2026-08-10 03:10:50 -04:00
|
|
|
|
yield Static(t("Timezone"), classes="grouptitle")
|
|
|
|
|
|
yield Input(
|
|
|
|
|
|
value=ctx.get("timezone") or "",
|
|
|
|
|
|
placeholder=t("Timezone for the VMs"),
|
|
|
|
|
|
id="f_tz",
|
|
|
|
|
|
)
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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")
|
2026-08-07 03:22:26 -04:00
|
|
|
|
yield Static(t("Parallelism"), classes="grouptitle")
|
2026-08-11 08:59:10 -04:00
|
|
|
|
# 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",
|
|
|
|
|
|
)
|
2026-08-07 03:22:26 -04:00
|
|
|
|
yield Select(
|
|
|
|
|
|
[(str(n), n) for n in range(1, host_cpu + 1)],
|
2026-08-11 08:59:10 -04:00
|
|
|
|
value=host_cpu,
|
2026-08-07 03:22:26 -04:00
|
|
|
|
allow_blank=False,
|
2026-08-11 08:59:10 -04:00
|
|
|
|
disabled=True,
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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")
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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 ------------------------------------- #
|
|
|
|
|
|
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):
|
|
|
|
|
|
entries = self._selected_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,
|
2026-08-07 03:22:26 -04:00
|
|
|
|
)
|
[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
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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)
|
2026-08-07 03:22:26 -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 _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é."""
|
|
|
|
|
|
return tuple(entry_key(e) for e in self._selected_entries())
|
|
|
|
|
|
|
|
|
|
|
|
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):
|
2026-08-07 03:22:26 -04:00
|
|
|
|
vm = r["vm"]
|
[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(self._selected_entries()[i])
|
[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",
|
|
|
|
|
|
value=(
|
|
|
|
|
|
item.get("branch")
|
|
|
|
|
|
or self.rows[i]["vm"].get("branch")
|
|
|
|
|
|
or branches[0]
|
|
|
|
|
|
),
|
|
|
|
|
|
allow_blank=False,
|
|
|
|
|
|
id=f"v{i}_branch",
|
|
|
|
|
|
),
|
[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",
|
2026-08-07 03:22:26 -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
|
|
|
|
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()),
|
|
|
|
|
|
):
|
|
|
|
|
|
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()
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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 ""
|
|
|
|
|
|
)
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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}"
|
2026-08-07 03:22:26 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# -- 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",))
|
[UPD] tui qemu: the plan shows each VM type
The server-or-desktop choice only showed on the left, in the form. The
table on the right, the one read before launching, said nothing about it:
two visually identical plans could produce different VMs.
The Status column, empty until now for a VM to create, therefore carries
the type, and the totals line repeats it. An already defined VM keeps its
collision message: it is not touched, showing a desktop there would
suggest one is about to be installed on it.
--- FR ---
Le choix serveur ou bureau ne se voyait qu'à gauche, dans le formulaire.
Le tableau de droite, celui qu'on relit avant de lancer, n'en disait
rien : deux plans identiques à l'écran pouvaient produire des VM
différentes.
La colonne Statut, vide jusqu'ici pour une VM à créer, porte donc le
type, et la ligne de totaux le rappelle. Une VM déjà définie garde son
message de collision : elle n'est pas retouchée, lui afficher un bureau
laisserait croire qu'on va lui en poser un.
Assisted-by: Claude Opus 5
2026-08-11 18:55:52 -04:00
|
|
|
|
# Recalcul : le disque annonce inclut le bureau, et la
|
|
|
|
|
|
# colonne Statut affiche le type de VM.
|
|
|
|
|
|
self._recompute()
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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))
|
2026-08-07 03:22:26 -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
|
|
|
|
# -- 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):
|
|
|
|
|
|
entries = self._selected_entries()
|
|
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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"]
|
|
|
|
|
|
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.
|
|
|
|
|
|
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
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
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,))
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
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)
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
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,))
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
|
|
|
|
|
|
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
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
field = INPUT_TO_FIELD.get(event.input.id)
|
2026-08-07 03:22:26 -04:00
|
|
|
|
if field:
|
[ADD] tui qemu: free values for vCPU, RAM and disk
The CLI already took a typed value -- a letter picks a suggestion, a
digit stands for itself. The form, though, locked the custom profile
into its three dropdowns.
Each now gets a final "free value..." entry revealing an input below it.
An invalid entry overwrites nothing: the resource falls back to the
catalog value instead of freezing the view.
The 3 vCPU preset was missing too, between 2 and 4; it comes from the
same constant, so both interfaces gain it together.
--- FR ---
La CLI acceptait déjà une valeur tapée — une lettre choisit une
suggestion, un chiffre vaut pour lui-même. Le formulaire, lui, enfermait
le profil personnalisé dans ses trois listes déroulantes.
Chacune reçoit donc un dernier choix, « valeur libre… », qui révèle une
saisie sous elle. Une entrée invalide n'écrase rien : la ressource
retombe sur celle du catalogue plutôt que de bloquer la vue.
Le préréglage 3 vCPU manquait aussi, entre 2 et 4 ; il vient de la même
constante, donc les deux interfaces l'offrent ensemble.
Assisted-by: Claude Opus 5
2026-08-11 06:57:49 -04:00
|
|
|
|
self._apply_free(field)
|
2026-08-07 03:22:26 -04:00
|
|
|
|
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()
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-07 03:22:26 -04:00
|
|
|
|
def on_checkbox_changed(self, event) -> None:
|
|
|
|
|
|
if event.checkbox.id == "f_install":
|
|
|
|
|
|
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
|
2026-08-11 08:59:10 -04:00
|
|
|
|
elif event.checkbox.id == "f_par_all":
|
|
|
|
|
|
self.query_one("#f_par", Select).disabled = event.value
|
2026-08-07 03:22:26 -04:00
|
|
|
|
|
|
|
|
|
|
# -- 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
|
|
|
|
|
2026-08-07 03:22:26 -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 "",
|
[IMP] qemu: a VM one can reach, and a first boot that does not stall
The monitor froze the address given at launch, so it lost the VM as soon as
cloud-init renamed the host and DHCP handed out another lease. It now
re-resolves at each attempt, in the views too, and reads virsh without sudo
— « sudo -n » fails in a detached session with no tty.
First boot also stopped paying for what it does not need: the guest agent
leaves cloud-init, snapd and locale-gen go, apt takes the fastest mirror.
The timezone follows the host. And when KVM is missing, the deployment says
so before the wait instead of being mysteriously fifteen times slower.
--- FR ---
Le suivi figeait l'adresse connue au lancement : il perdait donc la VM dès
que cloud-init posait le vrai nom d'hôte et que DHCP donnait un autre bail.
Il la ré-résout désormais à chaque tentative, dans les vues aussi, et lit
virsh sans sudo — « sudo -n » échoue dans une session détachée, sans tty.
Le premier démarrage cesse aussi de payer l'inutile : l'agent invité sort
de cloud-init, snapd et locale-gen disparaissent, apt prend le miroir le
plus rapide. Le fuseau suit l'hôte. Et faute de KVM, le déploiement le dit
avant l'attente, au lieu d'être quinze fois plus lent sans raison visible.
Assisted-by: Claude Opus 5
2026-08-10 03:10:50 -04:00
|
|
|
|
# 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(),
|
2026-08-07 03:22:26 -04:00
|
|
|
|
"install": install,
|
|
|
|
|
|
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
|
2026-08-11 08:59:10 -04:00
|
|
|
|
# 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
|
|
|
|
|
|
),
|
2026-08-07 03:22:26 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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.")]))
|
|
|
|
|
|
|
|
|
|
|
|
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
|