From d35950ab97346e1be333578cc832784153181076 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 00:33:04 -0400 Subject: [PATCH] [ADD] tui qemu: freeze one VM's resources, row in green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/todo/qemu_deploy_form.py | 291 +++++++++++++++++++++++++++++++- 1 file changed, 285 insertions(+), 6 deletions(-) diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index b7ad1fe..7e32146 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -388,8 +388,7 @@ def run_deploy_form(ctx, run_app: bool = True): /* 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. */ - - + .vmbranch { width: 18; } .vmcopy { width: 5; min-width: 5; } .vmhead { height: 1; } .vmrow { height: 3; align-vertical: middle; } @@ -694,7 +693,9 @@ def run_deploy_form(ctx, run_app: bool = True): # 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): - row["custom"] = bool(self.overrides.get(entry_key(entry))) + key = entry_key(entry) + row["custom"] = bool(self.overrides.get(key)) + row["locked"] = key in self.locked self._render_plan() self._render_mise() self._render_store() @@ -803,7 +804,19 @@ def run_deploy_form(ctx, run_app: bool = True): widgets = [] for i, r in enumerate(self.rows): vm = r["vm"] + key = entry_key(self._selected_entries()[i]) row = Horizontal( + # « + » 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", + ), Select( [(str(c), c) for c in ctx["cpu_presets"]] + [(t("free value
"), FREE)], @@ -867,6 +880,22 @@ def run_deploy_form(ctx, run_app: bool = True): id=f"c{i}_disk", classes="freeval", ), + ( + Button("−", id=f"m{i}", classes="vmcopy") + if item.get("instance") + else Static("", classes="vmcopy") + ), + Select( + [(b, b) for b in branches], + classes="vmbranch", + value=( + item.get("branch") + or self.rows[i]["vm"].get("branch") + or branches[0] + ), + allow_blank=False, + id=f"v{i}_branch", + ), Select( self._type_options(), value=vm.get("desktop") or SERVER, @@ -879,7 +908,15 @@ def run_deploy_form(ctx, run_app: bool = True): Vertical( Static(self._row_head(i, r), id=f"h{i}"), row, - classes="vmcard", + # 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" + ), ) ) @@ -910,6 +947,50 @@ def run_deploy_form(ctx, run_app: bool = True): self._sync_free_inputs() self._syncing = False + def _refresh_row_widgets(self) -> None: + """Remet les listes de chaque rangĂ©e sur ce que la VM vaut MAINTENANT. + + Sans cela, changer le profil x1..x4 mettait les totaux Ă  jour mais + laissait les listes sur leurs anciennes valeurs : l'Ă©cran affichait + 8192 pendant que la VM valait 4096. Les rangĂ©es ne sont remontĂ©es + que si le JEU de VM change — pour ne pas voler le focus — donc ce + rafraĂźchissement doit se faire Ă  la main. + + Aucun risque de boucle : on_select_changed ignore une valeur dĂ©jĂ  + Ă©gale Ă  celle du modĂšle, et le modĂšle vient prĂ©cisĂ©ment d'ĂȘtre + recalculĂ©.""" + for i, r in enumerate(self.rows): + vm = r["vm"] + for field, presets in ( + ("vcpus", ctx["cpu_presets"]), + ("ram", ctx["ram_presets"]), + ("disk", ctx["disk_presets"]), + ): + try: + sel = self.query_one(f"#v{i}_{field}", Select) + except Exception: + continue + # Une liste posĂ©e sur « libre
 » ne doit PAS ĂȘtre remise + # sur une valeur : l'utilisateur vient de la choisir, et + # tant qu'il n'a rien tapĂ© la VM vaut encore celle du + # profil — on la lui reprendrait sous les doigts. + if sel.value is FREE: + continue + # Une valeur libre dĂ©jĂ  saisie n'est dans aucune liste : + # liste vide, la saisie Ă  cĂŽtĂ© porte le nombre. + sel.value = ( + vm[field] if vm[field] in presets else SELECT_NULL + ) + for wid, value in ( + (f"#v{i}_type", vm.get("desktop") or SERVER), + (f"#v{i}_branch", vm.get("branch") or self._branch()), + ): + try: + self.query_one(wid, Select).value = value + except Exception: + pass + self._sync_free_inputs() + def _sync_free_inputs(self) -> None: for i, r in enumerate(self.rows): vm = r["vm"] @@ -922,7 +1003,16 @@ def run_deploy_form(ctx, run_app: bool = True): widget = self.query_one(f"#c{i}_{field}", Input) except Exception: continue - free = vm[field] not in presets + # Visible si la valeur EST libre, ou si la liste est + # posĂ©e sur « libre
 » en attente d'une saisie. + try: + chosen_free = ( + self.query_one(f"#v{i}_{field}", Select).value + is FREE + ) + except Exception: + chosen_free = False + free = chosen_free or vm[field] not in presets widget.display = free widget.disabled = not free @@ -933,7 +1023,12 @@ def run_deploy_form(ctx, run_app: bool = True): vm = row["vm"] icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[row["state"]] state = "" if row["state"] == "new" else f" {icon}{row['note']}" - mark = " ✎" if row.get("custom") else "" + if row.get("locked"): + mark = " 🔒 figĂ©e" + elif row.get("custom"): + mark = " ✎" + else: + mark = "" return ( f"[b]{vm['name']}[/b] {vm['distro']} {vm['version']} " f"[{vm['arch']}] {row['disk_gb']}G{state}{mark}" @@ -953,6 +1048,7 @@ def run_deploy_form(ctx, run_app: bool = True): ) except Exception: pass + 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. @@ -995,12 +1091,23 @@ def run_deploy_form(ctx, run_app: bool = True): self.arch = arches[event.radio_set.pressed_index] self._reload_catalog() elif event.radio_set.id == "f_type": + self._clear_overrides(("desktop",)) # Recalcul : le disque annonce inclut le bureau, et la # colonne Statut affiche le type de VM. self._recompute() elif event.radio_set.id == "f_profile": index = event.radio_set.pressed_index self.profile = "custom" if index == 4 else str(index + 1) + # 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) custom = self.profile == "custom" for field, (sel, _inp) in RES_FIELDS.items(): self.query_one(sel, Select).disabled = not custom @@ -1027,6 +1134,36 @@ def run_deploy_form(ctx, run_app: bool = True): entries = self._selected_entries() return entry_key(entries[index]) if index < len(entries) else None + def _clear_overrides(self, fields) -> None: + """Rend au choix commun les VM NON figĂ©es, pour ces champs-lĂ . + + Le cadenas est la seule chose qui rĂ©siste. Une valeur rĂ©glĂ©e Ă  la + main sur une rangĂ©e cĂšde donc au choix global suivant : c'est ce + qu'on attend d'un rĂ©glage « gĂ©nĂ©ral », et le verrou existe + prĂ©cisĂ©ment pour dire « pas celle-ci ». + + Par champ, pas en bloc : changer la RAM gĂ©nĂ©rale n'a aucune raison + d'effacer le disque qu'on a rĂ©glĂ© sur une VM. + + « name » n'y figure jamais : un renommage est explicite et ne + dĂ©coule d'aucune valeur gĂ©nĂ©rale.""" + changed = False + for key in list(self.overrides): + if key in self.locked: + continue + for field in fields: + changed |= self.overrides[key].pop(field, None) is not None + if not self.overrides[key]: + self.overrides.pop(key, None) + if changed: + # Forcer le remontage : une rangĂ©e peut porter une saisie + # LIBRE, que le simple rafraĂźchissement laisse en place — on + # verrait « 12 » Ă  l'Ă©cran pendant que la VM vaut 2. Le + # remontage rebĂątit tout depuis le modĂšle. Sans risque de vol + # de focus : ce chemin part d'un widget GLOBAL, jamais d'une + # rangĂ©e. + self._shown_ids = () + def _set_override(self, index, field, value) -> None: """Écrit — ou retire — la surcharge d'UNE VM.""" key = self._row_key(index) @@ -1121,6 +1258,19 @@ def run_deploy_form(ctx, run_app: bool = True): self._set_override(index, field, event.value) self._recompute() return + # Les choix GLOBAUX de branche et de profil ne portent aucune + # valeur de ressource : ils tombaient donc dans le « return » + # ci-dessous sans rien recalculer, et les rangĂ©es restaient sur + # l'ancienne version. Elles n'en gardent pas de copie — « » y + # veut dire « celle du formulaire » — il suffit de redessiner. + if event.select.id in ("f_branch", "f_profile_install"): + self._clear_overrides( + ("branch",) + if event.select.id == "f_branch" + else ("install_cmd",) + ) + self._recompute() + return field = SELECT_TO_FIELD.get(event.select.id) if not field: return @@ -1134,6 +1284,7 @@ def run_deploy_form(ctx, run_app: bool = True): self._free[field] = False self._show_free(field, False) self.custom[field] = event.value + self._clear_overrides((field,)) self._recompute() def _apply_free(self, field) -> None: @@ -1146,6 +1297,7 @@ def run_deploy_form(ctx, run_app: bool = True): self.custom[field] = parse_ram(raw) else: self.custom[field] = positive_int(raw, 0) + self._clear_overrides((field,)) def on_input_changed(self, event) -> None: if self._syncing: @@ -1166,6 +1318,133 @@ def run_deploy_form(ctx, run_app: bool = True): self._apply_free(field) self._recompute() + def _set_lock(self, index, on) -> None: + """Fige — ou libĂšre — les ressources d'une VM. + + Figer, c'est recopier les valeurs EFFECTIVES du moment dans les + surcharges : le profil commun ne les atteint plus. LibĂ©rer les + retire, et la VM retombe sous le profil. Le mĂ©canisme est celui + des surcharges, dĂ©jĂ  Ă©prouvĂ© ; le verrou n'en est que la commande + explicite, et il couvre les quatre champs d'un coup.""" + key = self._row_key(index) + if key is None or index >= len(self.rows): + return + if on: + vm = self.rows[index]["vm"] + self.locked.add(key) + # TOUT ce que la VM tient d'un choix commun est recopiĂ©, pas + # seulement les ressources : la branche et le profil Odoo en + # font partie. Les oublier laissait une VM « figĂ©e » changer + # de version d'ERPLibre dĂšs qu'on touchait au choix gĂ©nĂ©rique, + # ce qui vide le mot de son sens. + # + # Les deux se rĂ©solvent AVANT d'ĂȘtre figĂ©s : « » y signifie + # « celle du formulaire », et geler une chaĂźne vide ne + # gĂšlerait rien du tout. + self.overrides[key] = { + "vcpus": vm["vcpus"], + "ram": vm["ram"], + "disk": vm["disk"], + "desktop": vm.get("desktop") or "", + "branch": vm.get("branch") or self._branch(), + "install_cmd": ( + vm.get("install_cmd") or self._profile_cmd() + ), + } + else: + self.locked.discard(key) + self.overrides.pop(key, None) + self._recompute() + # La couleur de la ligne suit le verrou sans tout remonter : un + # remontage volerait le focus Ă  la case qu'on vient de cocher. + cards = self.query_one("#plan", VerticalScroll).children + if index < len(cards): + cards[index].set_class(on, "locked") + btn = self.query_one(f"#l{index}", Button) + btn.label = "🔒" if on else "🔓" + btn.variant = "success" if on else "default" + + def _add_copy(self, index, delta) -> None: + """Ajoute ou retire un exemplaire de l'entrĂ©e visĂ©e. + + Retirer enlĂšve le DERNIER exemplaire, et avec lui ses rĂ©glages : + les garder ferait resurgir d'anciennes valeurs Ă  la copie + suivante, sans que rien ne l'explique.""" + entries = self._plan_entries() + if index >= len(entries): + return + item = entries[index] + base = (item["distro"], item["version"], item["arch"]) + count = self.copies.get(base, 0) + if delta > 0: + self.copies[base] = count + 1 + else: + if count <= 0: + return + gone = (*base, count) + self.overrides.pop(gone, None) + self.locked.discard(gone) + self.copies[base] = count - 1 + if not self.copies[base]: + self.copies.pop(base, None) + self._recompute() + # Le JEU de VM a changĂ© : les rangĂ©es doivent ĂȘtre rebĂąties. + self._mount_rows() + + def on_button_pressed(self, event) -> None: + match = re.match(r"([pm])(\d+)$", event.button.id or "") + if match: + self._add_copy( + int(match.group(2)), 1 if match.group(1) == "p" else -1 + ) + return + match = re.match(r"r(\d+)$", event.button.id or "") + if match: + self._rename(int(match.group(1))) + return + match = re.match(r"l(\d+)$", event.button.id or "") + if match: + index = int(match.group(1)) + key = self._row_key(index) + if key is not None: + self._set_lock(index, key not in self.locked) + + def _rename(self, index) -> None: + """Renomme une VM. Le nom saisi devient une surcharge comme les + autres : il survit au recalcul, et F4 le retire avec le reste.""" + key = self._row_key(index) + if key is None or index >= len(self.rows): + return + entries = self._plan_entries() + auto = vm_name( + entries[index]["name"], + self.rows[index]["vm"].get("desktop"), + desktop_suffixes, + ) + + def done(value): + if value is None: + return + if not str(value).strip(): + self.overrides.get(key, {}).pop("name", None) + if not self.overrides.get(key): + self.overrides.pop(key, None) + else: + clean = clean_hostname(value) + if not clean: + self.notify( + t("Invalid name: letters, digits, hyphens."), + severity="error", + ) + return + self.overrides.setdefault(key, {})["name"] = clean + self._recompute() + self._mount_rows() + + self.push_screen( + RenameScreen(self.rows[index]["vm"]["name"], auto), done + ) + def on_checkbox_changed(self, event) -> None: if event.checkbox.id == "f_install": self._recompute() # le disque annoncĂ© inclut le +5 G ERPLibre