diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 33087b8..81a6f10 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -21,6 +21,7 @@ préchargées par l'appelant et arrivent dans `ctx`. from __future__ import annotations import os +import re import time try: @@ -76,7 +77,33 @@ def positive_int(value, fallback): return number if number > 0 else fallback -def apply_profile(entries, profile, base_vcpus, host_cpu, custom=None): +def parse_ram(value): + """« 2048 », « 128G », « 1,5G » -> mébioctets, ou 0 si invalide. + + Les valeurs proposées s'affichent en G — « 2G », « 16G » — alors que la + saisie libre comptait en Mo. Taper « 128G », ce que l'affichage invite à + faire, rendait 0 : la surcharge était alors RETIRÉE et la VM revenait à la + valeur du catalogue, sans un mot. On accepte donc les deux écritures, un + nombre nu restant des mébioctets.""" + txt = str(value or "").strip().upper().replace(",", ".") + factor = 1 + if txt.endswith("GI"): + factor, txt = 1024, txt[:-2] + elif txt.endswith(("G", "T")): + factor = 1024 * (1024 if txt.endswith("T") else 1) + txt = txt[:-1] + elif txt.endswith(("M", "MI")): + txt = txt.rstrip("IM") + try: + mib = int(float(txt) * factor) + except ValueError: + return 0 + return mib if mib > 0 else 0 + + +def apply_profile( + entries, profile, base_vcpus, host_cpu, custom=None, desktop="" +): """Applique le profil de ressources aux entrées choisies. Reproduit à l'identique `TODO._qemu_prompt_resources` : un multiplicateur @@ -104,6 +131,10 @@ def apply_profile(entries, profile, base_vcpus, host_cpu, custom=None): "ram": ram, "disk": disk, "vcpus": vcpus, + # Type de VM (« » = serveur). Il vit sur la VM et non sur la + # spec entière depuis qu'il se choisit machine par machine ; + # `desktop` n'est plus que le défaut commun. + "desktop": desktop, } ) return out @@ -118,10 +149,12 @@ def apply_overrides(vms, entries, overrides): return vms -def build_vms(entries, profile, base_vcpus, host_cpu, custom, overrides): +def build_vms( + entries, profile, base_vcpus, host_cpu, custom, overrides, desktop="" +): """Catalogue choisi + profil + surcharges -> liste de VM de la spec.""" return apply_overrides( - apply_profile(entries, profile, base_vcpus, host_cpu, custom), + apply_profile(entries, profile, base_vcpus, host_cpu, custom, desktop), entries, overrides, ) @@ -214,13 +247,10 @@ def run_deploy_form(ctx, run_app: bool = True): from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import ModalScreen from textual.widgets import ( - Button, Checkbox, - DataTable, Footer, Header, Input, - Label, RadioButton, RadioSet, Select, @@ -255,6 +285,9 @@ def run_deploy_form(ctx, run_app: bool = True): # Dernier choix de chaque liste de ressources : il ne porte pas de valeur, # il révèle la saisie libre placée juste dessous. FREE = "__free__" + # « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le + # rendrait indistinguable de la sentinelle « rien de sélectionné ». + SERVER = "__server__" RES_FIELDS = { "vcpus": ("#f_vcpus", "#c_vcpus"), "ram": ("#f_ram", "#c_ram"), @@ -270,50 +303,6 @@ def run_deploy_form(ctx, run_app: bool = True): f"RAM≥{e['ram']}Mo {e['disk']}" ) - class EditVMScreen(ModalScreen): - """Réglages d'UNE VM. Un champ vide garde la valeur courante.""" - - BINDINGS = [("escape", "cancel", t("Cancel"))] - - def __init__(self, vm): - super().__init__() - self._vm = vm - - def compose(self) -> ComposeResult: - with Vertical(id="editbox"): - yield Static(f" {self._vm['name']}", id="edittitle") - yield Label(t("Name")) - yield Input(value=self._vm["name"], id="e_name") - yield Label(t("vCPU")) - yield Input(value=str(self._vm["vcpus"]), id="e_vcpus") - yield Label(t("RAM (MB)")) - yield Input(value=str(self._vm["ram"]), id="e_ram") - yield Label(t("Disk")) - yield Input(value=str(self._vm["disk"]), id="e_disk") - with Horizontal(id="editbtns"): - yield Button(t("Apply"), variant="primary", id="e_ok") - yield Button(t("Cancel"), id="e_cancel") - - def on_button_pressed(self, event) -> None: - if event.button.id != "e_ok": - self.dismiss(None) - return - out = {} - name = self.query_one("#e_name", Input).value.strip() - if name: - out["name"] = name - for field, wid in (("vcpus", "#e_vcpus"), ("ram", "#e_ram")): - raw = self.query_one(wid, Input).value.strip() - if raw.isdigit() and int(raw) > 0: - out[field] = int(raw) - disk = parse_disk(self.query_one("#e_disk", Input).value) - if disk: - out["disk"] = disk - self.dismiss(out) - - def action_cancel(self) -> None: - self.dismiss(None) - class PreviewScreen(ModalScreen): """Aperçu des commandes qui seraient lancées (aucune exécution).""" @@ -347,15 +336,33 @@ def run_deploy_form(ctx, run_app: bool = True): .grouptitle { color: $accent; text-style: bold; padding: 1 0 0 0; } SelectionList { height: 10; border: solid $panel; } RadioSet { height: auto; layout: horizontal; } - .freeval { display: none; } - #reslabel { color: $text-muted; } - EditVMScreen { align: center middle; } - #editbox { - width: 56; height: auto; padding: 1 2; - border: thick $accent; background: $surface; + .freeval { display: none; width: 9; } + /* « width: auto » sur la CARTE, pas seulement sur la rangée. Un + conteneur Textual vaut « width: 1fr » par défaut : la carte se bornait + donc au panneau, et c'est ELLE que « #plan » mesure pour sa largeur + virtuelle. La rangée avait beau être en « auto », son débordement était + coupé dans une carte qui ne grandissait pas, et la barre horizontale + n'apparaissait jamais. */ + .vmcard { + height: auto; width: auto; + border-bottom: solid $panel; padding: 0 1; } - #edittitle { color: $accent; text-style: bold; } - #editbtns { height: auto; padding-top: 1; } + /* Une VM figée se voit à la LIGNE, pas à une case perdue au bout : + c'est ce qui permet de balayer le plan et de savoir d'un coup ce qui + échappe au profil. */ + .vmcard.locked { background: $success 20%; } + .vmlock { width: 5; min-width: 5; } + /* 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. */ + + + .vmcopy { width: 5; min-width: 5; } + .vmhead { height: 1; } + .vmrow { height: 3; align-vertical: middle; } + .vmrow Select { width: 15; } + .vmrow Input { width: 11; } + #reslabel { color: $text-muted; } PreviewScreen { align: center middle; } #prevbox { width: 90%; height: 70%; padding: 1 2; @@ -369,7 +376,6 @@ def run_deploy_form(ctx, run_app: bool = True): # par le champ de saisie qui a le focus. BINDINGS = [ ("f5", "deploy", t("Deploy")), - ("f2", "edit_vm", t("Edit VM")), ("f4", "clear_vm", t("Reset VM")), ("f3", "preview", t("Preview")), ("f6", "select_all", t("All")), @@ -385,15 +391,27 @@ def run_deploy_form(ctx, run_app: bool = True): self.custom = {} self._free = {} self.overrides = {} - # « all » : les champs de ressources nourrissent le profil. - # « one » : ils écrivent une surcharge pour la ligne du plan. - self.scope = "all" - # Vrai pendant qu'on repositionne les widgets nous-mêmes : sans ce - # verrou, remettre une liste à vide déclencherait on_select_changed, - # qui réécrirait une surcharge — une boucle qui se nourrit seule. + # 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. self._syncing = False - # Dernier rang connu du curseur du plan. - self._last_row = 0 + # Jeu de VM actuellement monté dans le panneau droit. + self._shown_ids = () + # Génération du jeu de rangées monté. Les identifiants de widgets + # portent un RANG, et le rang se décale quand on coche ou décoche + # une entrée : un événement émis par un widget déjà détruit + # s'appliquerait alors à la VM qui a pris sa place. Chaque widget + # de rangée retient sa génération ; ceux d'une génération périmée + # sont ignorés. + self._gen = 0 + # VM dont les ressources sont FIGÉES, par identité de catalogue. + # Distinct des surcharges : une VM peut être modifiée sans être + # verrouillée, et le verrou fige TOUT, pas seulement ce qu'on a + # touché. + self.locked = set() + # {clé de base: nombre d'exemplaires EN PLUS du premier}. + self.copies = {} + self.rows = [] self.vms = [] self.rows = [] @@ -409,7 +427,10 @@ def run_deploy_form(ctx, run_app: bool = True): yield RadioButton(label, value=a == self.arch) yield Static(t("Catalog"), classes="grouptitle") yield SelectionList(id="f_catalog") - yield Static(t("Resources per VM"), classes="grouptitle") + 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") @@ -437,12 +458,12 @@ def run_deploy_form(ctx, run_app: bool = True): for m in ctx["ram_presets"] ] + [(t("free value…"), FREE)], - prompt=t("RAM (MB)"), + prompt=t("RAM: 2048 or 8G"), id="f_ram", disabled=True, ) yield Input( - placeholder=t("RAM (MB)"), + placeholder=t("RAM: 2048 or 8G"), id="c_ram", classes="freeval", disabled=True, @@ -460,22 +481,15 @@ def run_deploy_form(ctx, run_app: bool = True): classes="freeval", disabled=True, ) - # Portée des TROIS champs ci-dessus. Le profil x1..x4 reste - # global par nature — il multiplie ce que demande chaque - # image. Ces valeurs-ci, elles, peuvent ne viser qu'une VM : - # une seule machine a besoin de 16 G, pas les huit autres. - # - # Le mode « une seule » rend les champs actifs même hors - # profil personnalisé : on y saisit une valeur absolue, pas - # un multiplicateur. - with RadioSet(id="f_scope"): - yield RadioButton(t("Apply to all VMs"), value=True) - yield RadioButton(t("Apply to the selected VM only")) - yield Static("", id="scopetarget") + 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", + ) # Serveur par défaut : c'est ce que sert une image cloud, # et GNOME ajoute une à deux heures sur une architecture # émulée. Le plan annonce le surcoût disque. - yield Static(t("VM type:"), classes="grouptitle") + yield Static(t("VM type (default):"), classes="grouptitle") with RadioSet(id="f_type"): yield RadioButton( t("Server (no graphical interface)"), @@ -563,26 +577,16 @@ def run_deploy_form(ctx, run_app: bool = True): id="f_par", ) with Vertical(id="right"): - yield DataTable(id="plan") + # 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)!") - table = self.query_one("#plan", DataTable) - table.cursor_type = "row" - table.add_columns( - t("Name"), - t("Distro"), - t("Version"), - t("Arch"), - "vCPU", - "RAM", - t("Disk"), - t("Status"), - ) self._reload_catalog(first_load=True) - self._sync_res_fields() # -- catalogue et recalcul ------------------------------------- # def _entries(self): @@ -629,14 +633,19 @@ def run_deploy_form(ctx, run_app: bool = True): host_cpu, self.custom, self.overrides, + self._default_desktop(), ) # ERPLibre et GNOME pèsent chacun sur le disque, et se cumulent. grow = 0 if self.query_one("#f_install", Checkbox).value: grow += extra_disk - if self._desktop(): - grow += desktop_disk self.rows = plan_rows(self.vms, domains, grow) + # Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle + # seule : un supplément commun mentait dès que les types + # différaient d'une machine à l'autre. + for row in self.rows: + if row["vm"].get("desktop"): + row["disk_gb"] += desktop_disk # 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 @@ -646,7 +655,6 @@ def run_deploy_form(ctx, run_app: bool = True): row["custom"] = bool(self.overrides.get(entry_key(entry))) self._render_plan() self._render_mise() - self.query_one("#scopetarget", Static).update(self._scope_label()) def _render_mise(self): """Grise le choix quand aucune VM retenue n'est servie par mise, @@ -685,51 +693,193 @@ def run_deploy_form(ctx, run_app: bool = True): def _mise_usable(self): return any(vm["arch"] in mise_arches for vm in self.vms) - def _desktop(self): + def _default_desktop(self): """« » pour un serveur, sinon la clé de la saveur choisie.""" + """Type de VM par défaut. Chaque rangée peut s'en écarter.""" index = self.query_one("#f_type", RadioSet).pressed_index if index is None or index < 1 or index > len(desktops): return "" return desktops[index - 1][0] - def _desktop_label(self): - """Libellé court du type de VM, pour le tableau et les totaux.""" - key = self._desktop() - if not key: - return t("server") - label = dict(desktops).get(key, key) - return f"🖥 {label}" + # -- panneau droit : une rangée de widgets par VM ---------------- # + def _row_ids(self): + """Identité du JEU de VM affiché. Reconstruire les widgets à chaque + frappe ferait perdre le focus en pleine saisie : on ne le fait que + si la liste elle-même a changé.""" + return tuple(entry_key(e) for e in self._selected_entries()) + + def _type_options(self): + return [(t("Server"), SERVER)] + [ + (label, key) for key, label in desktops + ] + + def _mount_rows(self) -> None: + """(Re)construit le panneau droit. + + Le verrou couvre TOUT le montage : poser « value= » sur un Select + fait émettre un Changed à Textual, que on_select_changed prenait + pour une saisie. Résultat mesuré — les trois champs de CHAQUE VM + recevaient une surcharge dès l'affichage, le profil x1..x4 ne + pouvait plus rien changer, et toutes les lignes portaient la + marque ✎. Il est relâché après le rafraîchissement, une fois ces + messages consommés.""" + self._syncing = True + self._gen += 1 + plan = self.query_one("#plan", VerticalScroll) + plan.remove_children() + widgets = [] + for i, r in enumerate(self.rows): + vm = r["vm"] + row = Horizontal( + Select( + [(str(c), c) for c in ctx["cpu_presets"]] + + [(t("free value…"), FREE)], + value=( + vm["vcpus"] + if vm["vcpus"] in ctx["cpu_presets"] + else SELECT_NULL + ), + prompt="vCPU", + id=f"v{i}_vcpus", + ), + Input( + value=( + "" + if vm["vcpus"] in ctx["cpu_presets"] + else str(vm["vcpus"]) + ), + placeholder="vCPU", + id=f"c{i}_vcpus", + classes="freeval", + ), + Select( + [(f"{m // 1024}G", m) for m in ctx["ram_presets"]] + + [(t("free value…"), FREE)], + value=( + vm["ram"] + if vm["ram"] in ctx["ram_presets"] + else SELECT_NULL + ), + prompt=t("RAM: 2048 or 8G"), + id=f"v{i}_ram", + ), + Input( + value=( + "" + if vm["ram"] in ctx["ram_presets"] + else str(vm["ram"]) + ), + placeholder=t("RAM: 2048 or 8G"), + id=f"c{i}_ram", + classes="freeval", + ), + Select( + [(d, d) for d in ctx["disk_presets"]] + + [(t("free value…"), FREE)], + value=( + vm["disk"] + if vm["disk"] in ctx["disk_presets"] + else SELECT_NULL + ), + prompt=t("Disk"), + id=f"v{i}_disk", + ), + Input( + value=( + "" + if vm["disk"] in ctx["disk_presets"] + else str(vm["disk"]) + ), + placeholder=t("Disk"), + id=f"c{i}_disk", + classes="freeval", + ), + Select( + self._type_options(), + value=vm.get("desktop") or SERVER, + allow_blank=False, + id=f"v{i}_type", + ), + classes="vmrow", + ) + widgets.append( + Vertical( + Static(self._row_head(i, r), id=f"h{i}"), + row, + classes="vmcard", + ) + ) + + # Marque de génération, posée sur CHAQUE widget de rangée. + # « walk_children() » ne voit RIEN avant le montage : les enfants + # passés au constructeur attendent dans « _pending_children ». + def mark(node): + node._el_gen = self._gen + for child in getattr(node, "_pending_children", None) or []: + mark(child) + + for card in widgets: + mark(card) + # Le montage vient APRÈS le marquage, jamais avant : « mount_all » + # consomme « _pending_children » et le vide. Marquer ensuite était + # une COURSE — gagnée sur une machine, perdue sur une autre. Perdue, + # les listes des rangées n'avaient plus de génération, TOUS les + # changements par VM étaient rejetés en silence, et seuls les + # réglages globaux semblaient agir. + if widgets: + plan.mount_all(widgets) + self._shown_ids = self._row_ids() + # Les saisies libres ne se révèlent qu'après le montage : leur + # style ne peut pas être touché avant qu'elles existent. + self.call_after_refresh(self._after_mount_rows) + + def _after_mount_rows(self) -> None: + self._sync_free_inputs() + self._syncing = False + + def _sync_free_inputs(self) -> None: + for i, r in enumerate(self.rows): + vm = r["vm"] + for field, presets in ( + ("vcpus", ctx["cpu_presets"]), + ("ram", ctx["ram_presets"]), + ("disk", ctx["disk_presets"]), + ): + try: + widget = self.query_one(f"#c{i}_{field}", Input) + except Exception: + continue + free = vm[field] not in presets + widget.display = free + widget.disabled = not free + + def _row_head(self, index, row): + """Ligne de titre d'une VM : nom, origine, état, marque de + personnalisation. Sans elle, deux rangées aux réglages différents + n'ont aucune explication à l'écran.""" + vm = row["vm"] + icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[row["state"]] + state = "" if row["state"] == "new" else f" {icon}{row['note']}" + mark = " ✎" if row.get("custom") else "" + return ( + f"[b]{vm['name']}[/b] {vm['distro']} {vm['version']} " + f"[{vm['arch']}] {row['disk_gb']}G{state}{mark}" + ) def _render_plan(self): - table = self.query_one("#plan", DataTable) - # « clear() » ramène le curseur en tête. Le plan étant recalculé à - # CHAQUE frappe, la ligne visée serait perdue entre deux saisies : - # on choisit debian, on tape 16384, le tableau se redessine, et la - # valeur suivante partirait sur ubuntu sans que rien ne le montre. - keep = table.cursor_row - table.clear() - # Le type ne concerne QUE les VM réellement créées : une VM déjà - # définie n'est pas retouchée, lui afficher « GNOME » laisserait - # croire qu'on va lui poser un bureau. - kind = self._desktop_label() - for r in self.rows: - vm = r["vm"] - icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[r["state"]] - status = kind if r["state"] == "new" else f"{icon}{r['note']}" - table.add_row( - f"{vm['name']} ✎" if r.get("custom") else vm["name"], - vm["distro"], - vm["version"], - vm["arch"], - str(vm["vcpus"]), - f"{vm['ram']}Mo", - f"{r['disk_gb']}G", - status, - ) - if self.rows: - # Borné : décocher une entrée raccourcit la liste, et un rang - # devenu hors bornes ferait perdre la ligne visée pour de bon. - table.move_cursor(row=min(max(keep, 0), len(self.rows) - 1)) + # 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 if not self.rows: # Rien de coché : un total à zéro n'apprend rien, on dit # plutôt comment remplir la liste. @@ -750,9 +900,20 @@ def run_deploy_form(ctx, run_app: bool = True): if dupes else "" ) + # Une VM DÉJÀ DÉFINIE n'est pas recréée : elle ne consomme rien + # de neuf, donc plan_totals l'écarte. Mais un total à zéro sans + # explication se lit comme un bogue — on a cru que les réglages + # par VM n'avaient aucun effet, alors qu'ils portaient sur une + # machine qui ne sera pas créée. + skipped = sum(1 for r in self.rows if r["state"] == "exists") + skip_txt = ( + f" ({skipped} {t('already defined, not counted')})" + if skipped + else "" + ) self.query_one("#totals", Static).update( f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G" - f" · {self._desktop_label()}{warn}{dup_txt}" + f"{skip_txt}{warn}{dup_txt}" ) # -- réactions aux champs -------------------------------------- # @@ -767,87 +928,38 @@ def run_deploy_form(ctx, run_app: bool = True): elif event.radio_set.id == "f_profile": index = event.radio_set.pressed_index self.profile = "custom" if index == 4 else str(index + 1) - self._sync_res_fields() - self._recompute() - elif event.radio_set.id == "f_scope": - self.scope = ( - "one" if event.radio_set.pressed_index == 1 else "all" - ) - # Les champs repartent à vide : ils décrivent désormais une - # AUTRE cible, et y laisser la valeur précédente ferait croire - # qu'elle s'y applique déjà. - self._reset_res_widgets() - self._sync_res_fields() - if self.scope == "one": - # Sans focus, le curseur du tableau reste invisible et on - # modifierait une ligne qu'on ne voit pas désignée. - self.query_one("#plan", DataTable).focus() + 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() - # -- portée des ressources -------------------------------------- # - def _cursor_index(self): - """Rang de la ligne visée dans le plan, ou None.""" - index = self.query_one("#plan", DataTable).cursor_row - return index if 0 <= index < len(self.vms) else None + # -- rangées du panneau droit ------------------------------- # + def _is_current(self, widget): + """Le widget appartient-il au jeu de rangées ACTUEL ?""" + return getattr(widget, "_el_gen", None) == self._gen - def _cursor_key(self): - """Identité de catalogue de la ligne visée, ou None. C'est elle qui - indexe les surcharges, pas le rang : cocher une autre distro ne doit - pas déplacer une personnalisation d'une VM à l'autre.""" - index = self._cursor_index() - if index is None: + def _focused_row(self): + """Rang de la VM dont un widget a le focus, ou None. C'est la seule + désignation qui ait un sens ici : il n'y a plus de curseur unique, + chaque rangée est éditable directement.""" + wid = getattr(self.focused, "id", "") or "" + match = re.match(r"[vch](\d+)(?:_|$)", wid) + if not match: return None - return entry_key(self._selected_entries()[index]) + index = int(match.group(1)) + return index if 0 <= index < len(self.rows) else None - def _scope_label(self): - """Ce que la saisie va toucher, dit en toutes lettres.""" - if self.scope != "one": - n = len(self.overrides) - if not n: - return "" - return f" ✎ {n} {t('VM(s) customised (F4 resets one)')}" - index = self._cursor_index() - if index is None: - return f" ⚠ {t('Pick a line in the plan (Tab, then arrows)')}" - vm = self.vms[index] - return ( - f" ✎ {vm['name']} — {vm['vcpus']} vCPU · " - f"{vm['ram']} Mo · {vm['disk']}" - ) + def _row_key(self, index): + entries = self._selected_entries() + return entry_key(entries[index]) if index < len(entries) else None - def _sync_res_fields(self) -> None: - """Active les trois champs quand ils ont un sens, et redit la cible. - - Portée « toutes » : ils ne servent que le profil personnalisé, comme - avant. Portée « une seule » : ils valent toujours, puisqu'on y saisit - la valeur absolue d'une VM et non un multiplicateur.""" - live = self.scope == "one" or self.profile == "custom" - for field, (sel, _inp) in RES_FIELDS.items(): - self.query_one(sel, Select).disabled = not live - self._show_free(field, live and self._free.get(field)) - self.query_one("#scopetarget", Static).update(self._scope_label()) - - def _reset_res_widgets(self) -> None: - """Remet listes et saisies à vide SANS déclencher d'écriture.""" - self._syncing = True - try: - for field, (sel, inp) in RES_FIELDS.items(): - self.query_one(sel, Select).value = SELECT_NULL - self.query_one(inp, Input).value = "" - self._free[field] = False - self._show_free(field, False) - finally: - self._syncing = False - - def _set_resource(self, field, value) -> None: - """Écrit une ressource là où la portée le demande.""" - if self.scope != "one": - self.custom[field] = value - return - key = self._cursor_key() + def _set_override(self, index, field, value) -> None: + """Écrit — ou retire — la surcharge d'UNE VM.""" + key = self._row_key(index) if key is None: return - if value in ("", 0): + if value in ("", 0, None): # Saisie vidée ou invalide : on RETIRE la surcharge au lieu # d'écrire un zéro, qui donnerait une VM à 0 vCPU. self.overrides.get(key, {}).pop(field, None) @@ -856,6 +968,21 @@ def run_deploy_form(ctx, run_app: bool = True): else: self.overrides.setdefault(key, {})[field] = value + def _row_free(self, index, field, visible) -> None: + widget = self.query_one(f"#c{index}_{field}", Input) + widget.display = bool(visible) + widget.disabled = not visible + if visible: + widget.focus() + + def _read_row_free(self, index, field): + raw = self.query_one(f"#c{index}_{field}", Input).value.strip() + if field == "disk": + return parse_disk(raw) or "" + if field == "ram": + return parse_ram(raw) + return positive_int(raw, 0) + def _show_free(self, field, visible) -> None: """Montre ou cache la saisie libre d'une ressource.""" widget = self.query_one(RES_FIELDS[field][1], Input) @@ -868,6 +995,59 @@ def run_deploy_form(ctx, run_app: bool = True): def on_select_changed(self, event) -> None: if self._syncing: return + wid = event.select.id or "" + row = re.match(r"v(\d+)_(vcpus|ram|disk|type|branch|prof)$", wid) + if row and not self._is_current(event.select): + # Widget d'une génération périmée : son rang ne désigne plus + # la même VM. L'appliquer écraserait le réglage d'une voisine. + return + if row: + index, field = int(row.group(1)), row.group(2) + if index >= len(self.rows): + return + # Poser « value= » au montage fait émettre un Changed que + # Textual délivre APRÈS coup : un verrou temporel ne l'attrape + # pas — mesuré, les trois champs de chaque VM se retrouvaient + # surchargés dès l'affichage et le profil x1..x4 devenait + # inopérant. On compare donc à ce que le modèle dit déjà : une + # valeur identique n'est pas une saisie, c'est l'écho. + # + # Cas limite assumé : choisir explicitement la valeur que le + # profil donne déjà n'enregistre pas de surcharge. La VM + # suivra donc le profil s'il change — ce qui est aussi le plus + # attendu quand on n'a rien changé de visible. + vm_now = self.rows[index]["vm"] + if field == "type": + new_desk = "" if event.value == SERVER else event.value + if new_desk == (vm_now.get("desktop") or ""): + return + elif ( + event.value is not FREE and event.value is not SELECT_NULL + ): + if event.value == vm_now.get(field): + return + if field == "type": + self._set_override( + index, + "desktop", + "" if event.value == SERVER else event.value, + ) + # « Serveur » est un choix légitime, pas un retrait : on le + # note explicitement pour qu'il tienne face au défaut. + if event.value == SERVER: + key = self._row_key(index) + if key is not None: + self.overrides.setdefault(key, {})["desktop"] = "" + elif event.value is FREE: + self._row_free(index, field, True) + self._set_override( + index, field, self._read_row_free(index, field) + ) + elif event.value is not SELECT_NULL: + self._row_free(index, field, False) + self._set_override(index, field, event.value) + self._recompute() + return field = SELECT_TO_FIELD.get(event.select.id) if not field: return @@ -880,7 +1060,7 @@ def run_deploy_form(ctx, run_app: bool = True): elif event.value is not SELECT_NULL: self._free[field] = False self._show_free(field, False) - self._set_resource(field, event.value) + self.custom[field] = event.value self._recompute() def _apply_free(self, field) -> None: @@ -888,32 +1068,31 @@ def run_deploy_form(ctx, run_app: bool = True): profil retombe alors sur celle du catalogue.""" raw = self.query_one(RES_FIELDS[field][1], Input).value.strip() if field == "disk": - self._set_resource(field, parse_disk(raw) or "") + self.custom[field] = parse_disk(raw) or "" + elif field == "ram": + self.custom[field] = parse_ram(raw) else: - self._set_resource(field, positive_int(raw, 0)) + self.custom[field] = positive_int(raw, 0) def on_input_changed(self, event) -> None: if self._syncing: return + wid = event.input.id or "" + row = re.match(r"c(\d+)_(vcpus|ram|disk)$", wid) + if row and not self._is_current(event.input): + return + if row: + index, field = int(row.group(1)), row.group(2) + self._set_override( + index, field, self._read_row_free(index, field) + ) + self._recompute() + return field = INPUT_TO_FIELD.get(event.input.id) if field: self._apply_free(field) self._recompute() - def on_data_table_row_highlighted(self, event) -> None: - """Changer de ligne change la cible : on repart de champs vides, - pour ne pas reporter par mégarde le réglage de la VM précédente. - - Le rang précédent est mémorisé plutôt que testé sur un verrou : - replacer le curseur après un redessin poste le même message, et - Textual le délivre APRÈS que le verrou soit retombé.""" - if event.cursor_row == self._last_row: - return - self._last_row = event.cursor_row - if self.scope == "one": - self._reset_res_widgets() - self._sync_res_fields() - def on_checkbox_changed(self, event) -> None: if event.checkbox.id == "f_install": self._recompute() # le disque annoncé inclut le +5 G ERPLibre @@ -935,32 +1114,21 @@ def run_deploy_form(ctx, run_app: bool = True): if e.get("default"): widget.select(i) - def action_edit_vm(self) -> None: - table = self.query_one("#plan", DataTable) - index = table.cursor_row - if not (0 <= index < len(self.vms)): - return - entries = self._selected_entries() - key = entry_key(entries[index]) - - def apply(changes): - if changes: - self.overrides.setdefault(key, {}).update(changes) - self._recompute() - - self.push_screen(EditVMScreen(dict(self.vms[index])), apply) - def action_clear_vm(self) -> None: - """Rend la VM visée au profil commun. Sans cette sortie, une - personnalisation posée par erreur ne se défaisait qu'en rouvrant le - formulaire.""" - key = self._cursor_key() + """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) if key is None or key not in self.overrides: return self.overrides.pop(key) - self._reset_res_widgets() - self._sync_res_fields() + # Les widgets de la rangée portent encore l'ancienne valeur : on + # les remonte pour qu'ils disent la vérité. self._recompute() + self._mount_rows() def _form_values(self): install = None @@ -987,7 +1155,7 @@ def run_deploy_form(ctx, run_app: bool = True): "timezone": self.query_one("#f_tz", Input).value.strip() or ctx.get("timezone") or "", - "desktop": self._desktop(), + "desktop": self._default_desktop(), "python_provider": self._python_provider(), "install": install, "add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value, diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 2558bf0..dc6664f 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -249,7 +249,13 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: # En-tête d'emblée (date/distro/version/arch) : le log n'est jamais # vide, l'utilisateur voit tout de suite QUOI s'installe. Path(log_path).write_text(_log_header(vm, branch, when)) - _launch_one(vm["ip"], remote_cmd, log_path, vm["name"]) + # Une VM peut porter SA commande : depuis que le type de VM (serveur + # ou bureau) se choisit machine par machine, le script distant n'est + # plus le même pour toutes. `remote_cmd` reste le défaut, ce qui laisse + # intacts les appelants qui n'en fournissent qu'une. + _launch_one( + vm["ip"], vm.get("remote_cmd") or remote_cmd, log_path, vm["name"] + ) entries.append( { "name": vm["name"], diff --git a/script/todo/todo.py b/script/todo/todo.py index 278915d..5490ca9 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -4797,8 +4797,17 @@ class TODO: run_monitor, ) + # `desktop` accepte une SAVEUR unique (toutes les VM) ou un dict + # {nom: saveur} depuis que le type se choisit machine par machine. La + # commande distante en dépend, donc elle se construit par VM ; celle-ci + # reste le défaut pour les noms absents du dict. + desk_map = desktop if isinstance(desktop, dict) else {} remote = self._qemu_erplibre_remote_cmd( - branch, final_cmd, prod, desktop, python_provider + branch, + final_cmd, + prod, + "" if desk_map else desktop, + python_provider, ) try: mod = self._qemu_import_module() @@ -4815,15 +4824,22 @@ class TODO: if mod else (None, None, None) ) - vms.append( - { - "name": name, - "ip": ip, - "distro": d, - "version": v, - "arch": a, - } - ) + entry = { + "name": name, + "ip": ip, + "distro": d, + "version": v, + "arch": a, + } + if desk_map: + entry["remote_cmd"] = self._qemu_erplibre_remote_cmd( + branch, + final_cmd, + prod, + desk_map.get(name, ""), + python_provider, + ) + vms.append(entry) else: print(f" {name}: {t('no IP, skipped.')}") if not vms: @@ -5361,7 +5377,9 @@ class TODO: dry_run=dry_run, timezone=spec.get("timezone"), locale=spec.get("locale"), - desktop=bool(spec.get("desktop")), + # Le type suit la VM. Repli sur la valeur de spec pour la CLI, + # qui ne pose la question qu'une fois pour tout le parc. + desktop=bool(vm.get("desktop", spec.get("desktop"))), ) # ---------------------------------------------------------------- # @@ -6121,7 +6139,18 @@ class TODO: deployed = list(spec.get("existing") or []) install = spec.get("install") install_branch = install["branch"] if install else None - desktop = spec.get("desktop") or "" + # Le type de VM est choisi machine par machine dans la TUI ; la CLI n'en + # pose qu'un pour tout le parc. On ramene les deux a la meme carte, et + # `desktop` reste la reponse a « faut-il installer un bureau quelque + # part ? », qui declenche la phase d'installation. + desktop_default = spec.get("desktop") or "" + desktop_map = { + vm["name"]: (vm.get("desktop", desktop_default) or "") + for vm in pending + } + for _name in deployed: + desktop_map.setdefault(_name, desktop_default) + desktop = next((d for d in desktop_map.values() if d), "") python_provider = spec.get("python_provider") or "" ssh_key = spec.get("ssh_key") add_ssh_config = spec["add_ssh_config"] @@ -6196,7 +6225,7 @@ class TODO: ip_map, install["cmd"] if install else None, install["prod"] if install else False, - desktop=desktop, + desktop=desktop_map, python_provider=python_provider, ) else: @@ -6212,7 +6241,7 @@ class TODO: ip_map.get(name), install["cmd"], install["prod"], - desktop=desktop, + desktop=desktop_map.get(name, ""), python_provider=python_provider, ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c59292d..a716ed4 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2139,26 +2139,146 @@ TRANSLATIONS = { "fr": "Éditer la VM", "en": "Edit VM", }, + "Server": { + "fr": "Serveur", + "en": "Server", + }, + "VM type (default):": { + "fr": "Type de VM (défaut) :", + "en": "VM type (default):", + }, + "These values are the default for every VM;": { + "fr": "Ces valeurs sont le défaut de chaque VM ;", + "en": "These values are the default for every VM;", + }, + "adjust any of them per VM on the right.": { + "fr": "ajustez-les VM par VM dans la vue de droite.", + "en": "adjust any of them per VM on the right.", + }, + "Application store:": { + "fr": "Magasin d'applications :", + "en": "Application store:", + }, + "Application store (graphical Ubuntu VMs):": { + "fr": "Magasin d'applications (VM Ubuntu graphiques) :", + "en": "Application store (graphical Ubuntu VMs):", + }, + "deb only (epiphany-browser)": { + "fr": "deb uniquement (epiphany-browser)", + "en": "deb only (epiphany-browser)", + }, + "Flatpak tooling, no Flathub": { + "fr": "outillage Flatpak, sans Flathub", + "en": "Flatpak tooling, no Flathub", + }, + "snap (Ubuntu default, Firefox)": { + "fr": "snap (défaut Ubuntu, Firefox)", + "en": "snap (Ubuntu default, Firefox)", + }, + "snap needs the store; slow under emulation.": { + "fr": "snap exige le store ; lent sous émulation.", + "en": "snap needs the store; slow under emulation.", + }, + "No graphical VM on a snap-based distro.": { + "fr": "Aucune VM graphique sur une distribution à snap.", + "en": "No graphical VM on a snap-based distro.", + }, + "Resources — applied to ALL VMs": { + "fr": "Ressources — appliquées à TOUTES les VM", + "en": "Resources — applied to ALL VMs", + }, + "The profile and these fields change EVERY VM.": { + "fr": "Le profil et ces champs changent TOUTES les VM.", + "en": "The profile and these fields change EVERY VM.", + }, + "A VM edited on the right (marked) keeps its own.": { + "fr": "Une VM modifiée à droite (marquée ✎) garde les siennes.", + "en": "A VM edited on the right (marked) keeps its own.", + }, + "Rename the VM": { + "fr": "Renommer la VM", + "en": "Rename the VM", + }, + "Rename": { + "fr": "Renommer", + "en": "Rename", + }, + "Empty = back to the automatic name:": { + "fr": "Vide = revenir au nom automatique :", + "en": "Empty = back to the automatic name:", + }, + "Invalid name: letters, digits, hyphens.": { + "fr": "Nom invalide : lettres, chiffres et traits d'union.", + "en": "Invalid name: letters, digits, hyphens.", + }, + "default; a VM may differ, see its line": { + "fr": "défaut ; une VM peut s'en écarter, voir sa ligne", + "en": "default; a VM may differ, see its line", + }, + "varies, see each line": { + "fr": "varie, voir chaque ligne", + "en": "varies, see each line", + }, + "From your workstation:": { + "fr": "Depuis votre poste :", + "en": "From your workstation:", + }, + "then point your client at": { + "fr": "puis pointez votre client sur", + "en": "then point your client at", + }, + "Remote desktop tunnel (VNC/RDP through SSH)": { + "fr": "🖥 Tunnel bureau distant (VNC/RDP par SSH)", + "en": "🖥 Remote desktop tunnel (VNC/RDP through SSH)", + }, + "Remote desktop tunnel": { + "fr": "Tunnel vers le bureau distant", + "en": "Remote desktop tunnel", + }, + "Which VM?": {"fr": "Quelle VM ?", "en": "Which VM?"}, + "No IP for this VM; is it running?": { + "fr": "Pas d'IP pour cette VM ; tourne-t-elle ?", + "en": "No IP for this VM; is it running?", + }, + "Run this on YOUR workstation:": { + "fr": "À lancer sur VOTRE poste :", + "en": "Run this on YOUR workstation:", + }, + "(through the ProxyJump already in ~/.ssh/config)": { + "fr": "(par le ProxyJump déjà dans ~/.ssh/config)", + "en": "(through the ProxyJump already in ~/.ssh/config)", + }, + "No ~/.ssh/config entry; see SSH configuration.": { + "fr": "Aucune entrée ~/.ssh/config ; voir Configuration SSH.", + "en": "No ~/.ssh/config entry; see SSH configuration.", + }, + "Not in an SSH session: check the host address.": { + "fr": "Hors session SSH : vérifiez l'adresse de l'hôte.", + "en": "Not in an SSH session: check the host address.", + }, + "The tunnel stays open as long as that ssh runs.": { + "fr": "Le tunnel reste ouvert tant que ce ssh tourne.", + "en": "The tunnel stays open as long as that ssh runs.", + }, + "No VM defined.": {"fr": "Aucune VM définie.", "en": "No VM defined."}, + "No host in ~/.ssh/config and no local VM.": { + "fr": "Aucun hôte dans ~/.ssh/config et aucune VM locale.", + "en": "No host in ~/.ssh/config and no local VM.", + }, + "local VM": {"fr": "VM locale", "en": "local VM"}, + "Remote desktop kind:": { + "fr": "Type de bureau distant :", + "en": "Remote desktop kind:", + }, + "already defined, not counted": { + "fr": "déjà définie(s), non comptée(s)", + "en": "already defined, not counted", + }, + "RAM: 2048 or 8G": {"fr": "RAM : 2048 ou 8G", "en": "RAM: 2048 or 8G"}, "Reset VM": { "fr": "Réinit. VM", "en": "Reset VM", }, - "Apply to all VMs": { - "fr": "Appliquer à toutes les VM", - "en": "Apply to all VMs", - }, - "Apply to the selected VM only": { - "fr": "Appliquer à la VM sélectionnée seulement", - "en": "Apply to the selected VM only", - }, - "VM(s) customised (F4 resets one)": { - "fr": "VM personnalisée(s) (F4 en réinitialise une)", - "en": "VM(s) customised (F4 resets one)", - }, - "Pick a line in the plan (Tab, then arrows)": { - "fr": "Choisissez une ligne du plan (Tab, puis les flèches)", - "en": "Pick a line in the plan (Tab, then arrows)", - }, "Preview": { "fr": "Aperçu", "en": "Preview",