erplibre/script/todo/qemu_deploy_form.py

1051 lines
48 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Formulaire Textual de déploiement QEMU, et vue de progression.
Deux interfaces mènent au même déploiement : les invites en ligne de
`todo.py` et ce formulaire. Toutes deux produisent la MÊME structure — la
« spec » — que `TODO._qemu_run_spec` consomme. Rien n'est décidé ici qui ne
puisse l'être là-bas, et réciproquement.
- build_vms(...) / plan_rows(...) : logique pure, testable sans terminal.
- run_deploy_form(ctx, run_app=True) : le formulaire ; renvoie une spec ou None.
- run_deploy_progress(jobs, ...) : blocs repliables par VM pendant le
déploiement, avec copie du log vers le presse-papiers (OSC 52).
Le formulaire ne lance AUCUNE commande privilégiée ni réseau : tout appel
`virsh` passe par sudo et une invite de mot de passe casserait l'affichage
Textual. Les données coûteuses (domaines existants, branches distantes) sont
préchargées par l'appelant et arrivent dans `ctx`.
"""
from __future__ import annotations
import os
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
import re
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
from script.todo.deploy_form_lib import ( # noqa: F401
CLIP_LIMIT,
CSS_BASE,
FREE,
INPUT_TO_FIELD,
RES_FIELDS,
RES_FMT,
SELECT_TO_FIELD,
apply_overrides,
apply_profile,
[FIX] deploy : la branche du dépôt, et la sortie pendant qu'elle arrive Trois défauts rapportés sur un déploiement Proxmox. La branche proposée était « dependabot/pip/aiobotocore-3.1.3 » : « git ls-remote » rend la liste par ordre alphabétique, et l'écran en prenait la première. Il propose maintenant la branche du DÉPÔT — celle qu'on a sous les yeux — puis develop, puis master ; la liste met ces trois-là en tête et relègue les branches de robot à la fin. Les deux écrans partagent la règle. La vue de progression n'affichait la sortie qu'à la FIN du travail : « subprocess.run » ne rend rien avant. Sur Proxmox, une VM demande le téléchargement de 325 Mio puis l'import du disque — plusieurs minutes de bloc vide. Elle lit maintenant ligne à ligne. Et « s » y ouvre un ssh sur la VM créée, par son nom, donc par l'entrée ~/.ssh/config qui porte le rebond. --- EN --- Three defects reported on a Proxmox deployment. The branch offered was "dependabot/pip/aiobotocore-3.1.3": "git ls-remote" returns the list in alphabetical order and the screen took its first entry. It now offers the CHECKOUT's branch — the one in front of you — then develop, then master; the list puts those three first and pushes robot branches to the end. Both screens share the rule. The progress view only showed output at the END of a job: "subprocess.run" returns nothing before that. On Proxmox a VM needs a 325 MiB download then a disk import — minutes of an empty block. It now reads line by line. And "s" opens an ssh to the created VM, by name, hence through the ~/.ssh/config entry that carries the jump. Assisted-by: Claude Opus 5
2026-08-24 05:38:28 -04:00
branch_default,
branch_order,
build_spec,
build_vms,
clean_hostname,
clip_payload,
copy_name,
disk_gb,
disk_note,
entry_key,
expand_copies,
fmt_dur,
gib,
parse_disk,
parse_ram,
plan_rows,
plan_totals,
positive_int,
res_choices,
res_labels,
res_row_widgets,
res_value,
run_deploy_progress,
vm_name,
vm_status,
)
# Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté
# tel quel : les appelants historiques importent encore ces noms ICI.
[REF] déploiement : la branche, le profil et le type se choisissent par VM Sur Proxmox on déploie le plus souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM ERPLibre. C'est exactement le cas où un réglage par machine sert, et c'est le seul écran qui ne l'offrait pas : ses rangées n'avaient ni branche, ni profil, ni type. Les trois choix et leur gestionnaire — quatre-vingts lignes — rejoignent le socle. Les dupliquer aurait remis en place le mécanisme de dérive qu'on vient d'enlever. L'écran QEMU/KVM perd encore 130 lignes sans qu'un widget, un modèle ou une spec ne bouge : ancien et nouveau montés dans le même processus, mêmes rangées, mêmes valeurs après avoir changé une branche, un type et un profil. Le déploiement suit : il lisait la seule valeur commune alors que le plan portait déjà le choix par rangée. Une seule VM qui s'écarte suffit à rendre la carte nécessaire — « len(set) > 1 » ne l'aurait pas vu. Un défaut trouvé par un test, pas à l'usage : l'écho du montage se reconnaissait à sa commande, or quand la commande imposée par le système n'est pas dans la liste proposée, la liste retombe au rang 0 — et l'écho de ce rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait ERPLibre et Odoo 18. L'écho se reconnaît maintenant au RANG affiché. --- EN --- On Proxmox you usually deploy a MIXED fleet — a nested hypervisor next to ERPLibre VMs. That is exactly where a per-machine setting earns its keep, and it was the only screen without one: its rows had no branch, no profile, no type. The three choices and their handler — eighty lines — move into the shared foundation. Duplicating them would have restored the very drift mechanism we just removed. The QEMU/KVM screen loses another 130 lines with no widget, model or spec moving: old and new mounted in one process, same rows, same values after changing a branch, a type and a profile. The deployment follows: it read the single common value while the plan already carried the per-row choice. One VM that differs is enough to require the map — "len(set) > 1" would not have seen it. One defect found by a test, not by use: the mount echo was recognised by its command, yet when the command imposed by the guest OS is absent from the offered list, the list falls back to index 0 — and that index-0 echo erased the imposition. A nested Proxmox took ERPLibre and Odoo 18 back. The echo is now recognised by the DISPLAYED index. Assisted-by: Claude Opus 5
2026-08-24 23:31:13 -04:00
from script.todo.deploy_form_extras import SERVER, ExtrasMixin
from script.todo.deploy_form_plan import ( # noqa: F401
PlanMixin,
preview_screen,
rename_screen,
)
# --------------------------------------------------------------------------- #
# 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.widgets import (
[ADD] tui qemu: deploy several copies of one entry A "+" per row adds a copy of the entry, a "−" removes it. The "−" only appears on a copy: the original cannot be deleted by mistake, it is unticked in the catalogue. The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3. The first keeps the catalogue name, so existing deployments keep theirs. The real work is in identity. entry_key was (distro, version, arch): two copies shared it, so setting the first set the second and their locks fought each other. It now carries the instance number, and overrides as well as locks follow the right machine. Each instance is a COPY of the dictionary, never a shared reference. Removing a copy forgets its settings: keeping them would resurrect old values on the next copy, with nothing to explain it. --- FR --- Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le « − » n'apparaît que sur une copie : l'original ne se supprime pas par mégarde, il se décoche au catalogue. Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le premier garde le nom du catalogue, pour que les déploiements existants gardent le leur. Le vrai travail est dans l'identité. entry_key valait (distro, version, arch) : deux copies la partageaient, donc régler la première réglait la seconde et leurs verrous se marchaient dessus. Elle porte maintenant le numéro d'exemplaire, et surcharges comme verrous suivent la bonne machine. Chaque exemplaire est une COPIE du dictionnaire, jamais une référence partagée. Retirer une copie oublie ses réglages : les garder ferait resurgir d'anciennes valeurs à la copie suivante, sans que rien ne l'explique. Assisted-by: Claude Opus 5
2026-08-13 00:37:45 -04:00
Button,
Checkbox,
Footer,
Header,
Input,
RadioButton,
RadioSet,
Select,
SelectionList,
Static,
)
# Textual 8 a ramené Select.BLANK à un alias déprécié valant False ; le
# sentinelle « rien de choisi » est Select.NULL. Comparer à BLANK ne
# filtrait donc plus rien, et NoSelection — dépourvu de __bool__, donc
# tenu pour vrai — passait pour une valeur jusque dans les totaux.
SELECT_NULL = getattr(Select, "NULL", Select.BLANK)
catalog = ctx["catalog"]
arches = ctx["arches"]
domains = set(ctx.get("domains") or [])
profiles = ctx.get("install_profiles") or []
# {système: (libellé, commande)} — ce qu'un système impose d'installer.
distro_profiles = ctx.get("distro_profiles") or {}
# Les commandes qui ne posent PAS ERPLibre : sa marge disque ne les suit
# pas. DÉDUITES des profils imposés — une seconde clé de contexte à tenir
# en accord avec la première aurait fini par en différer, et la marge
# serait revenue sans qu'on le voie. Jugé sur la commande effective de la
# rangée : un choix explicite compte donc autant que la règle du système.
no_erplibre = {
impose[1].strip() for impose in distro_profiles.values() if impose
}
[FIX] deploy : la branche du dépôt, et la sortie pendant qu'elle arrive Trois défauts rapportés sur un déploiement Proxmox. La branche proposée était « dependabot/pip/aiobotocore-3.1.3 » : « git ls-remote » rend la liste par ordre alphabétique, et l'écran en prenait la première. Il propose maintenant la branche du DÉPÔT — celle qu'on a sous les yeux — puis develop, puis master ; la liste met ces trois-là en tête et relègue les branches de robot à la fin. Les deux écrans partagent la règle. La vue de progression n'affichait la sortie qu'à la FIN du travail : « subprocess.run » ne rend rien avant. Sur Proxmox, une VM demande le téléchargement de 325 Mio puis l'import du disque — plusieurs minutes de bloc vide. Elle lit maintenant ligne à ligne. Et « s » y ouvre un ssh sur la VM créée, par son nom, donc par l'entrée ~/.ssh/config qui porte le rebond. --- EN --- Three defects reported on a Proxmox deployment. The branch offered was "dependabot/pip/aiobotocore-3.1.3": "git ls-remote" returns the list in alphabetical order and the screen took its first entry. It now offers the CHECKOUT's branch — the one in front of you — then develop, then master; the list puts those three first and pushes robot branches to the end. Both screens share the rule. The progress view only showed output at the END of a job: "subprocess.run" returns nothing before that. On Proxmox a VM needs a 325 MiB download then a disk import — minutes of an empty block. It now reads line by line. And "s" opens an ssh to the created VM, by name, hence through the ~/.ssh/config entry that carries the jump. Assisted-by: Claude Opus 5
2026-08-24 05:38:28 -04:00
branches = branch_order(
ctx.get("branches") or ["master"], ctx.get("branch_current")
)
host_cpu = ctx.get("host_cpu") or 2
free_ram = ctx.get("free_ram") or 0
free_disk = ctx.get("free_disk") or 0
total_disk = ctx.get("total_disk") or 0
base_vcpus = ctx.get("base_vcpus") or 2
extra_disk = ctx.get("extra_disk_gb") or 0
[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 {})
defaults = ctx.get("defaults") or {}
result = {"spec": None}
AUTO = "__auto__"
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']}"
)
[REF] déploiement : un seul socle pour ce qui décrit le système invité Type de VM, production, magasin d'applications, outils de développement, fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas la machine qui le porte. Ils valaient donc déjà sur Proxmox — l'écran n'en offrait que trois. Une VM créée là-bas naissait serveur nu, sans outils et en UTC, et rien ne le disait. La duplication était le mécanisme de la dérive : chaque correctif se posait sur un seul des deux écrans. Les six vivent maintenant dans un socle commun, widgets ET logique, et le contexte qui les nourrit est écrit une fois. L'écran QEMU/KVM perd 330 lignes sans qu'un widget ni une valeur de spec ne bouge — vérifié en montant l'ancien et le nouveau dans le même processus. Trois conséquences se sont propagées seules : le disque annoncé (bureau et outils compris) atteint enfin « qm resize », le parallélisme suit les cœurs de l'hôte au lieu d'un plafond de quatre, et le nom prend le suffixe du bureau — sans lui, une VM graphique et sa jumelle serveur se disputaient le même. Deux défauts nommés au passage. Le fuseau : « qm set » n'en pose pas, il part maintenant par ssh avant l'installation. Et l'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que les domaines d'ici — une VM ARM prise pour x86_64 recevait Android Studio, que Google ne publie pas pour elle. Le test porte sur la PARITÉ, pas sur six comportements : ajouter un réglage à un seul écran le fait échouer. Il a d'abord échoué à se lancer — hors des préfixes du lanceur, douze tests n'ont jamais tourné et le total n'avait pas bougé. La règle de nommage est maintenant dans son en-tête. --- EN --- VM type, production, app store, development tools, timezone, Python interpreter: six settings that describe the guest, not the machine hosting it. They already applied to Proxmox — the screen offered three. A VM created there was born a bare server, no tools, in UTC, and nothing said so. Duplication was the mechanism of the drift: each fix landed on one screen only. The six now live in a shared foundation, widgets AND logic, and the context feeding them is written once. The QEMU/KVM screen loses 330 lines with no widget and no spec value moving — verified by mounting the old and the new in one process. Three consequences followed on their own: the announced disk (desktop and tools included) finally reaches "qm resize", parallelism follows the host's cores instead of a cap of four, and the name takes the desktop suffix — without it a graphical VM and its server twin fought over the same one. Two defects named along the way. The timezone: "qm set" sets none, it now goes over ssh before the install. And a Proxmox VM's architecture came from "virsh", which only knows local domains — an ARM VM taken for x86_64 got Android Studio, which Google does not publish for it. The test covers PARITY, not six behaviours: adding a setting to one screen alone fails it. It first failed to run at all — outside the runner's prefixes, twelve tests never ran and the total had not moved. The naming rule is now in its header. Assisted-by: Claude Opus 5
2026-08-24 22:45:50 -04:00
class DeployForm(ExtrasMixin, PlanMixin, App):
# Le socle porte la mise en page et les modales ; ne reste ici que ce
# qui nomme les widgets propres à QEMU/KVM.
CSS = (
CSS_BASE
+ """
SelectionList { height: 10; border: solid $panel; }
RadioSet { height: auto; layout: horizontal; }
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
/* Ces deux règles portent « .vmrow Select » EN PLUS de leur classe :
« .vmrow Select » (une classe + un type) l'emporte sur « .vmbranch »
(une classe) par spécificité CSS. Écrites simplement, elles étaient
silencieusement écrasées à 15 — et le test, qui ne vérifiait que la
présence de la classe, passait sans rien prouver. La branche porte des
noms longs (« 1.6.0 », « develop », « feature/xyz ») : trop étroite, la
liste les tronque et on ne sait plus ce qu'on a choisi. */
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
.vmrow Select.vmbranch { width: 34; }
.vmrow Select.vmprof { width: 40; }
"""
)
# Touches de fonction plutôt que ctrl+lettre : ctrl+p est pris par la
# palette de commandes de Textual, et une lettre seule serait avalée
# par le champ de saisie qui a le focus.
BINDINGS = [
("f5", "deploy", t("Deploy")),
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
("f4", "clear_vm", t("Reset VM")),
("f3", "preview", t("Preview")),
[ADD] tui qemu: F9 dumps the form state, widgets and model A per-VM setting appears not to be taken into account, and nothing on screen tells which link failed: the gap may be between the widget and the model, or between the model and the spec. A screenshot shows neither. F9 writes both side by side into ~/.erplibre/deploy-form-dump.txt: profile, shared values, overrides, locks, row generation, then VM by VM what the list DISPLAYS against what the model HOLDS, and finally the spec that would go to deployment. The file can be pasted into a message, unlike an image, and it answers the question on its own: if the list shows 16384 while the model says 1024, the defect is in the intake; if they agree and the created VM differs, it is downstream, in deploy_qemu. --- FR --- Un réglage par VM ne semble pas pris en compte, et rien dans ce que l'écran montre ne permet de trancher : l'écart peut être entre le widget et le modèle, ou entre le modèle et la spec. Une capture d'écran ne dit ni l'un ni l'autre. F9 écrit les deux côte à côte dans ~/.erplibre/deploy-form-dump.txt : profil, valeurs communes, surcharges, verrous, génération des rangées, puis VM par VM ce que la liste AFFICHE contre ce que le modèle CONTIENT, et enfin la spec qui partirait au déploiement. Le fichier se recopie dans un message, contrairement à une image, et il répond seul à la question : si la liste montre 16384 et que le modèle dit 1024, le défaut est dans la prise en compte ; s'ils s'accordent et que la VM créée diffère, il est en aval, dans deploy_qemu. Assisted-by: Claude Opus 5
2026-08-13 04:51:27 -04:00
("f9", "dump_state", t("Diagnostic dump")),
("f6", "select_all", t("All")),
("f7", "select_main", t("Main versions")),
("f8", "select_none", t("None")),
("escape", "cancel", t("Cancel")),
]
def __init__(self):
super().__init__()
self.arch = ctx.get("native") or arches[0]
self.profile = "1"
self.custom = {}
self._free = {}
self.overrides = {}
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Vrai pendant qu'on repositionne les widgets nous-mêmes : sans
# ce verrou, poser une valeur déclencherait on_select_changed, qui
# réécrirait une surcharge — une boucle qui se nourrit seule.
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
self._syncing = False
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Jeu de VM actuellement monté dans le panneau droit.
self._shown_ids = ()
[REF] déploiement : la branche, le profil et le type se choisissent par VM Sur Proxmox on déploie le plus souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM ERPLibre. C'est exactement le cas où un réglage par machine sert, et c'est le seul écran qui ne l'offrait pas : ses rangées n'avaient ni branche, ni profil, ni type. Les trois choix et leur gestionnaire — quatre-vingts lignes — rejoignent le socle. Les dupliquer aurait remis en place le mécanisme de dérive qu'on vient d'enlever. L'écran QEMU/KVM perd encore 130 lignes sans qu'un widget, un modèle ou une spec ne bouge : ancien et nouveau montés dans le même processus, mêmes rangées, mêmes valeurs après avoir changé une branche, un type et un profil. Le déploiement suit : il lisait la seule valeur commune alors que le plan portait déjà le choix par rangée. Une seule VM qui s'écarte suffit à rendre la carte nécessaire — « len(set) > 1 » ne l'aurait pas vu. Un défaut trouvé par un test, pas à l'usage : l'écho du montage se reconnaissait à sa commande, or quand la commande imposée par le système n'est pas dans la liste proposée, la liste retombe au rang 0 — et l'écho de ce rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait ERPLibre et Odoo 18. L'écho se reconnaît maintenant au RANG affiché. --- EN --- On Proxmox you usually deploy a MIXED fleet — a nested hypervisor next to ERPLibre VMs. That is exactly where a per-machine setting earns its keep, and it was the only screen without one: its rows had no branch, no profile, no type. The three choices and their handler — eighty lines — move into the shared foundation. Duplicating them would have restored the very drift mechanism we just removed. The QEMU/KVM screen loses another 130 lines with no widget, model or spec moving: old and new mounted in one process, same rows, same values after changing a branch, a type and a profile. The deployment follows: it read the single common value while the plan already carried the per-row choice. One VM that differs is enough to require the map — "len(set) > 1" would not have seen it. One defect found by a test, not by use: the mount echo was recognised by its command, yet when the command imposed by the guest OS is absent from the offered list, the list falls back to index 0 — and that index-0 echo erased the imposition. A nested Proxmox took ERPLibre and Odoo 18 back. The echo is now recognised by the DISPLAYED index. Assisted-by: Claude Opus 5
2026-08-24 23:31:13 -04:00
self.extras_init(ctx, branches, profiles)
[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
# Génération du jeu de rangées monté. Les identifiants de widgets
# portent un RANG, et le rang se décale quand on coche ou décoche
# une entrée : un événement émis par un widget déjà détruit
# s'appliquerait alors à la VM qui a pris sa place. Chaque widget
# de rangée retient sa génération ; ceux d'une génération périmée
# sont ignorés.
self._gen = 0
# VM dont les ressources sont FIGÉES, par identité de catalogue.
# Distinct des surcharges : une VM peut être modifiée sans être
# verrouillée, et le verrou fige TOUT, pas seulement ce qu'on a
# touché.
self.locked = set()
# {clé de base: nombre d'exemplaires EN PLUS du premier}.
self.copies = {}
self.rows = []
self.vms = []
self.rows = []
# -- construction de l'écran ----------------------------------- #
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="body"):
with VerticalScroll(id="fields"):
yield Static(t("Architecture"), classes="grouptitle")
with RadioSet(id="f_arch"):
for a in arches:
label = a if a != "all" else t("all archs")
yield RadioButton(label, value=a == self.arch)
yield Static(t("Catalog"), classes="grouptitle")
yield SelectionList(id="f_catalog")
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
yield Static(
t("Resources — applied to ALL VMs"),
classes="grouptitle",
)
with RadioSet(id="f_profile"):
for label in ("x1", "x2", "x3", "x4"):
yield RadioButton(label, value=label == "x1")
yield RadioButton(t("custom"))
# Chaque ressource offre ses suggestions plus « libre… »,
# qui révèle une saisie. C'est l'équivalent TUI de la règle
# de la CLI : une lettre choisit une suggestion, un chiffre
# vaut pour lui-même.
yield Select(
[(str(c), c) for c in ctx["cpu_presets"]]
+ [(t("free value…"), FREE)],
prompt=t("vCPU"),
id="f_vcpus",
disabled=True,
)
yield Input(
placeholder=t("vCPU"),
id="c_vcpus",
classes="freeval",
disabled=True,
)
yield Select(
[
(f"{m} ({m // 1024}G)", m)
for m in ctx["ram_presets"]
]
+ [(t("free value…"), FREE)],
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
prompt=t("RAM: 2048 or 8G"),
id="f_ram",
disabled=True,
)
yield Input(
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
placeholder=t("RAM: 2048 or 8G"),
id="c_ram",
classes="freeval",
disabled=True,
)
yield Select(
[(d, d) for d in ctx["disk_presets"]]
+ [(t("free value…"), FREE)],
prompt=t("Disk"),
id="f_disk",
disabled=True,
)
yield Input(
placeholder=t("Disk (e.g. 250G, 1.5T)"),
id="c_disk",
classes="freeval",
disabled=True,
)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
yield Static(
f" {t('The profile and these fields change EVERY VM.')}"
f"\n {t('A VM edited on the right (marked) keeps its own.')}",
id="scopetarget",
)
[REF] déploiement : un seul socle pour ce qui décrit le système invité Type de VM, production, magasin d'applications, outils de développement, fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas la machine qui le porte. Ils valaient donc déjà sur Proxmox — l'écran n'en offrait que trois. Une VM créée là-bas naissait serveur nu, sans outils et en UTC, et rien ne le disait. La duplication était le mécanisme de la dérive : chaque correctif se posait sur un seul des deux écrans. Les six vivent maintenant dans un socle commun, widgets ET logique, et le contexte qui les nourrit est écrit une fois. L'écran QEMU/KVM perd 330 lignes sans qu'un widget ni une valeur de spec ne bouge — vérifié en montant l'ancien et le nouveau dans le même processus. Trois conséquences se sont propagées seules : le disque annoncé (bureau et outils compris) atteint enfin « qm resize », le parallélisme suit les cœurs de l'hôte au lieu d'un plafond de quatre, et le nom prend le suffixe du bureau — sans lui, une VM graphique et sa jumelle serveur se disputaient le même. Deux défauts nommés au passage. Le fuseau : « qm set » n'en pose pas, il part maintenant par ssh avant l'installation. Et l'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que les domaines d'ici — une VM ARM prise pour x86_64 recevait Android Studio, que Google ne publie pas pour elle. Le test porte sur la PARITÉ, pas sur six comportements : ajouter un réglage à un seul écran le fait échouer. Il a d'abord échoué à se lancer — hors des préfixes du lanceur, douze tests n'ont jamais tourné et le total n'avait pas bougé. La règle de nommage est maintenant dans son en-tête. --- EN --- VM type, production, app store, development tools, timezone, Python interpreter: six settings that describe the guest, not the machine hosting it. They already applied to Proxmox — the screen offered three. A VM created there was born a bare server, no tools, in UTC, and nothing said so. Duplication was the mechanism of the drift: each fix landed on one screen only. The six now live in a shared foundation, widgets AND logic, and the context feeding them is written once. The QEMU/KVM screen loses 330 lines with no widget and no spec value moving — verified by mounting the old and the new in one process. Three consequences followed on their own: the announced disk (desktop and tools included) finally reaches "qm resize", parallelism follows the host's cores instead of a cap of four, and the name takes the desktop suffix — without it a graphical VM and its server twin fought over the same one. Two defects named along the way. The timezone: "qm set" sets none, it now goes over ssh before the install. And a Proxmox VM's architecture came from "virsh", which only knows local domains — an ARM VM taken for x86_64 got Android Studio, which Google does not publish for it. The test covers PARITY, not six behaviours: adding a setting to one screen alone fails it. It first failed to run at all — outside the runner's prefixes, twelve tests never ran and the total had not moved. The naming rule is now in its header. Assisted-by: Claude Opus 5
2026-08-24 22:45:50 -04:00
yield from self.compose_vm_type()
# La case commande TOUTE installation — ERPLibre, Odoo, mais
# aussi l'hyperviseur Proxmox VE. Nommée « ERPLibre », elle
# laissait croire qu'un système Proxmox s'installerait
# quand même : rapporté, une VM Proxmox est restée une
# Debian nue. Placée SOUS le type de VM, juste avant les
# sections qu'elle commande.
yield Static(
t("Installation"),
id="t_install",
classes="grouptitle",
)
yield Checkbox(
t("Install software in the VM"),
value=defaults.get("install", True),
id="f_install",
)
yield Select(
[(b, b) for b in branches],
[FIX] deploy : la branche du dépôt, et la sortie pendant qu'elle arrive Trois défauts rapportés sur un déploiement Proxmox. La branche proposée était « dependabot/pip/aiobotocore-3.1.3 » : « git ls-remote » rend la liste par ordre alphabétique, et l'écran en prenait la première. Il propose maintenant la branche du DÉPÔT — celle qu'on a sous les yeux — puis develop, puis master ; la liste met ces trois-là en tête et relègue les branches de robot à la fin. Les deux écrans partagent la règle. La vue de progression n'affichait la sortie qu'à la FIN du travail : « subprocess.run » ne rend rien avant. Sur Proxmox, une VM demande le téléchargement de 325 Mio puis l'import du disque — plusieurs minutes de bloc vide. Elle lit maintenant ligne à ligne. Et « s » y ouvre un ssh sur la VM créée, par son nom, donc par l'entrée ~/.ssh/config qui porte le rebond. --- EN --- Three defects reported on a Proxmox deployment. The branch offered was "dependabot/pip/aiobotocore-3.1.3": "git ls-remote" returns the list in alphabetical order and the screen took its first entry. It now offers the CHECKOUT's branch — the one in front of you — then develop, then master; the list puts those three first and pushes robot branches to the end. Both screens share the rule. The progress view only showed output at the END of a job: "subprocess.run" returns nothing before that. On Proxmox a VM needs a 325 MiB download then a disk import — minutes of an empty block. It now reads line by line. And "s" opens an ssh to the created VM, by name, hence through the ~/.ssh/config entry that carries the jump. Assisted-by: Claude Opus 5
2026-08-24 05:38:28 -04:00
value=branch_default(
branches, ctx.get("branch_current")
),
allow_blank=False,
id="f_branch",
)
yield Select(
[(lbl, i) for i, (lbl, _c) in enumerate(profiles)],
value=0 if profiles else SELECT_NULL,
allow_blank=not profiles,
id="f_profile_install",
)
[REF] déploiement : un seul socle pour ce qui décrit le système invité Type de VM, production, magasin d'applications, outils de développement, fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas la machine qui le porte. Ils valaient donc déjà sur Proxmox — l'écran n'en offrait que trois. Une VM créée là-bas naissait serveur nu, sans outils et en UTC, et rien ne le disait. La duplication était le mécanisme de la dérive : chaque correctif se posait sur un seul des deux écrans. Les six vivent maintenant dans un socle commun, widgets ET logique, et le contexte qui les nourrit est écrit une fois. L'écran QEMU/KVM perd 330 lignes sans qu'un widget ni une valeur de spec ne bouge — vérifié en montant l'ancien et le nouveau dans le même processus. Trois conséquences se sont propagées seules : le disque annoncé (bureau et outils compris) atteint enfin « qm resize », le parallélisme suit les cœurs de l'hôte au lieu d'un plafond de quatre, et le nom prend le suffixe du bureau — sans lui, une VM graphique et sa jumelle serveur se disputaient le même. Deux défauts nommés au passage. Le fuseau : « qm set » n'en pose pas, il part maintenant par ssh avant l'installation. Et l'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que les domaines d'ici — une VM ARM prise pour x86_64 recevait Android Studio, que Google ne publie pas pour elle. Le test porte sur la PARITÉ, pas sur six comportements : ajouter un réglage à un seul écran le fait échouer. Il a d'abord échoué à se lancer — hors des préfixes du lanceur, douze tests n'ont jamais tourné et le total n'avait pas bougé. La règle de nommage est maintenant dans son en-tête. --- EN --- VM type, production, app store, development tools, timezone, Python interpreter: six settings that describe the guest, not the machine hosting it. They already applied to Proxmox — the screen offered three. A VM created there was born a bare server, no tools, in UTC, and nothing said so. Duplication was the mechanism of the drift: each fix landed on one screen only. The six now live in a shared foundation, widgets AND logic, and the context feeding them is written once. The QEMU/KVM screen loses 330 lines with no widget and no spec value moving — verified by mounting the old and the new in one process. Three consequences followed on their own: the announced disk (desktop and tools included) finally reaches "qm resize", parallelism follows the host's cores instead of a cap of four, and the name takes the desktop suffix — without it a graphical VM and its server twin fought over the same one. Two defects named along the way. The timezone: "qm set" sets none, it now goes over ssh before the install. And a Proxmox VM's architecture came from "virsh", which only knows local domains — an ARM VM taken for x86_64 got Android Studio, which Google does not publish for it. The test covers PARITY, not six behaviours: adding a setting to one screen alone fails it. It first failed to run at all — outside the runner's prefixes, twelve tests never ran and the total had not moved. The naming rule is now in its header. Assisted-by: Claude Opus 5
2026-08-24 22:45:50 -04:00
yield from self.compose_install_extras()
yield from self.compose_timezone()
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",
)
[REF] déploiement : un seul socle pour ce qui décrit le système invité Type de VM, production, magasin d'applications, outils de développement, fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas la machine qui le porte. Ils valaient donc déjà sur Proxmox — l'écran n'en offrait que trois. Une VM créée là-bas naissait serveur nu, sans outils et en UTC, et rien ne le disait. La duplication était le mécanisme de la dérive : chaque correctif se posait sur un seul des deux écrans. Les six vivent maintenant dans un socle commun, widgets ET logique, et le contexte qui les nourrit est écrit une fois. L'écran QEMU/KVM perd 330 lignes sans qu'un widget ni une valeur de spec ne bouge — vérifié en montant l'ancien et le nouveau dans le même processus. Trois conséquences se sont propagées seules : le disque annoncé (bureau et outils compris) atteint enfin « qm resize », le parallélisme suit les cœurs de l'hôte au lieu d'un plafond de quatre, et le nom prend le suffixe du bureau — sans lui, une VM graphique et sa jumelle serveur se disputaient le même. Deux défauts nommés au passage. Le fuseau : « qm set » n'en pose pas, il part maintenant par ssh avant l'installation. Et l'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que les domaines d'ici — une VM ARM prise pour x86_64 recevait Android Studio, que Google ne publie pas pour elle. Le test porte sur la PARITÉ, pas sur six comportements : ajouter un réglage à un seul écran le fait échouer. Il a d'abord échoué à se lancer — hors des préfixes du lanceur, douze tests n'ont jamais tourné et le total n'avait pas bougé. La règle de nommage est maintenant dans son en-tête. --- EN --- VM type, production, app store, development tools, timezone, Python interpreter: six settings that describe the guest, not the machine hosting it. They already applied to Proxmox — the screen offered three. A VM created there was born a bare server, no tools, in UTC, and nothing said so. Duplication was the mechanism of the drift: each fix landed on one screen only. The six now live in a shared foundation, widgets AND logic, and the context feeding them is written once. The QEMU/KVM screen loses 330 lines with no widget and no spec value moving — verified by mounting the old and the new in one process. Three consequences followed on their own: the announced disk (desktop and tools included) finally reaches "qm resize", parallelism follows the host's cores instead of a cap of four, and the name takes the desktop suffix — without it a graphical VM and its server twin fought over the same one. Two defects named along the way. The timezone: "qm set" sets none, it now goes over ssh before the install. And a Proxmox VM's architecture came from "virsh", which only knows local domains — an ARM VM taken for x86_64 got Android Studio, which Google does not publish for it. The test covers PARITY, not six behaviours: adding a setting to one screen alone fails it. It first failed to run at all — outside the runner's prefixes, twelve tests never ran and the total had not moved. The naming rule is now in its header. Assisted-by: Claude Opus 5
2026-08-24 22:45:50 -04:00
yield from self.compose_python()
# Hors de la section « Installation » : le suivi
# regarde la VM ARRIVER, même quand rien ne s'installe.
# Rangé dans cette section, il se serait grisé avec elle —
# et décocher ERPLibre avait déjà fait disparaître le
# tableau de bord une fois.
yield Static(
t("Monitoring and parallelism"),
id="t_deploy",
classes="grouptitle",
)
yield Checkbox(
t("Monitoring dashboard"),
value=defaults.get("monitor", True),
id="f_monitor",
)
# Indépendante du type de VM : une machine sans console
# peut vouloir un virtio-gpu accéléré — rendu hors écran,
# ou émulateur qui tourne dedans. Sans écran,
# « egl-headless » n'ouvre aucun port.
yield Checkbox(
t("3D acceleration (host GPU), even without a screen"),
value=defaults.get("gpu3d", False),
id="f_gpu3d",
)
# Le parallélisme reste dans « Déploiement » : c'est le
# nombre de VM menées de front, pas une option
# d'installation.
yield Static(f" {t('Parallelism')}")
# Cochée, la case donne une exécution PAR installation :
# le plafond du nombre de CPU ne s'applique plus. Décochée,
# le nombre reprend la main, et son défaut suit l'hôte —
# la CLI comptait déjà les CPU, la TUI restait figée à 4.
yield Checkbox(
t("One run per install"),
value=defaults.get("par_per_install", True),
id="f_par_all",
)
yield Select(
[(str(n), n) for n in range(1, host_cpu + 1)],
value=host_cpu,
allow_blank=False,
disabled=True,
id="f_par",
)
with Vertical(id="right"):
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Une liste de widgets, pas un tableau : chaque VM porte
# SES listes déroulantes, modifiables sur place. Un
# DataTable ne sait pas héberger de widget.
yield VerticalScroll(id="plan")
yield Static("", id="totals")
yield Footer()
def on_mount(self) -> None:
self.title = t("Deploy ERPLibre VM(s)!")
self._reload_catalog(first_load=True)
self._sync_install_deps()
# -- 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)]
# ------------------------------------------------------------ #
# Ce que le socle du plan attend de nous
# ------------------------------------------------------------ #
def _presets(self):
"""Choix offerts par ressource. Le socle s'en sert pour savoir
quand une valeur est « libre »."""
return {
"vcpus": ctx["cpu_presets"],
"ram": ctx["ram_presets"],
"disk": ctx["disk_presets"],
}
def _auto_name(self, index):
"""Le nom du catalogue, suffixé du bureau : ce que la VM
reprendrait si on effaçait son nom."""
return vm_name(
self._plan_entries()[index]["name"],
self.rows[index]["vm"].get("desktop"),
desktop_suffixes,
)
def _lock_fields(self, index):
"""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."""
vm = self.rows[index]["vm"]
return {
"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._row_default_cmd(index)
),
"install_label": (
vm.get("install_label")
or (
profiles[self._row_profile_index(index)][0]
if profiles
else ""
)
),
}
def _recompute(self):
[ADD] tui qemu: deploy several copies of one entry A "+" per row adds a copy of the entry, a "−" removes it. The "−" only appears on a copy: the original cannot be deleted by mistake, it is unticked in the catalogue. The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3. The first keeps the catalogue name, so existing deployments keep theirs. The real work is in identity. entry_key was (distro, version, arch): two copies shared it, so setting the first set the second and their locks fought each other. It now carries the instance number, and overrides as well as locks follow the right machine. Each instance is a COPY of the dictionary, never a shared reference. Removing a copy forgets its settings: keeping them would resurrect old values on the next copy, with nothing to explain it. --- FR --- Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le « − » n'apparaît que sur une copie : l'original ne se supprime pas par mégarde, il se décoche au catalogue. Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le premier garde le nom du catalogue, pour que les déploiements existants gardent le leur. Le vrai travail est dans l'identité. entry_key valait (distro, version, arch) : deux copies la partageaient, donc régler la première réglait la seconde et leurs verrous se marchaient dessus. Elle porte maintenant le numéro d'exemplaire, et surcharges comme verrous suivent la bonne machine. Chaque exemplaire est une COPIE du dictionnaire, jamais une référence partagée. Retirer une copie oublie ses réglages : les garder ferait resurgir d'anciennes valeurs à la copie suivante, sans que rien ne l'explique. Assisted-by: Claude Opus 5
2026-08-13 00:37:45 -04:00
entries = self._plan_entries()
self.vms = build_vms(
entries,
self.profile,
base_vcpus,
host_cpu,
self.custom,
self.overrides,
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
self._default_desktop(),
[ADD] qemu: a graphical VM name carries its desktop erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. A graphical VM is recognisable from a "virsh list", and its hostname says so too -- deploy_qemu uses the name as hostname when --hostname is not given. This is not only cosmetic. The name is the COLLISION KEY: a graphical VM and its server twin carried the same one, so the second was reported as "already exists" and silently skipped. They are now distinct. The suffix is applied AFTER overrides, the only point where each machine's type is known now that it is chosen VM by VM. In the form the name therefore follows the choice live, both ways. "mint" rather than "cinnamon": that is the name chosen for the fleet, the installed package still being Cinnamon from the distribution's own repositories. The suffix lives with the flavour, in _QEMU_DESKTOP, and the CLI calls the same function as the TUI rather than writing a second. --- FR --- erplibre-ubuntu-2404-gnome, erplibre-ubuntu-2404-mint. Une VM graphique se reconnaît d'un « virsh list », et son nom d'hôte le dit aussi — deploy_qemu prend le nom pour hôte quand --hostname n'est pas donné. Ce n'est pas que cosmétique. Le nom sert de CLÉ DE COLLISION : une VM graphique et sa jumelle serveur portaient le même, donc la seconde était signalée « existe déjà » et silencieusement ignorée. Elles se distinguent maintenant. Le suffixe est appliqué APRÈS les surcharges, seul moment où le type de chaque machine est connu depuis qu'il se choisit VM par VM. Dans le formulaire, le nom suit donc le choix en direct, dans les deux sens. « mint » et non « cinnamon » : c'est le nom retenu pour le parc, le paquet installé restant Cinnamon depuis les dépôts de la distribution. Le suffixe vit avec la saveur, dans _QEMU_DESKTOP, et la CLI appelle la même fonction que la TUI plutôt que d'en écrire une seconde. Assisted-by: Claude Opus 5
2026-08-12 03:29:33 -04:00
desktop_suffixes,
)
# Ce qu'un système IMPOSE d'installer, posé sur le MODÈLE et pas
# seulement à l'écran : le déploiement lit « install_cmd » VM par
# VM, et une VM Proxmox laissée à vide recevait la commande
# commune — donc ERPLibre et Odoo 18 sur un hyperviseur.
for vm in self.vms:
impose = distro_profiles.get(vm["distro"])
if impose and not vm.get("install_cmd"):
vm["install_label"], vm["install_cmd"] = (
impose[0],
impose[1],
)
self.rows = plan_rows(self.vms, domains)
[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.
# Le supplément d'ERPLibre ne vaut que pour les VM qui l'auront
# VRAIMENT : l'ajouter à une VM Proxmox gonflait son disque de
# cinq gigaoctets pour un dépôt qu'elle ne clonera pas.
installe = self.query_one("#f_install", Checkbox).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
# 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.
[ADD] todo qemu: build and test the mobile app, and fail the VM if it breaks A VM was declared ready without anything proving it. The mobile option now adds the repository to the manifest (additive, so it rides along an Odoo 18 install), runs the mobile repository's own install-android.sh — licences accepted included — then npm ci, vite build, cap sync, gradlew assembleDebug and npm test. A failure fails the VM: the exit code reaches the dashboard. Two gaps in that upstream installer had to be filled: unzip and wget, which no cloud image ships, and the SDK platform — it installs android-34 while variables.gradle asks for compileSdk 36, so the number is read from the file rather than frozen. Gradle writes tens of megabytes and hundreds of harmless lines carrying the word error: that output goes to a file of its own, and the log gets the named cause instead. --- FR --- Une VM était déclarée prête sans que rien ne le prouve. L'option mobile ajoute maintenant le dépôt au manifeste (additif, donc il accompagne une installation Odoo 18), lance l'install-android.sh du dépôt mobile lui-même — licences acceptées comprises —, puis npm ci, vite build, cap sync, gradlew assembleDebug et npm test. Un échec fait échouer la VM : le code de sortie remonte au tableau de bord. Deux manques de cet installateur amont ont dû être comblés : unzip et wget, qu'aucune image cloud ne livre, et la plateforme SDK — il pose android-34 quand variables.gradle réclame compileSdk 36, d'où le chiffre lu dans le fichier plutôt que figé. Gradle écrit des dizaines de mégaoctets et des centaines de lignes anodines portant le mot error : cette sortie part dans un fichier à part, et le journal reçoit la cause nommée. Assisted-by: Claude Opus 5
2026-08-18 00:50:08 -04:00
tools = self._vm_tools()
for i, row in enumerate(self.rows):
cmd_vm = (
row["vm"].get("install_cmd") or self._profile_cmd() or ""
)
if installe and cmd_vm.strip() not in no_erplibre:
row["disk_gb"] += extra_disk
[REF] déploiement : un seul socle pour ce qui décrit le système invité Type de VM, production, magasin d'applications, outils de développement, fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas la machine qui le porte. Ils valaient donc déjà sur Proxmox — l'écran n'en offrait que trois. Une VM créée là-bas naissait serveur nu, sans outils et en UTC, et rien ne le disait. La duplication était le mécanisme de la dérive : chaque correctif se posait sur un seul des deux écrans. Les six vivent maintenant dans un socle commun, widgets ET logique, et le contexte qui les nourrit est écrit une fois. L'écran QEMU/KVM perd 330 lignes sans qu'un widget ni une valeur de spec ne bouge — vérifié en montant l'ancien et le nouveau dans le même processus. Trois conséquences se sont propagées seules : le disque annoncé (bureau et outils compris) atteint enfin « qm resize », le parallélisme suit les cœurs de l'hôte au lieu d'un plafond de quatre, et le nom prend le suffixe du bureau — sans lui, une VM graphique et sa jumelle serveur se disputaient le même. Deux défauts nommés au passage. Le fuseau : « qm set » n'en pose pas, il part maintenant par ssh avant l'installation. Et l'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que les domaines d'ici — une VM ARM prise pour x86_64 recevait Android Studio, que Google ne publie pas pour elle. Le test porte sur la PARITÉ, pas sur six comportements : ajouter un réglage à un seul écran le fait échouer. Il a d'abord échoué à se lancer — hors des préfixes du lanceur, douze tests n'ont jamais tourné et le total n'avait pas bougé. La règle de nommage est maintenant dans son en-tête. --- EN --- VM type, production, app store, development tools, timezone, Python interpreter: six settings that describe the guest, not the machine hosting it. They already applied to Proxmox — the screen offered three. A VM created there was born a bare server, no tools, in UTC, and nothing said so. Duplication was the mechanism of the drift: each fix landed on one screen only. The six now live in a shared foundation, widgets AND logic, and the context feeding them is written once. The QEMU/KVM screen loses 330 lines with no widget and no spec value moving — verified by mounting the old and the new in one process. Three consequences followed on their own: the announced disk (desktop and tools included) finally reaches "qm resize", parallelism follows the host's cores instead of a cap of four, and the name takes the desktop suffix — without it a graphical VM and its server twin fought over the same one. Two defects named along the way. The timezone: "qm set" sets none, it now goes over ssh before the install. And a Proxmox VM's architecture came from "virsh", which only knows local domains — an ARM VM taken for x86_64 got Android Studio, which Google does not publish for it. The test covers PARITY, not six behaviours: adding a setting to one screen alone fails it. It first failed to run at all — outside the runner's prefixes, twelve tests never ran and the total had not moved. The naming rule is now in its header. Assisted-by: Claude Opus 5
2026-08-24 22:45:50 -04:00
# Le bureau et les outils ne pèsent que sur les VM qui
# les reçoivent RÉELLEMENT : Android Studio n'existe qu'en
# x86_64, les extensions GNOME n'ont de sens que sous GNOME.
row["disk_gb"] += self._extras_disk_gb(row["vm"], tools)
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
# Le plan doit MONTRER qu'une VM a été personnalisée : sans marque,
# deux lignes aux ressources différentes n'ont aucune explication à
# l'écran, et la surcharge est oubliée à la relecture. Le drapeau
# vit sur la ligne d'affichage, jamais sur la VM : celle-ci part
# telle quelle dans la spec, que la CLI produit à l'identique.
for row, entry in zip(self.rows, entries):
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
key = entry_key(entry)
row["custom"] = bool(self.overrides.get(key))
row["locked"] = key in self.locked
self._render_plan()
[REF] déploiement : un seul socle pour ce qui décrit le système invité Type de VM, production, magasin d'applications, outils de développement, fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas la machine qui le porte. Ils valaient donc déjà sur Proxmox — l'écran n'en offrait que trois. Une VM créée là-bas naissait serveur nu, sans outils et en UTC, et rien ne le disait. La duplication était le mécanisme de la dérive : chaque correctif se posait sur un seul des deux écrans. Les six vivent maintenant dans un socle commun, widgets ET logique, et le contexte qui les nourrit est écrit une fois. L'écran QEMU/KVM perd 330 lignes sans qu'un widget ni une valeur de spec ne bouge — vérifié en montant l'ancien et le nouveau dans le même processus. Trois conséquences se sont propagées seules : le disque annoncé (bureau et outils compris) atteint enfin « qm resize », le parallélisme suit les cœurs de l'hôte au lieu d'un plafond de quatre, et le nom prend le suffixe du bureau — sans lui, une VM graphique et sa jumelle serveur se disputaient le même. Deux défauts nommés au passage. Le fuseau : « qm set » n'en pose pas, il part maintenant par ssh avant l'installation. Et l'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que les domaines d'ici — une VM ARM prise pour x86_64 recevait Android Studio, que Google ne publie pas pour elle. Le test porte sur la PARITÉ, pas sur six comportements : ajouter un réglage à un seul écran le fait échouer. Il a d'abord échoué à se lancer — hors des préfixes du lanceur, douze tests n'ont jamais tourné et le total n'avait pas bougé. La règle de nommage est maintenant dans son en-tête. --- EN --- VM type, production, app store, development tools, timezone, Python interpreter: six settings that describe the guest, not the machine hosting it. They already applied to Proxmox — the screen offered three. A VM created there was born a bare server, no tools, in UTC, and nothing said so. Duplication was the mechanism of the drift: each fix landed on one screen only. The six now live in a shared foundation, widgets AND logic, and the context feeding them is written once. The QEMU/KVM screen loses 330 lines with no widget and no spec value moving — verified by mounting the old and the new in one process. Three consequences followed on their own: the announced disk (desktop and tools included) finally reaches "qm resize", parallelism follows the host's cores instead of a cap of four, and the name takes the desktop suffix — without it a graphical VM and its server twin fought over the same one. Two defects named along the way. The timezone: "qm set" sets none, it now goes over ssh before the install. And a Proxmox VM's architecture came from "virsh", which only knows local domains — an ARM VM taken for x86_64 got Android Studio, which Google does not publish for it. The test covers PARITY, not six behaviours: adding a setting to one screen alone fails it. It first failed to run at all — outside the runner's prefixes, twelve tests never ran and the total had not moved. The naming rule is now in its header. Assisted-by: Claude Opus 5
2026-08-24 22:45:50 -04:00
self.render_extras()
[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 _mount_rows(self) -> None:
"""(Re)construit le panneau droit.
Le verrou couvre TOUT le montage : poser « value= » sur un Select
fait émettre un Changed à Textual, que on_select_changed prenait
pour une saisie. Résultat mesuré — les trois champs de CHAQUE VM
recevaient une surcharge dès l'affichage, le profil x1..x4 ne
pouvait plus rien changer, et toutes les lignes portaient la
marque ✎. Il est relâché après le rafraîchissement, une fois ces
messages consommés."""
self._syncing = True
self._gen += 1
plan = self.query_one("#plan", VerticalScroll)
plan.remove_children()
widgets = []
for i, r in enumerate(self.rows):
vm = r["vm"]
[ADD] tui qemu: deploy several copies of one entry A "+" per row adds a copy of the entry, a "−" removes it. The "−" only appears on a copy: the original cannot be deleted by mistake, it is unticked in the catalogue. The name adapts on its own -- erplibre-ubuntu-2404, then -2, then -3. The first keeps the catalogue name, so existing deployments keep theirs. The real work is in identity. entry_key was (distro, version, arch): two copies shared it, so setting the first set the second and their locks fought each other. It now carries the instance number, and overrides as well as locks follow the right machine. Each instance is a COPY of the dictionary, never a shared reference. Removing a copy forgets its settings: keeping them would resurrect old values on the next copy, with nothing to explain it. --- FR --- Un « + » par rangée ajoute une copie de l'entrée, un « − » la retire. Le « − » n'apparaît que sur une copie : l'original ne se supprime pas par mégarde, il se décoche au catalogue. Le nom s'adapte seul — erplibre-ubuntu-2404, puis -2, puis -3. Le premier garde le nom du catalogue, pour que les déploiements existants gardent le leur. Le vrai travail est dans l'identité. entry_key valait (distro, version, arch) : deux copies la partageaient, donc régler la première réglait la seconde et leurs verrous se marchaient dessus. Elle porte maintenant le numéro d'exemplaire, et surcharges comme verrous suivent la bonne machine. Chaque exemplaire est une COPIE du dictionnaire, jamais une référence partagée. Retirer une copie oublie ses réglages : les garder ferait resurgir d'anciennes valeurs à la copie suivante, sans que rien ne l'explique. Assisted-by: Claude Opus 5
2026-08-13 00:37:45 -04:00
item = self._plan_entries()[i]
key = entry_key(item)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
row = Horizontal(
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
# « + » ajoute un exemplaire de CETTE entrée ; « - » ne
# s'affiche que sur une copie, pour qu'on ne puisse pas
# retirer l'original par mégarde.
Button("+", id=f"p{i}", classes="vmcopy"),
Button("✎", id=f"r{i}", classes="vmcopy"),
Button(
"🔒" if key in self.locked else "🔓",
id=f"l{i}",
variant="success" if key in self.locked else "default",
classes="vmlock",
),
# Le triplet vCPU / RAM / disque vient du socle
# commun : c'est la partie que le formulaire Proxmox pose
# à l'identique, et la seule que les deux dupliquaient.
*res_row_widgets(
i,
vm,
{
"vcpus": ctx["cpu_presets"],
"ram": ctx["ram_presets"],
"disk": ctx["disk_presets"],
},
labels={"vcpus": "vCPU"},
null=SELECT_NULL,
[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
),
[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")
),
[REF] déploiement : la branche, le profil et le type se choisissent par VM Sur Proxmox on déploie le plus souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM ERPLibre. C'est exactement le cas où un réglage par machine sert, et c'est le seul écran qui ne l'offrait pas : ses rangées n'avaient ni branche, ni profil, ni type. Les trois choix et leur gestionnaire — quatre-vingts lignes — rejoignent le socle. Les dupliquer aurait remis en place le mécanisme de dérive qu'on vient d'enlever. L'écran QEMU/KVM perd encore 130 lignes sans qu'un widget, un modèle ou une spec ne bouge : ancien et nouveau montés dans le même processus, mêmes rangées, mêmes valeurs après avoir changé une branche, un type et un profil. Le déploiement suit : il lisait la seule valeur commune alors que le plan portait déjà le choix par rangée. Une seule VM qui s'écarte suffit à rendre la carte nécessaire — « len(set) > 1 » ne l'aurait pas vu. Un défaut trouvé par un test, pas à l'usage : l'écho du montage se reconnaissait à sa commande, or quand la commande imposée par le système n'est pas dans la liste proposée, la liste retombe au rang 0 — et l'écho de ce rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait ERPLibre et Odoo 18. L'écho se reconnaît maintenant au RANG affiché. --- EN --- On Proxmox you usually deploy a MIXED fleet — a nested hypervisor next to ERPLibre VMs. That is exactly where a per-machine setting earns its keep, and it was the only screen without one: its rows had no branch, no profile, no type. The three choices and their handler — eighty lines — move into the shared foundation. Duplicating them would have restored the very drift mechanism we just removed. The QEMU/KVM screen loses another 130 lines with no widget, model or spec moving: old and new mounted in one process, same rows, same values after changing a branch, a type and a profile. The deployment follows: it read the single common value while the plan already carried the per-row choice. One VM that differs is enough to require the map — "len(set) > 1" would not have seen it. One defect found by a test, not by use: the mount echo was recognised by its command, yet when the command imposed by the guest OS is absent from the offered list, the list falls back to index 0 — and that index-0 echo erased the imposition. A nested Proxmox took ERPLibre and Odoo 18 back. The echo is now recognised by the DISPLAYED index. Assisted-by: Claude Opus 5
2026-08-24 23:31:13 -04:00
*self.install_row_widgets(i, SELECT_NULL),
[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
classes="vmrow",
)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
widgets.append(
Vertical(
Static(self._row_head(i, r), id=f"h{i}"),
row,
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
# Pas d'id sur la carte : « remove_children() » est
# ASYNCHRONE, les anciennes sont encore là au montage
# et Textual refuse deux frères de même id. Les ids
# des champs vivent un niveau plus bas, dans un parent
# neuf — la collision ne les touche pas. On atteint
# donc la carte par son RANG.
classes=(
"vmcard locked" if key in self.locked else "vmcard"
),
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
)
)
# Marque de génération, posée sur CHAQUE widget de rangée.
# « walk_children() » ne voit RIEN avant le montage : les enfants
# passés au constructeur attendent dans « _pending_children ».
def mark(node):
node._el_gen = self._gen
for child in getattr(node, "_pending_children", None) or []:
mark(child)
for card in widgets:
mark(card)
# Le montage vient APRÈS le marquage, jamais avant : « mount_all »
# consomme « _pending_children » et le vide. Marquer ensuite était
# une COURSE — gagnée sur une machine, perdue sur une autre. Perdue,
# les listes des rangées n'avaient plus de génération, TOUS les
# changements par VM étaient rejetés en silence, et seuls les
# réglages globaux semblaient agir.
if widgets:
plan.mount_all(widgets)
self._shown_ids = self._row_ids()
# Les saisies libres ne se révèlent qu'après le montage : leur
# style ne peut pas être touché avant qu'elles existent.
self.call_after_refresh(self._after_mount_rows)
def _after_mount_rows(self) -> None:
self._sync_install_deps()
[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._sync_free_inputs()
self._syncing = False
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
def _refresh_row_widgets(self) -> None:
"""Remet les listes de chaque rangée sur ce que la VM vaut MAINTENANT.
Sans cela, changer le profil x1..x4 mettait les totaux à jour mais
laissait les listes sur leurs anciennes valeurs : l'écran affichait
8192 pendant que la VM valait 4096. Les rangées ne sont remontées
que si le JEU de VM change — pour ne pas voler le focus — donc ce
rafraîchissement doit se faire à la main.
Aucun risque de boucle : on_select_changed ignore une valeur déjà
égale à celle du modèle, et le modèle vient précisément d'être
recalculé."""
for i, r in enumerate(self.rows):
vm = r["vm"]
for field, presets in (
("vcpus", ctx["cpu_presets"]),
("ram", ctx["ram_presets"]),
("disk", ctx["disk_presets"]),
):
try:
sel = self.query_one(f"#v{i}_{field}", Select)
except Exception:
continue
# Une liste posée sur « libre… » ne doit PAS être remise
# sur une valeur : l'utilisateur vient de la choisir, et
# tant qu'il n'a rien tapé la VM vaut encore celle du
# profil — on la lui reprendrait sous les doigts.
if sel.value is FREE:
continue
# Une valeur libre déjà saisie n'est dans aucune liste :
# liste vide, la saisie à côté porte le nombre.
sel.value = (
vm[field] if vm[field] in presets else SELECT_NULL
)
for wid, value in (
(f"#v{i}_type", vm.get("desktop") or SERVER),
(f"#v{i}_branch", vm.get("branch") or self._branch()),
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
(f"#v{i}_prof", self._row_profile_index(i)),
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
):
try:
self.query_one(wid, Select).value = value
except Exception:
pass
self._sync_free_inputs()
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
def _render_plan(self):
# Le JEU de VM a-t-il changé ? Si oui on remonte les widgets, sinon
# on se contente des titres : remonter à chaque frappe volerait le
# focus au champ en cours de saisie.
if self._row_ids() != self._shown_ids:
self._mount_rows()
else:
for i, r in enumerate(self.rows):
try:
self.query_one(f"#h{i}", Static).update(
self._row_head(i, r)
)
except Exception:
pass
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
self._refresh_row_widgets()
if not self.rows:
# Rien de coché : un total à zéro n'apprend rien, on dit
# plutôt comment remplir la liste.
self.query_one("#totals", Static).update(
f" {t('Tick what to deploy')} — "
f"{t('F7 main versions · F6 all')}"
)
return
n, cpus, ram, disk = plan_totals(self.rows)
# Une liste et non un seul avertissement : la RAM, les cœurs et le
# disque sont trois limites distinctes, et n'en montrer qu'une
# cachait les autres — on corrigeait la première pour découvrir la
# suivante au déploiement.
alertes = []
if free_ram and ram > free_ram:
alertes.append(t("> host free RAM"))
if free_disk and disk > free_disk:
alertes.append(t("> host free disk"))
if cpus > host_cpu:
alertes.append(f"{t('> host cores')} ({host_cpu})")
warn = f" ⚠ {' · '.join(alertes)}" if alertes else ""
dupes = len({vm["name"] for vm in self.vms}) != len(self.vms)
dup_txt = (
f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}"
if dupes
else ""
)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Une VM DÉJÀ DÉFINIE n'est pas recréée : elle ne consomme rien
# de neuf, donc plan_totals l'écarte. Mais un total à zéro sans
# explication se lit comme un bogue — on a cru que les réglages
# par VM n'avaient aucun effet, alors qu'ils portaient sur une
# machine qui ne sera pas créée.
skipped = sum(1 for r in self.rows if r["state"] == "exists")
skip_txt = (
f" ({skipped} {t('already defined, not counted')})"
if skipped
else ""
)
self.query_one("#totals", Static).update(
f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · "
f"{disk_note(disk, free_disk, total_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
f"{skip_txt}{warn}{dup_txt}"
)
# -- réactions aux champs -------------------------------------- #
def on_radio_set_changed(self, event) -> None:
if event.radio_set.id == "f_arch":
self.arch = arches[event.radio_set.pressed_index]
self._reload_catalog()
[ADD] tui qemu: graphical VMs, with a GNOME desktop VMs could only be servers. A server-or-graphical choice joins both interfaces, installing GNOME along with remote access to it. Packages come from the remote command, not cloud-init: their 1 to 2 GB would stretch an already long boot there while leaving no trace in the monitoring, and group installs do not go through it. The desktop therefore does not depend on ERPLibre -- a VM may be wanted graphical and bare. Each distribution has its own names, taken from the source: Arch has no xrdp in its official repositories and takes TigerVNC. The SPICE display is set only on amd64 and arm64; s390x does expose virtio-gpu-ccw, but nothing guarantees its kernel's DRM driver, whereas remote desktop works everywhere. --- FR --- Les VM ne pouvaient être que des serveurs. Un choix serveur ou graphique s'ajoute aux deux interfaces, et pose GNOME avec son accès distant. Les paquets viennent de la commande distante, pas de cloud-init : leurs 1 à 2 Go y allongeraient un démarrage déjà long sans laisser de trace dans le suivi, et les installations par groupe n'y passent pas. Le bureau ne dépend donc pas d'ERPLibre — une VM peut être voulue graphique et nue. Chaque distribution a ses noms, relevés à la source : Arch n'a pas xrdp dans ses dépôts officiels et prend TigerVNC. L'écran virtuel SPICE n'est posé que sur amd64 et arm64 ; s390x expose bien virtio-gpu-ccw, mais rien ne garantit le pilote DRM de son noyau, alors que le bureau distant, lui, marche partout. Assisted-by: Claude Opus 5
2026-08-11 09:49:17 -04:00
elif event.radio_set.id == "f_type":
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
self._clear_overrides(("desktop",))
# Le type est l'autre moitié de la décision : un bureau seul
# garde le magasin d'applications et les outils utiles.
self._sync_install_deps()
# Recalcul : le disque annonce inclut le bureau, et la
# colonne Statut affiche le type de VM.
self._recompute()
elif event.radio_set.id == "f_profile":
index = event.radio_set.pressed_index
self.profile = "custom" if index == 4 else str(index + 1)
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
# Un multiplicateur x1..x4 ne touche QUE les vCPU et la RAM —
# apply_profile y laisse le disque du catalogue. Y effacer une
# taille de disque réglée à la main la faisait disparaître sans
# rien mettre à la place : on revenait à 20G sans l'avoir
# demandé. Le disque n'est rendu au commun que par le profil
# « personnalisé », qui en porte un.
fields = ("vcpus", "ram")
if self.profile == "custom":
fields += ("disk",)
self._clear_overrides(fields)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
custom = self.profile == "custom"
for field, (sel, _inp) in RES_FIELDS.items():
self.query_one(sel, Select).disabled = not custom
self._show_free(field, custom and self._free.get(field))
self._recompute()
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# -- rangées du panneau droit ------------------------------- #
def 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.
[REF] déploiement : la branche, le profil et le type se choisissent par VM Sur Proxmox on déploie le plus souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM ERPLibre. C'est exactement le cas où un réglage par machine sert, et c'est le seul écran qui ne l'offrait pas : ses rangées n'avaient ni branche, ni profil, ni type. Les trois choix et leur gestionnaire — quatre-vingts lignes — rejoignent le socle. Les dupliquer aurait remis en place le mécanisme de dérive qu'on vient d'enlever. L'écran QEMU/KVM perd encore 130 lignes sans qu'un widget, un modèle ou une spec ne bouge : ancien et nouveau montés dans le même processus, mêmes rangées, mêmes valeurs après avoir changé une branche, un type et un profil. Le déploiement suit : il lisait la seule valeur commune alors que le plan portait déjà le choix par rangée. Une seule VM qui s'écarte suffit à rendre la carte nécessaire — « len(set) > 1 » ne l'aurait pas vu. Un défaut trouvé par un test, pas à l'usage : l'écho du montage se reconnaissait à sa commande, or quand la commande imposée par le système n'est pas dans la liste proposée, la liste retombe au rang 0 — et l'écho de ce rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait ERPLibre et Odoo 18. L'écho se reconnaît maintenant au RANG affiché. --- EN --- On Proxmox you usually deploy a MIXED fleet — a nested hypervisor next to ERPLibre VMs. That is exactly where a per-machine setting earns its keep, and it was the only screen without one: its rows had no branch, no profile, no type. The three choices and their handler — eighty lines — move into the shared foundation. Duplicating them would have restored the very drift mechanism we just removed. The QEMU/KVM screen loses another 130 lines with no widget, model or spec moving: old and new mounted in one process, same rows, same values after changing a branch, a type and a profile. The deployment follows: it read the single common value while the plan already carried the per-row choice. One VM that differs is enough to require the map — "len(set) > 1" would not have seen it. One defect found by a test, not by use: the mount echo was recognised by its command, yet when the command imposed by the guest OS is absent from the offered list, the list falls back to index 0 — and that index-0 echo erased the imposition. A nested Proxmox took ERPLibre and Odoo 18 back. The echo is now recognised by the DISPLAYED index. Assisted-by: Claude Opus 5
2026-08-24 23:31:13 -04:00
if self.extras_on_row_select(event, index, field):
[ADD] tui qemu: an Odoo version per VM A profile list per row, beside the branch: "ERPLibre + Odoo 18", "Odoo 17", "ERPLibre alone". The form's profile stays the default; picking another for a VM creates an override, going back clears it. It reaches down to execution, like the branch and the type before it. "final_cmd" now takes either a string for the whole fleet or a {name: command} map, and the remote command is built per machine as soon as profiles differ. A VM can install Odoo 18 while another validates Odoo 17, in the same deployment. Both lists are widened -- branch to 24 columns, profile to 28. Truncated, "develop" and "ERPLibre + Odoo 18" no longer showed what had been picked, and that is precisely what one wants to re-read before deploying. Two more defects: the global branch and Odoo choices reached nothing at all, and the summary announced the global profile for every VM, then named a version nothing would install. --- FR --- Une liste de profils par rangée, à côté de la branche : « ERPLibre + Odoo 18 », « Odoo 17 », « ERPLibre seul ». Le profil du formulaire reste le défaut ; en choisir un autre pour une VM crée une surcharge, y revenir l'efface. Il descend jusqu'à l'exécution, comme la branche et le type avant lui. « final_cmd » accepte maintenant une chaîne pour tout le parc ou une carte {nom: commande}, et la commande distante est bâtie par machine dès que les profils diffèrent. Une VM peut donc installer Odoo 18 pendant qu'une autre valide Odoo 17, dans le même déploiement. Les deux listes sont élargies : la branche passe à 24 colonnes, le profil à 28. Tronqués, « develop » et « ERPLibre + Odoo 18 » ne laissaient plus voir ce qu'on avait choisi — et c'est précisément ce qu'on veut relire avant de déployer. Deux autres défauts : les choix globaux de branche et d'Odoo n'atteignaient rien, et le sommaire annonçait le profil global pour toutes les VM, puis nommait une version que rien n'installait. Assisted-by: Claude Opus 5
2026-08-13 01:15:13 -04:00
return
[REF] déploiement : la branche, le profil et le type se choisissent par VM Sur Proxmox on déploie le plus souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM ERPLibre. C'est exactement le cas où un réglage par machine sert, et c'est le seul écran qui ne l'offrait pas : ses rangées n'avaient ni branche, ni profil, ni type. Les trois choix et leur gestionnaire — quatre-vingts lignes — rejoignent le socle. Les dupliquer aurait remis en place le mécanisme de dérive qu'on vient d'enlever. L'écran QEMU/KVM perd encore 130 lignes sans qu'un widget, un modèle ou une spec ne bouge : ancien et nouveau montés dans le même processus, mêmes rangées, mêmes valeurs après avoir changé une branche, un type et un profil. Le déploiement suit : il lisait la seule valeur commune alors que le plan portait déjà le choix par rangée. Une seule VM qui s'écarte suffit à rendre la carte nécessaire — « len(set) > 1 » ne l'aurait pas vu. Un défaut trouvé par un test, pas à l'usage : l'écho du montage se reconnaissait à sa commande, or quand la commande imposée par le système n'est pas dans la liste proposée, la liste retombe au rang 0 — et l'écho de ce rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait ERPLibre et Odoo 18. L'écho se reconnaît maintenant au RANG affiché. --- EN --- On Proxmox you usually deploy a MIXED fleet — a nested hypervisor next to ERPLibre VMs. That is exactly where a per-machine setting earns its keep, and it was the only screen without one: its rows had no branch, no profile, no type. The three choices and their handler — eighty lines — move into the shared foundation. Duplicating them would have restored the very drift mechanism we just removed. The QEMU/KVM screen loses another 130 lines with no widget, model or spec moving: old and new mounted in one process, same rows, same values after changing a branch, a type and a profile. The deployment follows: it read the single common value while the plan already carried the per-row choice. One VM that differs is enough to require the map — "len(set) > 1" would not have seen it. One defect found by a test, not by use: the mount echo was recognised by its command, yet when the command imposed by the guest OS is absent from the offered list, the list falls back to index 0 — and that index-0 echo erased the imposition. A nested Proxmox took ERPLibre and Odoo 18 back. The echo is now recognised by the DISPLAYED index. Assisted-by: Claude Opus 5
2026-08-24 23:31:13 -04:00
# Ne restent ici que les RESSOURCES : elles n'ont pas de
# défaut à comparer, mais une saisie libre à révéler.
if event.value is 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
self._row_free(index, field, True)
self._set_override(
index, field, self._read_row_free(index, field)
)
elif event.value is not SELECT_NULL:
[REF] déploiement : la branche, le profil et le type se choisissent par VM Sur Proxmox on déploie le plus souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM ERPLibre. C'est exactement le cas où un réglage par machine sert, et c'est le seul écran qui ne l'offrait pas : ses rangées n'avaient ni branche, ni profil, ni type. Les trois choix et leur gestionnaire — quatre-vingts lignes — rejoignent le socle. Les dupliquer aurait remis en place le mécanisme de dérive qu'on vient d'enlever. L'écran QEMU/KVM perd encore 130 lignes sans qu'un widget, un modèle ou une spec ne bouge : ancien et nouveau montés dans le même processus, mêmes rangées, mêmes valeurs après avoir changé une branche, un type et un profil. Le déploiement suit : il lisait la seule valeur commune alors que le plan portait déjà le choix par rangée. Une seule VM qui s'écarte suffit à rendre la carte nécessaire — « len(set) > 1 » ne l'aurait pas vu. Un défaut trouvé par un test, pas à l'usage : l'écho du montage se reconnaissait à sa commande, or quand la commande imposée par le système n'est pas dans la liste proposée, la liste retombe au rang 0 — et l'écho de ce rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait ERPLibre et Odoo 18. L'écho se reconnaît maintenant au RANG affiché. --- EN --- On Proxmox you usually deploy a MIXED fleet — a nested hypervisor next to ERPLibre VMs. That is exactly where a per-machine setting earns its keep, and it was the only screen without one: its rows had no branch, no profile, no type. The three choices and their handler — eighty lines — move into the shared foundation. Duplicating them would have restored the very drift mechanism we just removed. The QEMU/KVM screen loses another 130 lines with no widget, model or spec moving: old and new mounted in one process, same rows, same values after changing a branch, a type and a profile. The deployment follows: it read the single common value while the plan already carried the per-row choice. One VM that differs is enough to require the map — "len(set) > 1" would not have seen it. One defect found by a test, not by use: the mount echo was recognised by its command, yet when the command imposed by the guest OS is absent from the offered list, the list falls back to index 0 — and that index-0 echo erased the imposition. A nested Proxmox took ERPLibre and Odoo 18 back. The echo is now recognised by the DISPLAYED index. Assisted-by: Claude Opus 5
2026-08-24 23:31:13 -04:00
if self._row_echo(index, field, event.value):
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
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.
[REF] déploiement : un seul socle pour ce qui décrit le système invité Type de VM, production, magasin d'applications, outils de développement, fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas la machine qui le porte. Ils valaient donc déjà sur Proxmox — l'écran n'en offrait que trois. Une VM créée là-bas naissait serveur nu, sans outils et en UTC, et rien ne le disait. La duplication était le mécanisme de la dérive : chaque correctif se posait sur un seul des deux écrans. Les six vivent maintenant dans un socle commun, widgets ET logique, et le contexte qui les nourrit est écrit une fois. L'écran QEMU/KVM perd 330 lignes sans qu'un widget ni une valeur de spec ne bouge — vérifié en montant l'ancien et le nouveau dans le même processus. Trois conséquences se sont propagées seules : le disque annoncé (bureau et outils compris) atteint enfin « qm resize », le parallélisme suit les cœurs de l'hôte au lieu d'un plafond de quatre, et le nom prend le suffixe du bureau — sans lui, une VM graphique et sa jumelle serveur se disputaient le même. Deux défauts nommés au passage. Le fuseau : « qm set » n'en pose pas, il part maintenant par ssh avant l'installation. Et l'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que les domaines d'ici — une VM ARM prise pour x86_64 recevait Android Studio, que Google ne publie pas pour elle. Le test porte sur la PARITÉ, pas sur six comportements : ajouter un réglage à un seul écran le fait échouer. Il a d'abord échoué à se lancer — hors des préfixes du lanceur, douze tests n'ont jamais tourné et le total n'avait pas bougé. La règle de nommage est maintenant dans son en-tête. --- EN --- VM type, production, app store, development tools, timezone, Python interpreter: six settings that describe the guest, not the machine hosting it. They already applied to Proxmox — the screen offered three. A VM created there was born a bare server, no tools, in UTC, and nothing said so. Duplication was the mechanism of the drift: each fix landed on one screen only. The six now live in a shared foundation, widgets AND logic, and the context feeding them is written once. The QEMU/KVM screen loses 330 lines with no widget and no spec value moving — verified by mounting the old and the new in one process. Three consequences followed on their own: the announced disk (desktop and tools included) finally reaches "qm resize", parallelism follows the host's cores instead of a cap of four, and the name takes the desktop suffix — without it a graphical VM and its server twin fought over the same one. Two defects named along the way. The timezone: "qm set" sets none, it now goes over ssh before the install. And a Proxmox VM's architecture came from "virsh", which only knows local domains — an ARM VM taken for x86_64 got Android Studio, which Google does not publish for it. The test covers PARITY, not six behaviours: adding a setting to one screen alone fails it. It first failed to run at all — outside the runner's prefixes, twelve tests never ran and the total had not moved. The naming rule is now in its header. Assisted-by: Claude Opus 5
2026-08-24 22:45:50 -04:00
if self.extras_on_select(event):
[ADD] tui qemu: pick the time zone from a list The timezone was typed by hand. A misspelt IANA name is not rejected by cloud-init: it is IGNORED. The VM stays on UTC, and you only notice from the timestamps, once deployed. A list of twenty-five zones, Québec first, then the rest of Canada and the places one actually meets. The host's zone goes to the top, without duplication: a machine outside this list must still see its own at a glance. NAMES, not offsets: "UTC-5" says nothing about daylight saving and cloud-init will not take it. A name carries its own switching rules. "free value…" keeps the door open to the other six hundred zones in the database. The choice is copied into the field, which stays the only value the spec reads -- one place holds the answer. --- FR --- Le fuseau se tapait à la main. Un nom IANA mal orthographié n'est pas refusé par cloud-init : il est IGNORÉ. La VM reste en UTC, et on ne s'en aperçoit qu'aux horodatages, une fois déployée. Une liste de vingt-cinq fuseaux, le Québec d'abord, puis le reste du Canada et les places qu'on rencontre en pratique. Le fuseau de l'hôte passe en tête, sans doublon : une machine hors de cette liste doit voir le sien en un coup d'œil. Des NOMS, pas des décalages : « UTC-5 » ne dit rien de l'heure d'été et cloud-init n'en veut pas. Un nom porte ses propres règles de bascule. « libre… » garde la porte ouverte aux six cents autres fuseaux de la base. Le choix est recopié dans le champ, qui reste la seule valeur lue par la spec — un seul endroit porte la réponse. Assisted-by: Claude Opus 5
2026-08-13 01:56:36 -04:00
return
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
if event.select.id in ("f_branch", "f_profile_install"):
self._clear_overrides(
("branch",)
if event.select.id == "f_branch"
else ("install_cmd",)
)
self._recompute()
return
field = SELECT_TO_FIELD.get(event.select.id)
if not field:
return
if event.value is FREE:
# La valeur retenue est celle de la saisie, pas ce choix-ci.
self._free[field] = True
self._show_free(field, True)
self.query_one(RES_FIELDS[field][1], Input).focus()
self._apply_free(field)
elif event.value is not SELECT_NULL:
self._free[field] = False
self._show_free(field, False)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
self.custom[field] = event.value
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
self._clear_overrides((field,))
self._recompute()
def 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)
valeur = self._read_row_free(index, field)
# Même règle que pour les listes : poser « value= » au montage
# émet un Changed. L'écrire comme surcharge marquait ✎ une
# rangée que personne n'avait touchée — visible dès qu'une
# entrée du catalogue porte une taille absente des
# préréglages, comme les 32 G de Proxmox VE.
if self._row_echo(index, field, valeur):
return
self._set_override(index, field, valeur)
[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._recompute()
return
field = INPUT_TO_FIELD.get(event.input.id)
if field:
self._apply_free(field)
self._recompute()
[ADD] tui qemu: freeze one VM's resources, row in green A 🔒 box at the head of each row. Ticked, the VM's four current values -- vCPU, RAM, disk, type -- are copied into its overrides: the x1..x4 profile no longer reaches it. Unticked, they are removed and the VM falls back under the profile. The lock shows on the WHOLE ROW, not in a box lost at the end: that is what lets you scan the plan and see at once what escapes the profile. It rests on the override mechanism already proven, indexed by catalog identity, so it survives a remount. Locked state stays distinct from overrides: a VM can be edited without being frozen, and the lock covers all four fields at once. Four traps came with it. "remove_children()" is ASYNCHRONOUS, so giving the card an id broke the remount, the old one still being there. Lists kept stale values across a remount, and a refresh took back the free entry. A frozen VM changed version all the same. And switching profile wiped the disk size that had been set. --- FR --- Une case 🔒 en tête de chaque rangée. Cochée, les quatre valeurs courantes de la VM — vCPU, RAM, disque, type — sont recopiées dans ses surcharges : le profil x1..x4 ne l'atteint plus. Décochée, elles sont retirées et la VM retombe sous le profil. Le verrou se voit à la LIGNE ENTIÈRE, pas à une case perdue au bout : c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui échappe au profil. Il s'appuie sur le mécanisme de surcharge déjà éprouvé, indexé par identité de catalogue : il survit donc à un remontage. L'état verrouillé reste distinct des surcharges : une VM peut être modifiée sans être figée, et le verrou couvre les quatre champs d'un coup. Quatre pièges l'ont accompagné. « remove_children() » est ASYNCHRONE : donner un id à la carte faisait échouer le remontage, l'ancienne étant encore là. Les listes gardaient des valeurs périmées au remontage, et un rafraîchissement reprenait la saisie libre. Une VM figée changeait quand même de version. Et changer de profil effaçait la taille de disque réglée. Assisted-by: Claude Opus 5
2026-08-13 00:33:04 -04:00
def 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 on_checkbox_changed(self, event) -> None:
if event.checkbox.id == "f_install":
self._sync_install_deps()
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
elif event.checkbox.id == "f_par_all":
self.query_one("#f_par", Select).disabled = event.value
[ADD] todo qemu: offer PyCharm, Android Studio and GNOME extensions A graphical VM was a desktop and nothing else: every developer tool had to be installed by hand afterwards. A check list now carries them, filtered per machine — Android Studio is x86_64 only, Google publishes no Linux aarch64 build — and their disk cost reaches the plan before any qcow2 is created. They are installed BEFORE the clone: PyCharm writes the .idea/ of the repository, and the install that follows is what runs pycharm_configuration.py, through update_env_version.pycharm_update(). Its launcher is named studio, which is enough to conclude the install failed; it now answers to android-studio too. GNOME extensions come from the site by UUID, per running Shell: the same endpoint serves gTile v59 for GNOME 46 and v62 for 48. Checked with stubs: every tool can fail and the install's exit code still wins, 45 tests. --- FR --- Une VM graphique n'était qu'un bureau : chaque outil de développement restait à poser à la main. Une liste à cocher les porte, filtrés machine par machine — Android Studio n'existe qu'en x86_64, Google ne publiant aucune archive Linux aarch64 — et leur place disque atteint le plan avant qu'un seul qcow2 ne soit créé. Ils sont posés AVANT le clone : PyCharm écrit le .idea/ du dépôt, et c'est l'installation qui suit qui lance pycharm_configuration.py, via update_env_version.pycharm_update(). Son lanceur s'appelle studio, ce qui suffit à conclure à un échec ; il répond désormais aussi à android-studio. Les extensions GNOME viennent du site par UUID, selon le Shell qui tourne : le même point d'entrée sert gTile v59 pour GNOME 46 et v62 pour 48. Vérifié avec des leurres : chaque outil peut échouer sans que le code de sortie de l'installation ne change, 45 tests. Assisted-by: Claude Opus 5
2026-08-17 22:57:56 -04:00
elif str(event.checkbox.id or "").startswith("f_tool_"):
# Un IDE de plus, c'est un disque plus grand : le plan doit le
# montrer AVANT de déployer, pas après une heure d'installation.
self._recompute()
# -- actions ---------------------------------------------------- #
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
def action_clear_vm(self) -> None:
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
"""Rend au profil commun la VM dont un widget a le focus. Sans
cette sortie, un réglage posé par erreur ne se défaisait qu'en
rouvrant le formulaire."""
index = self._focused_row()
if index is None:
return
key = self._row_key(index)
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
if key is None or key not in self.overrides:
return
self.overrides.pop(key)
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
# Les widgets de la rangée portent encore l'ancienne valeur : on
# les remonte pour qu'ils disent la vérité.
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
self._recompute()
[ADD] tui qemu: set every VM in place, type included VM type was global: the whole fleet as servers, or all of them GNOME. It now lives on the VM, all the way down -- the creation flag, and one remote command per machine at install time, where a single one served them all. The right pane is no longer a table but a row of widgets per VM: vCPU, RAM, disk and type, each with its usual values and a free entry. The left-hand fields become the shared default, which the screen now says. The scope selector and the F2 modal go away: given two ways to do the same thing, keep the visible one. Three traps came out of it, and the tests lock them. Widget ids carry a RANK, and the rank shifts when an entry is ticked, so an event from an already destroyed widget applied to the VM that took its place -- rows now carry a generation, marked BEFORE mounting, since mount_all empties the pending children and marking after it was a race. The x1..x4 profile no longer reached any VM. And a total of zero never said it had counted nothing. --- FR --- Le type de VM était global : tout le parc en serveur, ou tout en GNOME. Il vit désormais sur la VM, jusqu'au bout — le drapeau de création, et une commande distante par machine à l'installation, là où une seule les servait toutes. Le panneau de droite n'est plus un tableau mais une rangée de widgets par VM : vCPU, RAM, disque et type, chacun avec ses valeurs usuelles et une saisie libre. Les champs de gauche deviennent le défaut commun, ce que l'écran dit maintenant. Le sélecteur de portée et la modale F2 partent : entre deux façons de faire la même chose, on garde la visible. Trois pièges en sont sortis, et les tests les verrouillent. Les identifiants de widgets portent un RANG, et le rang se décale quand on coche une entrée : un événement émis par un widget déjà détruit s'appliquait à la VM qui avait pris sa place — les rangées portent maintenant une génération, marquée AVANT le montage, car mount_all vide les enfants en attente et marquer après était une course. Le profil x1..x4 n'atteignait plus aucune VM. Et un total à zéro ne disait pas qu'il n'avait rien compté. Assisted-by: Claude Opus 5
2026-08-12 02:46:41 -04:00
self._mount_rows()
[ADD] tui qemu: customise one VM without touching the others The three resource fields applied to the whole fleet. With a single machine needing 16 G, the other eight got it too. A scope selector settles it: all VMs, or the row targeted in the plan. The x1..x4 profile stays global, multiplying what each image asks for; under "selected VM" the fields are absolute, hence active even outside the custom profile. The F2 modal already existed but was undiscoverable, and its cursor was unusable: the table never held focus. It stays, the toggle now focuses the table, F4 returns a VM to the shared profile, and the plan marks customised rows with a ✎ -- without it, two rows with different resources have no explanation on screen. One defect found along the way: clear() reset the cursor to the top on every redraw, and the plan recomputes on every keystroke. You picked debian, typed the value, the table redrew, and the next entry landed on ubuntu with nothing to show for it. --- FR --- Les trois champs de ressources s'appliquaient à tout le parc. Une seule machine ayant besoin de 16 G, il fallait les donner aux huit autres. Un sélecteur de portée tranche : toutes les VM, ou la ligne visée dans le plan. Le profil x1..x4 reste global, il multiplie ce que demande chaque image ; en portée « une seule » les champs valent en absolu, et sont donc actifs même hors profil personnalisé. La modale F2 existait déjà mais restait introuvable, et son curseur était inutilisable : le tableau n'avait jamais le focus. Elle demeure, la bascule lui donne le focus, F4 rend une VM au profil commun, et le plan marque d'un ✎ ce qui a été personnalisé — sans marque, deux lignes aux ressources différentes n'ont aucune explication à l'écran. Un défaut au passage : « clear() » ramenait le curseur en tête à chaque redessin, et le plan se recalcule à chaque frappe. On choisissait debian, on tapait la valeur, le tableau se redessinait, et la saisie suivante partait sur ubuntu sans que rien ne le montre. Assisted-by: Claude Opus 5
2026-08-12 01:30:11 -04:00
def _form_values(self):
install = None
if self.query_one("#f_install", Checkbox).value and profiles:
index = self.query_one("#f_profile_install", Select).value
label, cmd = profiles[index if isinstance(index, int) else 0]
install = {
"branch": self.query_one("#f_branch", Select).value,
"prod": self.query_one("#f_prod", Checkbox).value,
"label": label,
"cmd": cmd,
"monitor": self.query_one("#f_monitor", Checkbox).value,
}
key = self.query_one("#f_key", Input).value.strip()
return {
# Le suivi est demandé au NIVEAU DU DÉPLOIEMENT, pas de
# l'installation : décocher ERPLibre emportait la case avec
# elle, et le tableau de bord ne s'ouvrait plus du tout.
"monitor": self.query_one("#f_monitor", Checkbox).value,
"gpu3d": self.query_one("#f_gpu3d", Checkbox).value,
"res_label": (
t("custom")
if self.profile == "custom"
else f"x{self.profile}"
),
"ssh_key": os.path.expanduser(key) if key else "",
[REF] déploiement : un seul socle pour ce qui décrit le système invité Type de VM, production, magasin d'applications, outils de développement, fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas la machine qui le porte. Ils valaient donc déjà sur Proxmox — l'écran n'en offrait que trois. Une VM créée là-bas naissait serveur nu, sans outils et en UTC, et rien ne le disait. La duplication était le mécanisme de la dérive : chaque correctif se posait sur un seul des deux écrans. Les six vivent maintenant dans un socle commun, widgets ET logique, et le contexte qui les nourrit est écrit une fois. L'écran QEMU/KVM perd 330 lignes sans qu'un widget ni une valeur de spec ne bouge — vérifié en montant l'ancien et le nouveau dans le même processus. Trois conséquences se sont propagées seules : le disque annoncé (bureau et outils compris) atteint enfin « qm resize », le parallélisme suit les cœurs de l'hôte au lieu d'un plafond de quatre, et le nom prend le suffixe du bureau — sans lui, une VM graphique et sa jumelle serveur se disputaient le même. Deux défauts nommés au passage. Le fuseau : « qm set » n'en pose pas, il part maintenant par ssh avant l'installation. Et l'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que les domaines d'ici — une VM ARM prise pour x86_64 recevait Android Studio, que Google ne publie pas pour elle. Le test porte sur la PARITÉ, pas sur six comportements : ajouter un réglage à un seul écran le fait échouer. Il a d'abord échoué à se lancer — hors des préfixes du lanceur, douze tests n'ont jamais tourné et le total n'avait pas bougé. La règle de nommage est maintenant dans son en-tête. --- EN --- VM type, production, app store, development tools, timezone, Python interpreter: six settings that describe the guest, not the machine hosting it. They already applied to Proxmox — the screen offered three. A VM created there was born a bare server, no tools, in UTC, and nothing said so. Duplication was the mechanism of the drift: each fix landed on one screen only. The six now live in a shared foundation, widgets AND logic, and the context feeding them is written once. The QEMU/KVM screen loses 330 lines with no widget and no spec value moving — verified by mounting the old and the new in one process. Three consequences followed on their own: the announced disk (desktop and tools included) finally reaches "qm resize", parallelism follows the host's cores instead of a cap of four, and the name takes the desktop suffix — without it a graphical VM and its server twin fought over the same one. Two defects named along the way. The timezone: "qm set" sets none, it now goes over ssh before the install. And a Proxmox VM's architecture came from "virsh", which only knows local domains — an ARM VM taken for x86_64 got Android Studio, which Google does not publish for it. The test covers PARITY, not six behaviours: adding a setting to one screen alone fails it. It first failed to run at all — outside the runner's prefixes, twelve tests never ran and the total had not moved. The naming rule is now in its header. Assisted-by: Claude Opus 5
2026-08-24 22:45:50 -04:00
**self.extras_values(),
"install": install,
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
# Une exécution par installation : le nombre de VM retenues
# fait foi. Le déploiement le borne ensuite à ce même nombre,
# donc une valeur haute ne crée jamais de travailleur inutile.
"parallelism": (
max(1, len(self.vms))
if self.query_one("#f_par_all", Checkbox).value
else self.query_one("#f_par", Select).value
),
}
def action_preview(self) -> None:
spec = build_spec(self.vms, domains, self._form_values())
build = ctx.get("build_command")
if not build:
return
lines = [build(vm, spec, True) for vm in spec["vms"]]
self.push_screen(
preview_screen()(lines or [t("Nothing selected.")])
)
[ADD] tui qemu: F9 dumps the form state, widgets and model A per-VM setting appears not to be taken into account, and nothing on screen tells which link failed: the gap may be between the widget and the model, or between the model and the spec. A screenshot shows neither. F9 writes both side by side into ~/.erplibre/deploy-form-dump.txt: profile, shared values, overrides, locks, row generation, then VM by VM what the list DISPLAYS against what the model HOLDS, and finally the spec that would go to deployment. The file can be pasted into a message, unlike an image, and it answers the question on its own: if the list shows 16384 while the model says 1024, the defect is in the intake; if they agree and the created VM differs, it is downstream, in deploy_qemu. --- FR --- Un réglage par VM ne semble pas pris en compte, et rien dans ce que l'écran montre ne permet de trancher : l'écart peut être entre le widget et le modèle, ou entre le modèle et la spec. Une capture d'écran ne dit ni l'un ni l'autre. F9 écrit les deux côte à côte dans ~/.erplibre/deploy-form-dump.txt : profil, valeurs communes, surcharges, verrous, génération des rangées, puis VM par VM ce que la liste AFFICHE contre ce que le modèle CONTIENT, et enfin la spec qui partirait au déploiement. Le fichier se recopie dans un message, contrairement à une image, et il répond seul à la question : si la liste montre 16384 et que le modèle dit 1024, le défaut est dans la prise en compte ; s'ils s'accordent et que la VM créée diffère, il est en aval, dans deploy_qemu. Assisted-by: Claude Opus 5
2026-08-13 04:51:27 -04:00
def action_dump_state(self) -> None:
"""Écrit l'état COMPLET dans un fichier, widgets ET modèle.
Une capture d'écran ne dit pas si l'écart vient de ce qu'on voit
ou de ce qui sera déployé. Ce vidage met les deux côte à côte, VM
par VM : si la liste affiche 16384 et que le modèle dit 1024, le
défaut est dans la prise en compte ; s'ils s'accordent et que la
VM déployée diffère, il est en aval."""
path = os.path.expanduser("~/.erplibre/deploy-form-dump.txt")
os.makedirs(os.path.dirname(path), exist_ok=True)
out = [
"=== formulaire de deploiement ===",
f"profil={self.profile} custom={self.custom}",
f"verrous={sorted(self.locked)}",
f"surcharges={self.overrides}",
f"generation={self._gen} jeu_monte={self._shown_ids}",
f"branche_globale={self._branch()}",
f"profil_odoo_global={self._profile_cmd()}",
"",
" # widget -> modele, VM par VM",
]
for i, r in enumerate(self.rows):
vm = r["vm"]
shown = {}
for f in ("vcpus", "ram", "disk", "type", "branch", "prof"):
try:
shown[f] = self.query_one(f"#v{i}_{f}", Select).value
except Exception:
shown[f] = "-"
try:
free = self.query_one(f"#c{i}_{f}", Input)
if free.display:
shown[f] = f"libre:{free.value!r}"
except Exception:
pass
out.append(f" [{i}] {vm['name']} etat={r['state']}")
out.append(f" widgets = {shown}")
out.append(
f" modele = vcpus={vm['vcpus']} ram={vm['ram']} "
f"disk={vm['disk']} desktop={vm.get('desktop')!r} "
f"branch={vm.get('branch')!r} cmd={vm.get('install_cmd')!r}"
)
spec = build_spec(self.vms, domains, self._form_values())
out.append("")
out.append(" # spec qui partirait au deploiement")
for vm in spec["vms"]:
out.append(
f" {vm['name']}: ram={vm['ram']} vcpus={vm['vcpus']} "
f"disk={vm['disk']}"
)
out.append(f" ignorees (existent deja) = {spec['existing']}")
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(out) + "\n")
self.notify(f"{t('State written to')} {path}")
def action_deploy(self) -> None:
if not self.vms:
self.notify(t("Nothing selected."), severity="warning")
return
spec = build_spec(self.vms, domains, self._form_values())
if not spec["vms"]:
self.notify(
t("Nothing to create - every VM already exists."),
severity="warning",
)
return
orphans = [r for r in self.rows if r["state"] == "orphan"]
if orphans and not getattr(self, "_orphan_ack", False):
# Un qcow2 orphelin fait échouer deploy_qemu : on prévient une
# première fois, F5 à nouveau vaut confirmation.
self._orphan_ack = True
self.notify(
t("orphan disk - will FAIL")
+ f" ({len(orphans)}) — "
+ t("press F5 again to confirm"),
severity="error",
timeout=10,
)
return
result["spec"] = spec
self.exit()
def action_cancel(self) -> None:
self.exit()
app = DeployForm()
# Exposé pour les tests headless (run_app=False), qui pilotent l'app
# eux-mêmes et ont besoin de lire la spec produite.
app._result = result
if not run_app:
return app
app.run()
return result["spec"]
# --------------------------------------------------------------------------- #
# Vue de progression : un bloc repliable par VM
# --------------------------------------------------------------------------- #